diff --git a/.forgejo/workflows/backend.yml b/.forgejo/workflows/backend.yml index 34217c1..aef58b6 100644 --- a/.forgejo/workflows/backend.yml +++ b/.forgejo/workflows/backend.yml @@ -19,4 +19,6 @@ jobs: steps: - uses: actions/checkout@v4 - run: cd backend && uv sync + - run: cd backend && uv run ruff check . + - run: cd backend && uv run ruff format . --check --diff - run: cd backend && uv run pytest diff --git a/AGENTS.md b/AGENTS.md index cdbc871..60a6464 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,8 @@ Kirche Einfach — AI-assisted church administration. FastAPI backend (`backend/ Backend (run from `backend/`): - `uv sync` — install dependencies - `uv run pytest` — full test suite (throwaway SQLite file, no external DB needed) +- `uv run ruff check .` — lint (no findings allowed; only pre-exempt `EXE002`, see `[tool.ruff.lint.ignore]`) +- `uv run ruff format .` — code formatting (run after edits; CI checks with `--check --diff`) - `uv run alembic upgrade head` — migrate the dev database (`backend/data/`, gitignored) - `uv run python scripts/sync_flows.py --sync` — reconcile `backend/flows/` into DB + LFX - `uv run uvicorn app.main:app --port 8000` — run the API (paths resolve from repo root, CWD-independent) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9010e9..b5e6d5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 human release responsibility, honest pilot framing instead of invented testimonials; CTA now "Pilotgespräch vereinbaren"). - New photo `pilot-meeting.webp` (consultation scene, self-hosted). +- Backend lint + format standards via ruff (pinned dev dependency, + enforced in CI): `uv run ruff check .`, `uv run ruff format`. ## [0.1.0] - 2026-09-13 ### Added diff --git a/backend/app/auth/api.py b/backend/app/auth/api.py index b1f6c81..7880082 100644 --- a/backend/app/auth/api.py +++ b/backend/app/auth/api.py @@ -1,5 +1,6 @@ """Access + auth HTTP API (magic-link login, session cookie).""" +from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response @@ -68,11 +69,9 @@ def _set_session_cookie(response: Response, session_id: str) -> None: @access_router.post("/requests", response_model=AccessRequestRead, status_code=201) -def create_request(data: AccessRequestCreate, session: Session = Depends(get_db)): +def create_request(data: AccessRequestCreate, session: Annotated[Session, Depends(get_db)]): try: - request = service.create_access_request( - session, data.name, data.email, data.reason - ) + request = service.create_access_request(session, data.name, data.email, data.reason) except service.RateLimitedError as exc: raise HTTPException(status_code=429, detail=str(exc)) from exc mail.send_access_notification(str(request.id), request.name, request.email) @@ -81,9 +80,9 @@ def create_request(data: AccessRequestCreate, session: Session = Depends(get_db) @access_router.get("/requests", response_model=list[AccessRequestRead]) def list_requests( + session: Annotated[Session, Depends(get_db)], + admin: Annotated[User, Depends(require_admin)], status: str | None = Query(default=None), - session: Session = Depends(get_db), - admin: User = Depends(require_admin), ): return [_read_request(r) for r in service.list_access_requests(session, status)] @@ -91,13 +90,11 @@ def list_requests( @access_router.post("/requests/{request_id}/approve", response_model=AccessDecisionRead) def approve_request( request_id: UUID, - session: Session = Depends(get_db), - admin: User = Depends(require_admin), + session: Annotated[Session, Depends(get_db)], + admin: Annotated[User, Depends(require_admin)], ): try: - request, _user, token = service.approve_access_request( - session, request_id, admin.email - ) + request, _user, token = service.approve_access_request(session, request_id, admin.email) except service.NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except service.ConflictError as exc: @@ -110,8 +107,8 @@ def approve_request( @access_router.post("/requests/{request_id}/reject", response_model=AccessRequestRead) def reject_request( request_id: UUID, - session: Session = Depends(get_db), - admin: User = Depends(require_admin), + session: Annotated[Session, Depends(get_db)], + admin: Annotated[User, Depends(require_admin)], ): try: request = service.reject_access_request(session, request_id, admin.email) @@ -125,7 +122,7 @@ def reject_request( @auth_router.post("/magic/request", response_model=MagicRequestRead) def request_magic( data: MagicRequestRequest, - session: Session = Depends(get_db), + session: Annotated[Session, Depends(get_db)], ): """Email-first login: allowed emails get a magic link mailed. @@ -146,7 +143,7 @@ def request_magic( def consume_magic( data: MagicConsumeRequest, response: Response, - session: Session = Depends(get_db), + session: Annotated[Session, Depends(get_db)], ): try: user_session = service.consume_magic_token(session, data.token) @@ -164,7 +161,7 @@ def consume_magic( @auth_router.get("/me", response_model=MeRead) -def read_me(user: User = Depends(get_current_user)): +def read_me(user: Annotated[User, Depends(get_current_user)]): return _read_me(user) @@ -172,7 +169,7 @@ def read_me(user: User = Depends(get_current_user)): def logout( request: Request, response: Response, - session: Session = Depends(get_db), + session: Annotated[Session, Depends(get_db)], ): # Revoke the server-side row when the cookie maps to a live session; # always clear the client cookie. @@ -184,7 +181,7 @@ def logout( @auth_router.post("/admin-bootstrap", response_model=MessageRead, status_code=201) -def admin_bootstrap(data: AdminBootstrapRequest, session: Session = Depends(get_db)): +def admin_bootstrap(data: AdminBootstrapRequest, session: Annotated[Session, Depends(get_db)]): try: _user, token = service.bootstrap_admin(session, data.email) except service.ConflictError as exc: diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py index bdaa2ce..b090eb9 100644 --- a/backend/app/auth/deps.py +++ b/backend/app/auth/deps.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterator +from typing import Annotated from fastapi import Depends, HTTPException, Request from sqlmodel import Session @@ -21,7 +22,7 @@ def get_db() -> Iterator[Session]: def get_optional_user( - request: Request, session: Session = Depends(get_db) + request: Request, session: Annotated[Session, Depends(get_db)] ) -> User | None: session_id = request.cookies.get(SESSION_COOKIE) if not session_id: @@ -32,24 +33,20 @@ def get_optional_user( return service.get_user(session, record.user_id) -def get_current_user( - request: Request, session: Session = Depends(get_db) -) -> User: +def get_current_user(request: Request, session: Annotated[Session, Depends(get_db)]) -> User: user = get_optional_user(request, session) if user is None: raise HTTPException(status_code=401, detail="not authenticated") return user -def require_admin(user: User = Depends(get_current_user)) -> User: +def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User: if user.role != "admin": raise HTTPException(status_code=403, detail="admin only") return user -def resolve_tenant( - user: User | None, explicit: str | None, default: str = "default" -) -> str: +def resolve_tenant(user: User | None, explicit: str | None, default: str = "default") -> str: """Tenant comes from the session; the body/query param is a deprecated fallback for unauthenticated callers.""" if user is not None: diff --git a/backend/app/auth/mail.py b/backend/app/auth/mail.py index 8764f18..623f5f3 100644 --- a/backend/app/auth/mail.py +++ b/backend/app/auth/mail.py @@ -42,8 +42,8 @@ def _page(title: str, intro: str, button_label: str, button_url: str | None, not f'' f'{button_label}' ) return f""" @@ -106,14 +106,10 @@ def _branded_mail( message["From"] = settings.mail_from or settings.contact_to or to_email if settings.contact_to: message["Reply-To"] = settings.contact_to - message.set_content( - f"{headline}\n\n{intro}\n\n{button_label}:\n{button_url}\n\n{note}\n" - ) + message.set_content(f"{headline}\n\n{intro}\n\n{button_label}:\n{button_url}\n\n{note}\n") # Email-safe HTML: tables + inline CSS; some clients strip