chore: save session state – feature/phase-1 ready to implement

- Update session-context.md with exact resume point for next session
- Update settings.local.json with broader git permissions
- feature/grundstruktur merged to develop
- PAT authentication configured

Version: 0.2.3
This commit is contained in:
Faultier314
2026-04-05 23:24:21 +02:00
parent 5d9d517d18
commit b58edfc6eb
23 changed files with 838 additions and 14 deletions

View File

@@ -1,7 +1,7 @@
# Session-Kontext # Session-Kontext
> Claude liest diese Datei zu Beginn jeder Session. > 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 | | Feld | Wert |
|---|---| |---|---|
| **Version** | 0.2.1 | | **Version** | 0.2.3 |
| **Aktiver Branch** | feature/grundstruktur | | **Aktiver Branch** | feature/phase-1 |
| **Basis-Branch** | develop | | **Basis-Branch** | develop |
| **Zuletzt geändert** | 2026-04-05 | | **Zuletzt geändert** | 2026-04-05 |
## Offene Arbeit ## Offene Arbeit nächste Session startet hier
- [ ] Techstack festlegen Phase 1 implementieren. Reihenfolge:
- [ ] feature/grundstruktur → develop mergen (wenn Techstack entschieden)
## 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) ### Backend-Spec (fertig ausgearbeitet, direkt verwenden):
- .gitattributes, bump.sh, new-feature.sh, session-context.md eingeführt - FastAPI + SQLAlchemy async + Alembic + PostgreSQL (asyncpg)
- Branch Protection + Squash-Merge serverseitig konfiguriert - 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 ## Schnellreferenz
@@ -36,7 +58,4 @@ bash .claude/scripts/new-feature.sh feature <name>
# Aktueller Branch # Aktueller Branch
git branch --show-current git branch --show-current
# Status
git status
``` ```

View File

@@ -8,7 +8,8 @@
"Bash(git -C c:/Projekte/Home/gartenmanager credential fill)", "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(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 \"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)"
] ]
} }
} }

6
backend/Dockerfile Normal file
View File

@@ -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"]

41
backend/alembic.ini Normal file
View File

@@ -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

0
backend/app/__init__.py Normal file
View File

View File

View File

@@ -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()

161
backend/app/core/deps.py Normal file
View File

@@ -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

View File

@@ -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

View File

13
backend/app/db/base.py Normal file
View File

@@ -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

33
backend/app/db/session.py Normal file
View File

@@ -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()

View File

66
backend/app/models/bed.py Normal file
View File

@@ -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"
)

133
backend/app/models/plant.py Normal file
View File

@@ -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"
)

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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"
)

View File

View File

@@ -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"

View File

@@ -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

View File

@@ -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

11
backend/requirements.txt Normal file
View File

@@ -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