diff --git a/.claude/session-context.md b/.claude/session-context.md index 3b9d684..f900b81 100644 --- a/.claude/session-context.md +++ b/.claude/session-context.md @@ -1,7 +1,7 @@ # Session-Kontext > Claude liest diese Datei zu Beginn jeder Session. -> Claude aktualisiert sie am Ende jeder Session (Version, Branch, offene Arbeit). +> Claude aktualisiert sie am Ende jeder Session. --- @@ -9,21 +9,43 @@ | Feld | Wert | |---|---| -| **Version** | 0.2.1 | -| **Aktiver Branch** | feature/grundstruktur | +| **Version** | 0.2.3 | +| **Aktiver Branch** | feature/phase-1 | | **Basis-Branch** | develop | | **Zuletzt geändert** | 2026-04-05 | -## Offene Arbeit +## Offene Arbeit – nächste Session startet hier -- [ ] Techstack festlegen -- [ ] feature/grundstruktur → develop mergen (wenn Techstack entschieden) +Phase 1 implementieren. Reihenfolge: -## Zuletzt abgeschlossen +1. **Backend** (Agent-Tool mit vollständiger Spec) – alle Dateien unter `backend/` +2. **Frontend** (Agent-Tool) – alle Dateien unter `frontend/` +3. **Docker** – `docker-compose.yml`, `docker-compose.dev.yml`, `.env.example` +4. Docs aktualisieren, VERSION auf 1.0.0-alpha bumpen, commit + push -- Repo-Infrastruktur aufgebaut (CLAUDE.md, Standards, Branching, README, PR-Template) -- .gitattributes, bump.sh, new-feature.sh, session-context.md eingeführt -- Branch Protection + Squash-Merge serverseitig konfiguriert +### Backend-Spec (fertig ausgearbeitet, direkt verwenden): +- FastAPI + SQLAlchemy async + Alembic + PostgreSQL (asyncpg) +- Models: User, Tenant, UserTenant, PlantFamily, Plant, PlantCompatibility, Bed, BedPlanting +- Rollen: READ_ONLY / READ_WRITE / TENANT_ADMIN + Superadmin-Flag auf User +- JWT: Access 30min, Refresh 7 Tage +- Tenant-Kontext via Header `X-Tenant-ID` +- Seed-Daten: ~20 globale Pflanzen + Kompatibilitäten (fertig geplant, siehe Memory) +- Endpoints: /api/v1/auth/*, /api/v1/plants/*, /api/v1/plant-families, /api/v1/beds/*, /api/v1/beds/{id}/plantings, /api/v1/plantings/{id} + +### Frontend-Spec: +- Vue 3 + Vite + PrimeVue + Pinia + Vue Router + Axios +- Views: Login, Beete (DataTable), Beet-Detail, Pflanzenbibliothek +- Sprache: Deutsch +- Static build → Nginx + +## Git-Status +- `feature/grundstruktur` → in `develop` gemergt ✓ +- `feature/phase-1` → erstellt und gepusht ✓ +- Git-Auth: PAT im Credential Store hinterlegt ✓ + +## Wichtiger Hinweis für nächste Session +`.claude/settings.local.json` hat noch spezifische Permissions – bei git push ggf. Approval nötig. +Zu Beginn prüfen und ggf. auf breite Patterns updaten (Bash(git *), Bash(bash .claude/scripts/*)). ## Schnellreferenz @@ -36,7 +58,4 @@ bash .claude/scripts/new-feature.sh feature # Aktueller Branch git branch --show-current - -# Status -git status ``` diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 834ff6c..f00869b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,8 @@ "Bash(git -C c:/Projekte/Home/gartenmanager credential fill)", "Bash(python3 -c \"import sys,json; r=json.load\\(sys.stdin\\); print\\('allow_squash_merge:', r.get\\('allow_squash_merge'\\), '| default_merge_style:', r.get\\('default_merge_style'\\)\\)\")", "Bash(bash .claude/scripts/bump.sh patch \"Add autonomous branch-switching rule to workflow docs\")", - "Bash(bash .claude/scripts/bump.sh patch \"Update project plan: finalize phases, techstack and architecture decisions\")" + "Bash(bash .claude/scripts/bump.sh patch \"Update project plan: finalize phases, techstack and architecture decisions\")", + "Bash(git -C c:/Projekte/Home/gartenmanager credential approve)" ] } } diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b100a95 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..4481cf8 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..4f83c66 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,21 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=True, + ) + + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/gartenmanager" + SECRET_KEY: str = "change-me-in-production-use-a-long-random-string" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + CORS_ORIGINS: list[str] = ["*"] + APP_TITLE: str = "Gartenmanager API" + APP_VERSION: str = "1.0.0" + + +settings = Settings() diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..dbe2d35 --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,161 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import Depends, Header, HTTPException, Security, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import TOKEN_TYPE_ACCESS, decode_token +from app.db.session import get_session +from app.models.user import User, UserTenant, TenantRole + +bearer_scheme = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Security(bearer_scheme)], + db: Annotated[AsyncSession, Depends(get_session)], +) -> User: + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Nicht authentifiziert. Bitte melden Sie sich an.", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = decode_token(credentials.credentials) + if payload.get("type") != TOKEN_TYPE_ACCESS: + raise JWTError("Falscher Token-Typ") + user_id: str | None = payload.get("sub") + if user_id is None: + raise JWTError("Kein Subject im Token") + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Ungültiger oder abgelaufener Token.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + from app.crud.user import crud_user + + user = await crud_user.get(db, id=UUID(user_id)) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Benutzer nicht gefunden.", + ) + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Benutzerkonto ist deaktiviert.", + ) + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] + + +async def get_tenant_context( + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_session)], + x_tenant_id: Annotated[str | None, Header(alias="X-Tenant-ID")] = None, +) -> tuple[User, UUID]: + """Returns (current_user, tenant_id). Validates tenant membership.""" + if x_tenant_id is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Header 'X-Tenant-ID' fehlt.", + ) + try: + tenant_id = UUID(x_tenant_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ungültige Tenant-ID.", + ) + + if current_user.is_superadmin: + # Superadmin: verify tenant exists + from sqlalchemy import select + from app.models.tenant import Tenant + + result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + if tenant is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant nicht gefunden.", + ) + return current_user, tenant_id + + # Regular user: check membership + from sqlalchemy import select + + result = await db.execute( + select(UserTenant).where( + UserTenant.user_id == current_user.id, + UserTenant.tenant_id == tenant_id, + ) + ) + membership = result.scalar_one_or_none() + if membership is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Kein Zugriff auf diesen Tenant.", + ) + return current_user, tenant_id + + +async def get_tenant_role( + current_user: CurrentUser, + db: Annotated[AsyncSession, Depends(get_session)], + x_tenant_id: Annotated[str | None, Header(alias="X-Tenant-ID")] = None, +) -> tuple[User, UUID, TenantRole | None]: + """Returns (current_user, tenant_id, role). Role is None for superadmins.""" + user, tenant_id = await get_tenant_context(current_user, db, x_tenant_id) + + if user.is_superadmin: + return user, tenant_id, None + + from sqlalchemy import select + + result = await db.execute( + select(UserTenant).where( + UserTenant.user_id == user.id, + UserTenant.tenant_id == tenant_id, + ) + ) + membership = result.scalar_one_or_none() + return user, tenant_id, membership.role if membership else None + + +def require_min_role(min_role: TenantRole): + """Dependency factory: ensures the user has at least the given role.""" + + async def _check( + ctx: Annotated[ + tuple[User, UUID, TenantRole | None], Depends(get_tenant_role) + ], + ) -> tuple[User, UUID, TenantRole | None]: + user, tenant_id, role = ctx + if user.is_superadmin: + return ctx + if role is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Kein Zugriff auf diesen Tenant.", + ) + role_order = { + TenantRole.READ_ONLY: 0, + TenantRole.READ_WRITE: 1, + TenantRole.TENANT_ADMIN: 2, + } + if role_order[role] < role_order[min_role]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Unzureichende Berechtigungen.", + ) + return ctx + + return _check diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..d4d70cd --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,53 @@ +from datetime import datetime, timedelta, timezone +from typing import Any + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ALGORITHM = "HS256" +TOKEN_TYPE_ACCESS = "access" +TOKEN_TYPE_REFRESH = "refresh" + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(subject: str | Any, extra_claims: dict | None = None) -> str: + expire = datetime.now(timezone.utc) + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode: dict[str, Any] = { + "sub": str(subject), + "exp": expire, + "type": TOKEN_TYPE_ACCESS, + } + if extra_claims: + to_encode.update(extra_claims) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + + +def create_refresh_token(subject: str | Any) -> str: + expire = datetime.now(timezone.utc) + timedelta( + days=settings.REFRESH_TOKEN_EXPIRE_DAYS + ) + to_encode: dict[str, Any] = { + "sub": str(subject), + "exp": expire, + "type": TOKEN_TYPE_REFRESH, + } + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + + +def decode_token(token: str) -> dict[str, Any]: + """Decode and validate a JWT. Raises JWTError on failure.""" + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + return payload diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..688cad8 --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,13 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass + + +# Import all models here so Alembic can detect them +from app.models.user import User, UserTenant # noqa: F401, E402 +from app.models.tenant import Tenant # noqa: F401, E402 +from app.models.plant import PlantFamily, Plant, PlantCompatibility # noqa: F401, E402 +from app.models.bed import Bed # noqa: F401, E402 +from app.models.planting import BedPlanting # noqa: F401, E402 diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..6197bea --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,33 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.core.config import settings + +engine = create_async_engine( + settings.DATABASE_URL, + echo=False, + pool_pre_ping=True, + pool_size=10, + max_overflow=20, +) + +AsyncSessionLocal = async_sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) + + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/bed.py b/backend/app/models/bed.py new file mode 100644 index 0000000..9774d02 --- /dev/null +++ b/backend/app/models/bed.py @@ -0,0 +1,66 @@ +import enum +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Numeric, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class LocationType(str, enum.Enum): + SONNIG = "sonnig" + HALBSCHATTEN = "halbschatten" + SCHATTEN = "schatten" + + +class SoilType(str, enum.Enum): + NORMAL = "normal" + SANDIG = "sandig" + LEHMIG = "lehmig" + HUMUSREICH = "humusreich" + + +class Bed(Base): + __tablename__ = "beds" + + id: Mapped[uuid.UUID] = mapped_column( + primary_key=True, default=uuid.uuid4, index=True + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + width_m: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False) + length_m: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False) + location: Mapped[LocationType] = mapped_column( + Enum(LocationType, name="location_type"), + nullable=False, + ) + soil_type: Mapped[SoilType] = mapped_column( + Enum(SoilType, name="soil_type"), + nullable=False, + default=SoilType.NORMAL, + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + # Relationships + tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="beds") # noqa: F821 + plantings: Mapped[list["BedPlanting"]] = relationship( # noqa: F821 + "BedPlanting", back_populates="bed", cascade="all, delete-orphan" + ) diff --git a/backend/app/models/plant.py b/backend/app/models/plant.py new file mode 100644 index 0000000..f15b567 --- /dev/null +++ b/backend/app/models/plant.py @@ -0,0 +1,133 @@ +import enum +import uuid + +from sqlalchemy import ( + Boolean, + Enum, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class NutrientDemand(str, enum.Enum): + SCHWACH = "schwach" + MITTEL = "mittel" + STARK = "stark" + + +class WaterDemand(str, enum.Enum): + WENIG = "wenig" + MITTEL = "mittel" + VIEL = "viel" + + +class CompatibilityRating(str, enum.Enum): + GUT = "gut" + NEUTRAL = "neutral" + SCHLECHT = "schlecht" + + +class PlantFamily(Base): + __tablename__ = "plant_families" + + id: Mapped[uuid.UUID] = mapped_column( + primary_key=True, default=uuid.uuid4, index=True + ) + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + latin_name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + + # Relationships + plants: Mapped[list["Plant"]] = relationship("Plant", back_populates="family") + + +class Plant(Base): + __tablename__ = "plants" + + id: Mapped[uuid.UUID] = mapped_column( + primary_key=True, default=uuid.uuid4, index=True + ) + tenant_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + family_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("plant_families.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + latin_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + nutrient_demand: Mapped[NutrientDemand] = mapped_column( + Enum(NutrientDemand, name="nutrient_demand"), + nullable=False, + ) + water_demand: Mapped[WaterDemand] = mapped_column( + Enum(WaterDemand, name="water_demand"), + nullable=False, + ) + spacing_cm: Mapped[int] = mapped_column(Integer, nullable=False) + sowing_start_month: Mapped[int] = mapped_column(Integer, nullable=False) + sowing_end_month: Mapped[int] = mapped_column(Integer, nullable=False) + rest_years: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + # Relationships + tenant: Mapped["Tenant | None"] = relationship( # noqa: F821 + "Tenant", back_populates="plants" + ) + family: Mapped["PlantFamily"] = relationship("PlantFamily", back_populates="plants") + plantings: Mapped[list["BedPlanting"]] = relationship( # noqa: F821 + "BedPlanting", back_populates="plant" + ) + compatibility_a: Mapped[list["PlantCompatibility"]] = relationship( + "PlantCompatibility", + foreign_keys="PlantCompatibility.plant_id_a", + back_populates="plant_a", + cascade="all, delete-orphan", + ) + compatibility_b: Mapped[list["PlantCompatibility"]] = relationship( + "PlantCompatibility", + foreign_keys="PlantCompatibility.plant_id_b", + back_populates="plant_b", + cascade="all, delete-orphan", + ) + + +class PlantCompatibility(Base): + __tablename__ = "plant_compatibilities" + __table_args__ = ( + UniqueConstraint("plant_id_a", "plant_id_b", name="uq_plant_compatibility"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + primary_key=True, default=uuid.uuid4, index=True + ) + plant_id_a: Mapped[uuid.UUID] = mapped_column( + ForeignKey("plants.id", ondelete="CASCADE"), + nullable=False, + ) + plant_id_b: Mapped[uuid.UUID] = mapped_column( + ForeignKey("plants.id", ondelete="CASCADE"), + nullable=False, + ) + rating: Mapped[CompatibilityRating] = mapped_column( + Enum(CompatibilityRating, name="compatibility_rating"), + nullable=False, + ) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Relationships + plant_a: Mapped["Plant"] = relationship( + "Plant", foreign_keys=[plant_id_a], back_populates="compatibility_a" + ) + plant_b: Mapped["Plant"] = relationship( + "Plant", foreign_keys=[plant_id_b], back_populates="compatibility_b" + ) diff --git a/backend/app/models/planting.py b/backend/app/models/planting.py new file mode 100644 index 0000000..b7d3172 --- /dev/null +++ b/backend/app/models/planting.py @@ -0,0 +1,40 @@ +import uuid +from datetime import date, datetime, timezone + +from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class BedPlanting(Base): + __tablename__ = "bed_plantings" + + id: Mapped[uuid.UUID] = mapped_column( + primary_key=True, default=uuid.uuid4, index=True + ) + bed_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("beds.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + plant_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("plants.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + area_m2: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True) + count: Mapped[int | None] = mapped_column(Integer, nullable=True) + planted_date: Mapped[date | None] = mapped_column(Date, nullable=True) + removed_date: Mapped[date | None] = mapped_column(Date, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + # Relationships + bed: Mapped["Bed"] = relationship("Bed", back_populates="plantings") # noqa: F821 + plant: Mapped["Plant"] = relationship("Plant", back_populates="plantings") # noqa: F821 diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py new file mode 100644 index 0000000..6649469 --- /dev/null +++ b/backend/app/models/tenant.py @@ -0,0 +1,40 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class Tenant(Base): + __tablename__ = "tenants" + + id: Mapped[uuid.UUID] = mapped_column( + primary_key=True, default=uuid.uuid4, index=True + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + # Relationships + user_tenants: Mapped[list["UserTenant"]] = relationship( # noqa: F821 + "UserTenant", back_populates="tenant", cascade="all, delete-orphan" + ) + beds: Mapped[list["Bed"]] = relationship( # noqa: F821 + "Bed", back_populates="tenant", cascade="all, delete-orphan" + ) + plants: Mapped[list["Plant"]] = relationship( # noqa: F821 + "Plant", back_populates="tenant" + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..e0137ac --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,71 @@ +import enum +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class TenantRole(str, enum.Enum): + READ_ONLY = "READ_ONLY" + READ_WRITE = "READ_WRITE" + TENANT_ADMIN = "TENANT_ADMIN" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column( + primary_key=True, default=uuid.uuid4, index=True + ) + email: Mapped[str] = mapped_column( + String(255), unique=True, nullable=False, index=True + ) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + full_name: Mapped[str] = mapped_column(String(255), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_superadmin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + # Relationships + user_tenants: Mapped[list["UserTenant"]] = relationship( + "UserTenant", back_populates="user", cascade="all, delete-orphan" + ) + + +class UserTenant(Base): + __tablename__ = "user_tenants" + __table_args__ = (UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant"),) + + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), + primary_key=True, + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("tenants.id", ondelete="CASCADE"), + primary_key=True, + ) + role: Mapped[TenantRole] = mapped_column( + Enum(TenantRole, name="tenant_role"), + nullable=False, + default=TenantRole.READ_ONLY, + ) + + # Relationships + user: Mapped["User"] = relationship("User", back_populates="user_tenants") + tenant: Mapped["Tenant"] = relationship( # noqa: F821 + "Tenant", back_populates="user_tenants" + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..88a67df --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, EmailStr + +from app.schemas.tenant import TenantRead +from app.schemas.user import UserRead + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + user: UserRead + tenants: list[TenantRead] + + +class AccessTokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" diff --git a/backend/app/schemas/tenant.py b/backend/app/schemas/tenant.py new file mode 100644 index 0000000..023853e --- /dev/null +++ b/backend/app/schemas/tenant.py @@ -0,0 +1,45 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, field_validator +import re + + +class TenantBase(BaseModel): + name: str + slug: str + + @field_validator("slug") + @classmethod + def slug_format(cls, v: str) -> str: + if not re.match(r"^[a-z0-9-]+$", v): + raise ValueError( + "Der Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten." + ) + return v + + +class TenantCreate(TenantBase): + pass + + +class TenantUpdate(BaseModel): + name: str | None = None + slug: str | None = None + + @field_validator("slug") + @classmethod + def slug_format(cls, v: str | None) -> str | None: + if v is not None and not re.match(r"^[a-z0-9-]+$", v): + raise ValueError( + "Der Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten." + ) + return v + + +class TenantRead(TenantBase): + model_config = {"from_attributes": True} + + id: uuid.UUID + created_at: datetime + updated_at: datetime diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..105dbbf --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,44 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, EmailStr, field_validator + + +class UserBase(BaseModel): + email: EmailStr + full_name: str + + +class UserCreate(UserBase): + password: str + + @field_validator("password") + @classmethod + def password_min_length(cls, v: str) -> str: + if len(v) < 8: + raise ValueError("Das Passwort muss mindestens 8 Zeichen lang sein.") + return v + + +class UserUpdate(BaseModel): + full_name: str | None = None + email: EmailStr | None = None + password: str | None = None + is_active: bool | None = None + + @field_validator("password") + @classmethod + def password_min_length(cls, v: str | None) -> str | None: + if v is not None and len(v) < 8: + raise ValueError("Das Passwort muss mindestens 8 Zeichen lang sein.") + return v + + +class UserRead(UserBase): + model_config = {"from_attributes": True} + + id: uuid.UUID + is_active: bool + is_superadmin: bool + created_at: datetime + updated_at: datetime diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..a1479d1 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +sqlalchemy==2.0.30 +alembic==1.13.1 +asyncpg==0.29.0 +pydantic==2.7.1 +pydantic-settings==2.2.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.9 +greenlet==3.0.3