feat: Phase 1 complete – full working application

Backend (FastAPI):
- REST API: auth, plants, beds, plantings
- CRUD layer with CRUDBase
- Pydantic v2 schemas for all entities
- Alembic migration: complete schema + all enums
- Seed data: 28 global plants + 15 compatibilities

Frontend (Vue 3 + PrimeVue):
- Axios client with JWT interceptor + auto-refresh
- Pinia stores: auth, beds, plants
- Views: Login, Beds, BedDetail, PlantLibrary
- Components: AppLayout, BedForm, PlantingForm, PlantForm

Docker:
- docker-compose.yml (production)
- docker-compose.dev.yml (development with hot-reload)
- Nginx config with SPA fallback + API proxy
- Multi-stage frontend Dockerfile
- .env.example, .gitignore

Version: 1.0.0-alpha
This commit is contained in:
Faultier314
2026-04-06 07:45:00 +02:00
parent 905115d115
commit 834a3bf4d5
51 changed files with 2918 additions and 100 deletions

59
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,59 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
from app.core.config import settings # noqa: E402
from app.db.base import Base # noqa: E402, F401 imports all models via __init__
target_metadata = Base.metadata
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,155 @@
"""initial schema
Revision ID: 001
Revises:
Create Date: 2026-04-06
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
import sqlalchemy.dialects.postgresql as pg
revision: str = "001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Enums
op.execute("CREATE TYPE tenant_role AS ENUM ('READ_ONLY', 'READ_WRITE', 'TENANT_ADMIN')")
op.execute("CREATE TYPE nutrient_demand AS ENUM ('schwach', 'mittel', 'stark')")
op.execute("CREATE TYPE water_demand AS ENUM ('wenig', 'mittel', 'viel')")
op.execute("CREATE TYPE compatibility_rating AS ENUM ('gut', 'neutral', 'schlecht')")
op.execute("CREATE TYPE location_type AS ENUM ('sonnig', 'halbschatten', 'schatten')")
op.execute("CREATE TYPE soil_type AS ENUM ('normal', 'sandig', 'lehmig', 'humusreich')")
# users
op.create_table(
"users",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
sa.Column("email", sa.String(255), nullable=False, unique=True),
sa.Column("hashed_password", sa.String(255), nullable=False),
sa.Column("full_name", sa.String(255), nullable=False),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
sa.Column("is_superadmin", sa.Boolean, nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_users_id", "users", ["id"])
op.create_index("ix_users_email", "users", ["email"])
# tenants
op.create_table(
"tenants",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("slug", sa.String(100), nullable=False, unique=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_tenants_id", "tenants", ["id"])
op.create_index("ix_tenants_slug", "tenants", ["slug"])
# user_tenants
op.create_table(
"user_tenants",
sa.Column("user_id", pg.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
sa.Column("tenant_id", pg.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True),
sa.Column("role", sa.Enum("READ_ONLY", "READ_WRITE", "TENANT_ADMIN", name="tenant_role", create_type=False), nullable=False),
sa.UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant"),
)
# plant_families
op.create_table(
"plant_families",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(255), nullable=False, unique=True),
sa.Column("latin_name", sa.String(255), nullable=False, unique=True),
)
op.create_index("ix_plant_families_id", "plant_families", ["id"])
# plants
op.create_table(
"plants",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
sa.Column("tenant_id", pg.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("family_id", pg.UUID(as_uuid=True), sa.ForeignKey("plant_families.id", ondelete="RESTRICT"), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("latin_name", sa.String(255), nullable=True),
sa.Column("nutrient_demand", sa.Enum("schwach", "mittel", "stark", name="nutrient_demand", create_type=False), nullable=False),
sa.Column("water_demand", sa.Enum("wenig", "mittel", "viel", name="water_demand", create_type=False), nullable=False),
sa.Column("spacing_cm", sa.Integer, nullable=False),
sa.Column("sowing_start_month", sa.Integer, nullable=False),
sa.Column("sowing_end_month", sa.Integer, nullable=False),
sa.Column("rest_years", sa.Integer, nullable=False, server_default="0"),
sa.Column("notes", sa.Text, nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
)
op.create_index("ix_plants_id", "plants", ["id"])
op.create_index("ix_plants_tenant_id", "plants", ["tenant_id"])
op.create_index("ix_plants_family_id", "plants", ["family_id"])
# plant_compatibilities
op.create_table(
"plant_compatibilities",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
sa.Column("plant_id_a", pg.UUID(as_uuid=True), sa.ForeignKey("plants.id", ondelete="CASCADE"), nullable=False),
sa.Column("plant_id_b", pg.UUID(as_uuid=True), sa.ForeignKey("plants.id", ondelete="CASCADE"), nullable=False),
sa.Column("rating", sa.Enum("gut", "neutral", "schlecht", name="compatibility_rating", create_type=False), nullable=False),
sa.Column("reason", sa.Text, nullable=True),
sa.UniqueConstraint("plant_id_a", "plant_id_b", name="uq_plant_compatibility"),
)
# beds
op.create_table(
"beds",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
sa.Column("tenant_id", pg.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("width_m", sa.Numeric(5, 2), nullable=False),
sa.Column("length_m", sa.Numeric(5, 2), nullable=False),
sa.Column("location", sa.Enum("sonnig", "halbschatten", "schatten", name="location_type", create_type=False), nullable=False),
sa.Column("soil_type", sa.Enum("normal", "sandig", "lehmig", "humusreich", name="soil_type", create_type=False), nullable=False, server_default="normal"),
sa.Column("notes", sa.Text, nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_beds_id", "beds", ["id"])
op.create_index("ix_beds_tenant_id", "beds", ["tenant_id"])
# bed_plantings
op.create_table(
"bed_plantings",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
sa.Column("bed_id", pg.UUID(as_uuid=True), sa.ForeignKey("beds.id", ondelete="CASCADE"), nullable=False),
sa.Column("plant_id", pg.UUID(as_uuid=True), sa.ForeignKey("plants.id", ondelete="RESTRICT"), nullable=False),
sa.Column("area_m2", sa.Numeric(5, 2), nullable=True),
sa.Column("count", sa.Integer, nullable=True),
sa.Column("planted_date", sa.Date, nullable=True),
sa.Column("removed_date", sa.Date, nullable=True),
sa.Column("notes", sa.Text, nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_bed_plantings_id", "bed_plantings", ["id"])
op.create_index("ix_bed_plantings_bed_id", "bed_plantings", ["bed_id"])
op.create_index("ix_bed_plantings_plant_id", "bed_plantings", ["plant_id"])
def downgrade() -> None:
op.drop_table("bed_plantings")
op.drop_table("beds")
op.drop_table("plant_compatibilities")
op.drop_table("plants")
op.drop_table("plant_families")
op.drop_table("user_tenants")
op.drop_table("tenants")
op.drop_table("users")
op.execute("DROP TYPE IF EXISTS soil_type")
op.execute("DROP TYPE IF EXISTS location_type")
op.execute("DROP TYPE IF EXISTS compatibility_rating")
op.execute("DROP TYPE IF EXISTS water_demand")
op.execute("DROP TYPE IF EXISTS nutrient_demand")
op.execute("DROP TYPE IF EXISTS tenant_role")

View File

View File

View File

@@ -0,0 +1,68 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from jose import JWTError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import CurrentUser, get_session
from app.core.security import TOKEN_TYPE_REFRESH, create_access_token, create_refresh_token, decode_token
from app.crud.user import crud_user
from app.schemas.auth import AccessTokenResponse, LoginRequest, RefreshRequest, TokenResponse
from app.schemas.user import UserRead
router = APIRouter(prefix="/auth", tags=["Authentifizierung"])
@router.post("/login", response_model=TokenResponse)
async def login(
body: LoginRequest,
db: Annotated[AsyncSession, Depends(get_session)],
) -> TokenResponse:
user = await crud_user.authenticate(db, email=body.email, password=body.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="E-Mail oder Passwort falsch.",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Benutzerkonto ist deaktiviert.",
)
tenants = await crud_user.get_tenants(db, user_id=user.id)
return TokenResponse(
access_token=create_access_token(str(user.id)),
refresh_token=create_refresh_token(str(user.id)),
user=UserRead.model_validate(user),
tenants=tenants,
)
@router.post("/refresh", response_model=AccessTokenResponse)
async def refresh_token(
body: RefreshRequest,
db: Annotated[AsyncSession, Depends(get_session)],
) -> AccessTokenResponse:
try:
payload = decode_token(body.refresh_token)
if payload.get("type") != TOKEN_TYPE_REFRESH:
raise JWTError("Falscher Token-Typ")
user_id: str = payload["sub"]
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Ungültiger oder abgelaufener Refresh-Token.",
)
from uuid import UUID
user = await crud_user.get(db, id=UUID(user_id))
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Benutzer nicht gefunden oder deaktiviert.",
)
return AccessTokenResponse(access_token=create_access_token(user_id))
@router.get("/me", response_model=UserRead)
async def get_me(current_user: CurrentUser) -> UserRead:
return UserRead.model_validate(current_user)

View File

@@ -0,0 +1,78 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_session, get_tenant_context, require_min_role
from app.crud.bed import crud_bed
from app.models.user import TenantRole
from app.schemas.bed import BedCreate, BedDetailRead, BedRead, BedUpdate
router = APIRouter(prefix="/beds", tags=["Beete"])
TenantCtx = Annotated[tuple, Depends(get_tenant_context)]
WriteCtx = Annotated[tuple, Depends(require_min_role(TenantRole.READ_WRITE))]
AdminCtx = Annotated[tuple, Depends(require_min_role(TenantRole.TENANT_ADMIN))]
@router.get("", response_model=list[BedRead])
async def list_beds(
db: Annotated[AsyncSession, Depends(get_session)],
ctx: TenantCtx,
) -> list[BedRead]:
_, tenant_id = ctx
beds = await crud_bed.get_multi_for_tenant(db, tenant_id=tenant_id)
return [BedRead.model_validate(b) for b in beds]
@router.get("/{bed_id}", response_model=BedDetailRead)
async def get_bed(
bed_id: UUID,
db: Annotated[AsyncSession, Depends(get_session)],
ctx: TenantCtx,
) -> BedDetailRead:
_, tenant_id = ctx
bed = await crud_bed.get_with_plantings(db, id=bed_id)
if not bed or bed.tenant_id != tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Beet nicht gefunden.")
return BedDetailRead.model_validate(bed)
@router.post("", response_model=BedRead, status_code=status.HTTP_201_CREATED)
async def create_bed(
body: BedCreate,
db: Annotated[AsyncSession, Depends(get_session)],
ctx: WriteCtx,
) -> BedRead:
_, tenant_id, _ = ctx
bed = await crud_bed.create_for_tenant(db, obj_in=body, tenant_id=tenant_id)
return BedRead.model_validate(bed)
@router.put("/{bed_id}", response_model=BedRead)
async def update_bed(
bed_id: UUID,
body: BedUpdate,
db: Annotated[AsyncSession, Depends(get_session)],
ctx: WriteCtx,
) -> BedRead:
_, tenant_id, _ = ctx
bed = await crud_bed.get(db, id=bed_id)
if not bed or bed.tenant_id != tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Beet nicht gefunden.")
updated = await crud_bed.update(db, db_obj=bed, obj_in=body)
return BedRead.model_validate(updated)
@router.delete("/{bed_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_bed(
bed_id: UUID,
db: Annotated[AsyncSession, Depends(get_session)],
ctx: AdminCtx,
) -> None:
_, tenant_id, _ = ctx
bed = await crud_bed.get(db, id=bed_id)
if not bed or bed.tenant_id != tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Beet nicht gefunden.")
await crud_bed.remove(db, id=bed_id)

View File

@@ -0,0 +1,78 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_session, get_tenant_context, require_min_role
from app.crud.bed import crud_bed
from app.crud.planting import crud_planting
from app.models.user import TenantRole
from app.schemas.planting import PlantingCreate, PlantingRead, PlantingUpdate
router = APIRouter(tags=["Bepflanzungen"])
TenantCtx = Annotated[tuple, Depends(get_tenant_context)]
WriteCtx = Annotated[tuple, Depends(require_min_role(TenantRole.READ_WRITE))]
async def _get_bed_or_404(db: AsyncSession, bed_id: UUID, tenant_id: UUID):
bed = await crud_bed.get(db, id=bed_id)
if not bed or bed.tenant_id != tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Beet nicht gefunden.")
return bed
@router.get("/beds/{bed_id}/plantings", response_model=list[PlantingRead])
async def list_plantings(
bed_id: UUID,
db: Annotated[AsyncSession, Depends(get_session)],
ctx: TenantCtx,
) -> list[PlantingRead]:
_, tenant_id = ctx
await _get_bed_or_404(db, bed_id, tenant_id)
plantings = await crud_planting.get_multi_for_bed(db, bed_id=bed_id)
return [PlantingRead.model_validate(p) for p in plantings]
@router.post("/beds/{bed_id}/plantings", response_model=PlantingRead, status_code=status.HTTP_201_CREATED)
async def create_planting(
bed_id: UUID,
body: PlantingCreate,
db: Annotated[AsyncSession, Depends(get_session)],
ctx: WriteCtx,
) -> PlantingRead:
_, tenant_id, _ = ctx
await _get_bed_or_404(db, bed_id, tenant_id)
planting = await crud_planting.create_for_bed(db, obj_in=body, bed_id=bed_id)
return PlantingRead.model_validate(planting)
@router.put("/plantings/{planting_id}", response_model=PlantingRead)
async def update_planting(
planting_id: UUID,
body: PlantingUpdate,
db: Annotated[AsyncSession, Depends(get_session)],
ctx: WriteCtx,
) -> PlantingRead:
_, tenant_id, _ = ctx
planting = await crud_planting.get(db, id=planting_id)
if not planting:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bepflanzung nicht gefunden.")
await _get_bed_or_404(db, planting.bed_id, tenant_id)
updated = await crud_planting.update(db, db_obj=planting, obj_in=body)
return PlantingRead.model_validate(updated)
@router.delete("/plantings/{planting_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_planting(
planting_id: UUID,
db: Annotated[AsyncSession, Depends(get_session)],
ctx: WriteCtx,
) -> None:
_, tenant_id, _ = ctx
planting = await crud_planting.get(db, id=planting_id)
if not planting:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bepflanzung nicht gefunden.")
await _get_bed_or_404(db, planting.bed_id, tenant_id)
await crud_planting.remove(db, id=planting_id)

View File

@@ -0,0 +1,95 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_session, get_tenant_context, require_min_role
from app.crud.plant import crud_plant, crud_plant_family
from app.models.user import TenantRole
from app.schemas.plant import PlantCreate, PlantFamilyRead, PlantRead, PlantUpdate
router = APIRouter(tags=["Pflanzen"])
TenantCtx = Annotated[tuple, Depends(get_tenant_context)]
WriteCtx = Annotated[tuple, Depends(require_min_role(TenantRole.READ_WRITE))]
@router.get("/plant-families", response_model=list[PlantFamilyRead])
async def list_plant_families(
db: Annotated[AsyncSession, Depends(get_session)],
_: TenantCtx,
) -> list[PlantFamilyRead]:
families = await crud_plant_family.get_all(db)
return [PlantFamilyRead.model_validate(f) for f in families]
@router.get("/plants", response_model=list[PlantRead])
async def list_plants(
db: Annotated[AsyncSession, Depends(get_session)],
ctx: TenantCtx,
) -> list[PlantRead]:
_, tenant_id = ctx
plants = await crud_plant.get_multi_for_tenant(db, tenant_id=tenant_id)
return [PlantRead.model_validate(p) for p in plants]
@router.get("/plants/{plant_id}", response_model=PlantRead)
async def get_plant(
plant_id: UUID,
db: Annotated[AsyncSession, Depends(get_session)],
_: TenantCtx,
) -> PlantRead:
plant = await crud_plant.get(db, id=plant_id)
if not plant:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pflanze nicht gefunden.")
return PlantRead.model_validate(plant)
@router.post("/plants", response_model=PlantRead, status_code=status.HTTP_201_CREATED)
async def create_plant(
body: PlantCreate,
db: Annotated[AsyncSession, Depends(get_session)],
ctx: WriteCtx,
) -> PlantRead:
_, tenant_id, _ = ctx
plant = await crud_plant.create_for_tenant(db, obj_in=body, tenant_id=tenant_id)
return PlantRead.model_validate(plant)
@router.put("/plants/{plant_id}", response_model=PlantRead)
async def update_plant(
plant_id: UUID,
body: PlantUpdate,
db: Annotated[AsyncSession, Depends(get_session)],
ctx: WriteCtx,
) -> PlantRead:
user, tenant_id, _ = ctx
plant = await crud_plant.get(db, id=plant_id)
if not plant:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pflanze nicht gefunden.")
# Global plants: only superadmin
if plant.tenant_id is None and not user.is_superadmin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Globale Pflanzen können nur von Superadmins bearbeitet werden.")
# Tenant plants: must belong to current tenant
if plant.tenant_id is not None and plant.tenant_id != tenant_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Kein Zugriff auf diese Pflanze.")
updated = await crud_plant.update(db, db_obj=plant, obj_in=body)
return PlantRead.model_validate(updated)
@router.delete("/plants/{plant_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_plant(
plant_id: UUID,
db: Annotated[AsyncSession, Depends(get_session)],
ctx: WriteCtx,
) -> None:
user, tenant_id, _ = ctx
plant = await crud_plant.get(db, id=plant_id)
if not plant:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pflanze nicht gefunden.")
if plant.tenant_id is None and not user.is_superadmin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Globale Pflanzen können nur von Superadmins gelöscht werden.")
if plant.tenant_id is not None and plant.tenant_id != tenant_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Kein Zugriff auf diese Pflanze.")
await crud_plant.remove(db, id=plant_id)

View File

@@ -0,0 +1,10 @@
from fastapi import APIRouter
from app.api.v1 import auth, beds, plantings, plants
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router)
api_router.include_router(plants.router)
api_router.include_router(beds.router)
api_router.include_router(plantings.router)

View File

@@ -0,0 +1,6 @@
from app.crud.user import crud_user
from app.crud.plant import crud_plant, crud_plant_family
from app.crud.bed import crud_bed
from app.crud.planting import crud_planting
__all__ = ["crud_user", "crud_plant", "crud_plant_family", "crud_bed", "crud_planting"]

57
backend/app/crud/base.py Normal file
View File

@@ -0,0 +1,57 @@
from typing import Any, Generic, TypeVar
from uuid import UUID
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import Base
ModelType = TypeVar("ModelType", bound=Base)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
def __init__(self, model: type[ModelType]):
self.model = model
async def get(self, db: AsyncSession, *, id: UUID) -> ModelType | None:
result = await db.execute(select(self.model).where(self.model.id == id))
return result.scalar_one_or_none()
async def get_multi(
self, db: AsyncSession, *, skip: int = 0, limit: int = 100
) -> list[ModelType]:
result = await db.execute(select(self.model).offset(skip).limit(limit))
return list(result.scalars().all())
async def create(self, db: AsyncSession, *, obj_in: CreateSchemaType, **extra: Any) -> ModelType:
data = obj_in.model_dump()
data.update(extra)
db_obj = self.model(**data)
db.add(db_obj)
await db.flush()
await db.refresh(db_obj)
return db_obj
async def update(
self, db: AsyncSession, *, db_obj: ModelType, obj_in: UpdateSchemaType | dict[str, Any]
) -> ModelType:
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_obj, field, value)
db.add(db_obj)
await db.flush()
await db.refresh(db_obj)
return db_obj
async def remove(self, db: AsyncSession, *, id: UUID) -> ModelType | None:
db_obj = await self.get(db, id=id)
if db_obj:
await db.delete(db_obj)
await db.flush()
return db_obj

60
backend/app/crud/bed.py Normal file
View File

@@ -0,0 +1,60 @@
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud.base import CRUDBase
from app.models.bed import Bed
from app.schemas.bed import BedCreate, BedUpdate
class CRUDBed(CRUDBase[Bed, BedCreate, BedUpdate]):
async def get(self, db: AsyncSession, *, id: UUID) -> Bed | None:
result = await db.execute(
select(Bed)
.options(
selectinload(Bed.plantings).selectinload(
__import__("app.models.planting", fromlist=["BedPlanting"]).BedPlanting.plant
).selectinload(
__import__("app.models.plant", fromlist=["Plant"]).Plant.family
)
)
.where(Bed.id == id)
)
return result.scalar_one_or_none()
async def get_multi_for_tenant(
self, db: AsyncSession, *, tenant_id: UUID, skip: int = 0, limit: int = 100
) -> list[Bed]:
result = await db.execute(
select(Bed)
.where(Bed.tenant_id == tenant_id, Bed.is_active == True) # noqa: E712
.order_by(Bed.name)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def get_with_plantings(self, db: AsyncSession, *, id: UUID) -> Bed | None:
from app.models.planting import BedPlanting
from app.models.plant import Plant
result = await db.execute(
select(Bed)
.options(
selectinload(Bed.plantings)
.selectinload(BedPlanting.plant)
.selectinload(Plant.family)
)
.where(Bed.id == id)
)
return result.scalar_one_or_none()
async def create_for_tenant(
self, db: AsyncSession, *, obj_in: BedCreate, tenant_id: UUID
) -> Bed:
return await self.create(db, obj_in=obj_in, tenant_id=tenant_id)
crud_bed = CRUDBed(Bed)

54
backend/app/crud/plant.py Normal file
View File

@@ -0,0 +1,54 @@
from uuid import UUID
from sqlalchemy import or_, select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud.base import CRUDBase
from app.models.plant import Plant, PlantCompatibility, PlantFamily
from app.schemas.plant import PlantCreate, PlantUpdate
class CRUDPlant(CRUDBase[Plant, PlantCreate, PlantUpdate]):
async def get(self, db: AsyncSession, *, id: UUID) -> Plant | None:
result = await db.execute(
select(Plant)
.options(selectinload(Plant.family))
.where(Plant.id == id)
)
return result.scalar_one_or_none()
async def get_multi_for_tenant(
self, db: AsyncSession, *, tenant_id: UUID, skip: int = 0, limit: int = 200
) -> list[Plant]:
result = await db.execute(
select(Plant)
.options(selectinload(Plant.family))
.where(
Plant.is_active == True, # noqa: E712
or_(Plant.tenant_id == None, Plant.tenant_id == tenant_id), # noqa: E711
)
.order_by(Plant.name)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
async def create_for_tenant(
self, db: AsyncSession, *, obj_in: PlantCreate, tenant_id: UUID
) -> Plant:
return await self.create(db, obj_in=obj_in, tenant_id=tenant_id)
class CRUDPlantFamily(CRUDBase[PlantFamily, PlantFamily, PlantFamily]):
async def get_all(self, db: AsyncSession) -> list[PlantFamily]:
result = await db.execute(select(PlantFamily).order_by(PlantFamily.name))
return list(result.scalars().all())
async def get_by_name(self, db: AsyncSession, *, name: str) -> PlantFamily | None:
result = await db.execute(select(PlantFamily).where(PlantFamily.name == name))
return result.scalar_one_or_none()
crud_plant = CRUDPlant(Plant)
crud_plant_family = CRUDPlantFamily(PlantFamily)

View File

@@ -0,0 +1,39 @@
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud.base import CRUDBase
from app.models.planting import BedPlanting
from app.models.plant import Plant
from app.schemas.planting import PlantingCreate, PlantingUpdate
class CRUDPlanting(CRUDBase[BedPlanting, PlantingCreate, PlantingUpdate]):
async def get(self, db: AsyncSession, *, id: UUID) -> BedPlanting | None:
result = await db.execute(
select(BedPlanting)
.options(selectinload(BedPlanting.plant).selectinload(Plant.family))
.where(BedPlanting.id == id)
)
return result.scalar_one_or_none()
async def get_multi_for_bed(
self, db: AsyncSession, *, bed_id: UUID
) -> list[BedPlanting]:
result = await db.execute(
select(BedPlanting)
.options(selectinload(BedPlanting.plant).selectinload(Plant.family))
.where(BedPlanting.bed_id == bed_id)
.order_by(BedPlanting.planted_date.desc().nullslast())
)
return list(result.scalars().all())
async def create_for_bed(
self, db: AsyncSession, *, obj_in: PlantingCreate, bed_id: UUID
) -> BedPlanting:
return await self.create(db, obj_in=obj_in, bed_id=bed_id)
crud_planting = CRUDPlanting(BedPlanting)

48
backend/app/crud/user.py Normal file
View File

@@ -0,0 +1,48 @@
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import get_password_hash, verify_password
from app.crud.base import CRUDBase
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
async def get_by_email(self, db: AsyncSession, *, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def create(self, db: AsyncSession, *, obj_in: UserCreate, **extra) -> User:
data = obj_in.model_dump(exclude={"password"})
data["hashed_password"] = get_password_hash(obj_in.password)
data.update(extra)
db_obj = User(**data)
db.add(db_obj)
await db.flush()
await db.refresh(db_obj)
return db_obj
async def authenticate(self, db: AsyncSession, *, email: str, password: str) -> User | None:
user = await self.get_by_email(db, email=email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
async def get_tenants(self, db: AsyncSession, *, user_id: UUID):
from sqlalchemy.orm import selectinload
from app.models.user import UserTenant
from app.models.tenant import Tenant
result = await db.execute(
select(Tenant)
.join(UserTenant, UserTenant.tenant_id == Tenant.id)
.where(UserTenant.user_id == user_id)
)
return list(result.scalars().all())
crud_user = CRUDUser(User)

34
backend/app/main.py Normal file
View File

@@ -0,0 +1,34 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.router import api_router
from app.core.config import settings
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
app = FastAPI(
title=settings.APP_TITLE,
version=settings.APP_VERSION,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
@app.get("/health", tags=["System"])
async def health_check():
return {"status": "ok", "version": settings.APP_VERSION}

View File

@@ -0,0 +1,15 @@
from app.schemas.auth import AccessTokenResponse, LoginRequest, RefreshRequest, TokenResponse
from app.schemas.user import UserCreate, UserRead, UserUpdate
from app.schemas.tenant import TenantCreate, TenantRead, TenantUpdate
from app.schemas.plant import PlantCompatibilityRead, PlantCreate, PlantFamilyRead, PlantRead, PlantUpdate
from app.schemas.bed import BedCreate, BedDetailRead, BedRead, BedUpdate
from app.schemas.planting import PlantingCreate, PlantingRead, PlantingUpdate
__all__ = [
"AccessTokenResponse", "LoginRequest", "RefreshRequest", "TokenResponse",
"UserCreate", "UserRead", "UserUpdate",
"TenantCreate", "TenantRead", "TenantUpdate",
"PlantCompatibilityRead", "PlantCreate", "PlantFamilyRead", "PlantRead", "PlantUpdate",
"BedCreate", "BedDetailRead", "BedRead", "BedUpdate",
"PlantingCreate", "PlantingRead", "PlantingUpdate",
]

View File

@@ -0,0 +1,62 @@
import uuid
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, computed_field
from app.models.bed import LocationType, SoilType
from app.schemas.plant import PlantRead
class BedBase(BaseModel):
name: str
width_m: Decimal
length_m: Decimal
location: LocationType
soil_type: SoilType = SoilType.NORMAL
notes: str | None = None
class BedCreate(BedBase):
pass
class BedUpdate(BaseModel):
name: str | None = None
width_m: Decimal | None = None
length_m: Decimal | None = None
location: LocationType | None = None
soil_type: SoilType | None = None
notes: str | None = None
is_active: bool | None = None
class BedRead(BedBase):
model_config = {"from_attributes": True}
id: uuid.UUID
tenant_id: uuid.UUID
is_active: bool
created_at: datetime
updated_at: datetime
@computed_field
@property
def area_m2(self) -> Decimal:
return self.width_m * self.length_m
class PlantingInBed(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
plant: PlantRead
area_m2: Decimal | None
count: int | None
planted_date: datetime | None = None
removed_date: datetime | None = None
notes: str | None = None
class BedDetailRead(BedRead):
plantings: list[PlantingInBed] = []

View File

@@ -0,0 +1,63 @@
import uuid
from pydantic import BaseModel
from app.models.plant import CompatibilityRating, NutrientDemand, WaterDemand
class PlantFamilyRead(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
name: str
latin_name: str
class PlantBase(BaseModel):
name: str
latin_name: str | None = None
family_id: uuid.UUID
nutrient_demand: NutrientDemand
water_demand: WaterDemand
spacing_cm: int
sowing_start_month: int
sowing_end_month: int
rest_years: int = 0
notes: str | None = None
class PlantCreate(PlantBase):
pass
class PlantUpdate(BaseModel):
name: str | None = None
latin_name: str | None = None
family_id: uuid.UUID | None = None
nutrient_demand: NutrientDemand | None = None
water_demand: WaterDemand | None = None
spacing_cm: int | None = None
sowing_start_month: int | None = None
sowing_end_month: int | None = None
rest_years: int | None = None
notes: str | None = None
is_active: bool | None = None
class PlantRead(PlantBase):
model_config = {"from_attributes": True}
id: uuid.UUID
tenant_id: uuid.UUID | None
is_active: bool
family: PlantFamilyRead
class PlantCompatibilityRead(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
plant_id_a: uuid.UUID
plant_id_b: uuid.UUID
rating: CompatibilityRating
reason: str | None

View File

@@ -0,0 +1,38 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel
from app.schemas.plant import PlantRead
class PlantingBase(BaseModel):
plant_id: uuid.UUID
area_m2: Decimal | None = None
count: int | None = None
planted_date: date | None = None
removed_date: date | None = None
notes: str | None = None
class PlantingCreate(PlantingBase):
pass
class PlantingUpdate(BaseModel):
plant_id: uuid.UUID | None = None
area_m2: Decimal | None = None
count: int | None = None
planted_date: date | None = None
removed_date: date | None = None
notes: str | None = None
class PlantingRead(PlantingBase):
model_config = {"from_attributes": True}
id: uuid.UUID
bed_id: uuid.UUID
plant: PlantRead
created_at: datetime

View File

View File

@@ -0,0 +1,171 @@
"""
Seed-Daten: Globale Pflanzenfamilien, Pflanzen und Kompatibilitäten.
Idempotent kann mehrfach ausgeführt werden ohne Fehler.
"""
import asyncio
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import AsyncSessionLocal
from app.models.plant import (
CompatibilityRating,
NutrientDemand,
Plant,
PlantCompatibility,
PlantFamily,
WaterDemand,
)
FAMILIES = [
{"name": "Solanaceae", "latin_name": "Solanaceae"},
{"name": "Kreuzblütler", "latin_name": "Brassicaceae"},
{"name": "Doldenblütler", "latin_name": "Apiaceae"},
{"name": "Hülsenfrüchtler", "latin_name": "Fabaceae"},
{"name": "Kürbisgewächse", "latin_name": "Cucurbitaceae"},
{"name": "Korbblütler", "latin_name": "Asteraceae"},
{"name": "Lauchgewächse", "latin_name": "Alliaceae"},
{"name": "Gänsefußgewächse", "latin_name": "Amaranthaceae"},
{"name": "Lippenblütler", "latin_name": "Lamiaceae"},
{"name": "Süßgräser", "latin_name": "Poaceae"},
]
# (name, latin_name, family_name, nutrient, water, spacing_cm, sow_start, sow_end, rest_years)
PLANTS = [
# Solanaceae
("Tomate", "Solanum lycopersicum", "Solanaceae", NutrientDemand.STARK, WaterDemand.MITTEL, 60, 3, 4, 3),
("Paprika", "Capsicum annuum", "Solanaceae", NutrientDemand.STARK, WaterDemand.MITTEL, 45, 2, 3, 3),
("Aubergine", "Solanum melongena", "Solanaceae", NutrientDemand.STARK, WaterDemand.MITTEL, 50, 2, 3, 3),
# Kreuzblütler
("Brokkoli", "Brassica oleracea var. italica", "Kreuzblütler", NutrientDemand.STARK, WaterDemand.MITTEL, 45, 3, 4, 4),
("Weißkohl", "Brassica oleracea var. capitata", "Kreuzblütler", NutrientDemand.STARK, WaterDemand.MITTEL, 50, 3, 4, 4),
("Kohlrabi", "Brassica oleracea var. gongylodes", "Kreuzblütler", NutrientDemand.MITTEL, WaterDemand.MITTEL, 22, 3, 7, 3),
("Radieschen", "Raphanus sativus", "Kreuzblütler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 5, 3, 8, 2),
# Doldenblütler
("Möhre", "Daucus carota", "Doldenblütler", NutrientDemand.MITTEL, WaterDemand.WENIG, 5, 3, 7, 3),
("Petersilie", "Petroselinum crispum", "Doldenblütler", NutrientDemand.MITTEL, WaterDemand.MITTEL, 18, 3, 5, 2),
("Sellerie", "Apium graveolens", "Doldenblütler", NutrientDemand.STARK, WaterDemand.VIEL, 28, 2, 3, 3),
# Hülsenfrüchtler
("Buschbohne", "Phaseolus vulgaris", "Hülsenfrüchtler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 12, 5, 7, 3),
("Erbse", "Pisum sativum", "Hülsenfrüchtler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 8, 3, 5, 3),
# Kürbisgewächse
("Gurke", "Cucumis sativus", "Kürbisgewächse", NutrientDemand.STARK, WaterDemand.VIEL, 60, 4, 5, 2),
("Zucchini", "Cucurbita pepo", "Kürbisgewächse", NutrientDemand.STARK, WaterDemand.VIEL, 90, 4, 5, 2),
("Kürbis", "Cucurbita maxima", "Kürbisgewächse", NutrientDemand.STARK, WaterDemand.VIEL, 180, 4, 5, 2),
# Korbblütler
("Kopfsalat", "Lactuca sativa", "Korbblütler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 22, 3, 8, 2),
("Feldsalat", "Valerianella locusta", "Korbblütler", NutrientDemand.SCHWACH, WaterDemand.WENIG, 8, 8, 9, 2),
# Lauchgewächse
("Zwiebel", "Allium cepa", "Lauchgewächse", NutrientDemand.MITTEL, WaterDemand.WENIG, 12, 3, 4, 3),
("Lauch", "Allium porrum", "Lauchgewächse", NutrientDemand.MITTEL, WaterDemand.MITTEL, 12, 2, 3, 3),
("Knoblauch", "Allium sativum", "Lauchgewächse", NutrientDemand.SCHWACH, WaterDemand.WENIG, 10, 10, 11, 4),
("Schnittlauch", "Allium schoenoprasum", "Lauchgewächse", NutrientDemand.SCHWACH, WaterDemand.WENIG, 10, 3, 4, 3),
# Gänsefußgewächse
("Mangold", "Beta vulgaris var. cicla", "Gänsefußgewächse", NutrientDemand.MITTEL, WaterDemand.MITTEL, 28, 3, 6, 3),
("Spinat", "Spinacia oleracea", "Gänsefußgewächse", NutrientDemand.MITTEL, WaterDemand.MITTEL, 12, 3, 9, 3),
("Rote Bete", "Beta vulgaris var. conditiva", "Gänsefußgewächse", NutrientDemand.MITTEL, WaterDemand.MITTEL, 10, 4, 6, 3),
# Lippenblütler
("Basilikum", "Ocimum basilicum", "Lippenblütler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 20, 4, 5, 2),
("Thymian", "Thymus vulgaris", "Lippenblütler", NutrientDemand.SCHWACH, WaterDemand.WENIG, 25, 3, 4, 2),
("Minze", "Mentha spicata", "Lippenblütler", NutrientDemand.SCHWACH, WaterDemand.VIEL, 30, 3, 4, 2),
("Oregano", "Origanum vulgare", "Lippenblütler", NutrientDemand.SCHWACH, WaterDemand.WENIG, 25, 3, 4, 2),
]
# (plant_a_name, plant_b_name, rating, reason)
COMPATIBILITIES = [
("Tomate", "Basilikum", CompatibilityRating.GUT, "Basilikum fördert das Tomatenwachstum und hält Schädlinge fern."),
("Tomate", "Möhre", CompatibilityRating.GUT, "Gute Nachbarn, gegenseitige Förderung."),
("Tomate", "Petersilie", CompatibilityRating.GUT, "Petersilie stärkt die Tomaten."),
("Tomate", "Brokkoli", CompatibilityRating.SCHLECHT, "Kohl hemmt das Tomatenwachstum."),
("Tomate", "Weißkohl", CompatibilityRating.SCHLECHT, "Kohl hemmt das Tomatenwachstum."),
("Möhre", "Zwiebel", CompatibilityRating.GUT, "Zwiebeln halten die Möhrenfliege fern."),
("Möhre", "Lauch", CompatibilityRating.GUT, "Lauch schützt die Möhre vor der Möhrenfliege."),
("Möhre", "Erbse", CompatibilityRating.GUT, "Erbsen lockern den Boden für Möhren."),
("Gurke", "Buschbohne", CompatibilityRating.GUT, "Klassische gute Nachbarschaft."),
("Weißkohl", "Sellerie", CompatibilityRating.GUT, "Sellerie hält die Kohlfliege fern."),
("Brokkoli", "Sellerie", CompatibilityRating.GUT, "Sellerie hält Kohlschädlinge fern."),
("Spinat", "Radieschen", CompatibilityRating.GUT, "Platzsparende Kombination, gegenseitig förderlich."),
("Zwiebel", "Buschbohne", CompatibilityRating.SCHLECHT, "Zwiebeln hemmen das Bohnenwachstum."),
("Zwiebel", "Erbse", CompatibilityRating.SCHLECHT, "Zwiebeln und Hülsenfrüchtler vertragen sich nicht."),
("Zwiebel", "Weißkohl", CompatibilityRating.SCHLECHT, "Konkurrenz um Nährstoffe."),
]
async def seed_initial_data(db: AsyncSession) -> None:
# 1. Plant families
family_map: dict[str, PlantFamily] = {}
for f in FAMILIES:
result = await db.execute(select(PlantFamily).where(PlantFamily.name == f["name"]))
existing = result.scalar_one_or_none()
if existing:
family_map[f["name"]] = existing
else:
obj = PlantFamily(id=uuid.uuid4(), **f)
db.add(obj)
await db.flush()
family_map[f["name"]] = obj
# 2. Global plants
plant_map: dict[str, Plant] = {}
for (name, latin, family_name, nutrient, water, spacing, sow_start, sow_end, rest) in PLANTS:
result = await db.execute(
select(Plant).where(Plant.name == name, Plant.tenant_id == None) # noqa: E711
)
existing = result.scalar_one_or_none()
if existing:
plant_map[name] = existing
else:
obj = Plant(
id=uuid.uuid4(),
tenant_id=None,
family_id=family_map[family_name].id,
name=name,
latin_name=latin,
nutrient_demand=nutrient,
water_demand=water,
spacing_cm=spacing,
sowing_start_month=sow_start,
sowing_end_month=sow_end,
rest_years=rest,
)
db.add(obj)
await db.flush()
plant_map[name] = obj
# 3. Compatibilities (both directions)
for (name_a, name_b, rating, reason) in COMPATIBILITIES:
if name_a not in plant_map or name_b not in plant_map:
continue
id_a = plant_map[name_a].id
id_b = plant_map[name_b].id
result = await db.execute(
select(PlantCompatibility).where(
PlantCompatibility.plant_id_a == id_a,
PlantCompatibility.plant_id_b == id_b,
)
)
if not result.scalar_one_or_none():
db.add(PlantCompatibility(id=uuid.uuid4(), plant_id_a=id_a, plant_id_b=id_b, rating=rating, reason=reason))
# Reverse direction
result = await db.execute(
select(PlantCompatibility).where(
PlantCompatibility.plant_id_a == id_b,
PlantCompatibility.plant_id_b == id_a,
)
)
if not result.scalar_one_or_none():
db.add(PlantCompatibility(id=uuid.uuid4(), plant_id_a=id_b, plant_id_b=id_a, rating=rating, reason=reason))
await db.commit()
print("Seed-Daten erfolgreich eingespielt.")
async def main() -> None:
async with AsyncSessionLocal() as db:
await seed_initial_data(db)
if __name__ == "__main__":
asyncio.run(main())