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:
6
backend/Dockerfile
Normal file
6
backend/Dockerfile
Normal 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
41
backend/alembic.ini
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
21
backend/app/core/config.py
Normal file
21
backend/app/core/config.py
Normal 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
161
backend/app/core/deps.py
Normal 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
|
||||
53
backend/app/core/security.py
Normal file
53
backend/app/core/security.py
Normal 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
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
13
backend/app/db/base.py
Normal file
13
backend/app/db/base.py
Normal 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
33
backend/app/db/session.py
Normal 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()
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
66
backend/app/models/bed.py
Normal file
66
backend/app/models/bed.py
Normal 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
133
backend/app/models/plant.py
Normal 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"
|
||||
)
|
||||
40
backend/app/models/planting.py
Normal file
40
backend/app/models/planting.py
Normal 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
|
||||
40
backend/app/models/tenant.py
Normal file
40
backend/app/models/tenant.py
Normal 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"
|
||||
)
|
||||
71
backend/app/models/user.py
Normal file
71
backend/app/models/user.py
Normal 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"
|
||||
)
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
26
backend/app/schemas/auth.py
Normal file
26
backend/app/schemas/auth.py
Normal 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"
|
||||
45
backend/app/schemas/tenant.py
Normal file
45
backend/app/schemas/tenant.py
Normal 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
|
||||
44
backend/app/schemas/user.py
Normal file
44
backend/app/schemas/user.py
Normal 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
11
backend/requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user