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:
59
backend/alembic/env.py
Normal file
59
backend/alembic/env.py
Normal 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()
|
||||
155
backend/alembic/versions/001_initial.py
Normal file
155
backend/alembic/versions/001_initial.py
Normal 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")
|
||||
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
68
backend/app/api/v1/auth.py
Normal file
68
backend/app/api/v1/auth.py
Normal 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)
|
||||
78
backend/app/api/v1/beds.py
Normal file
78
backend/app/api/v1/beds.py
Normal 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)
|
||||
78
backend/app/api/v1/plantings.py
Normal file
78
backend/app/api/v1/plantings.py
Normal 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)
|
||||
95
backend/app/api/v1/plants.py
Normal file
95
backend/app/api/v1/plants.py
Normal 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)
|
||||
10
backend/app/api/v1/router.py
Normal file
10
backend/app/api/v1/router.py
Normal 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)
|
||||
6
backend/app/crud/__init__.py
Normal file
6
backend/app/crud/__init__.py
Normal 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
57
backend/app/crud/base.py
Normal 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
60
backend/app/crud/bed.py
Normal 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
54
backend/app/crud/plant.py
Normal 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)
|
||||
39
backend/app/crud/planting.py
Normal file
39
backend/app/crud/planting.py
Normal 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
48
backend/app/crud/user.py
Normal 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
34
backend/app/main.py
Normal 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}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
62
backend/app/schemas/bed.py
Normal file
62
backend/app/schemas/bed.py
Normal 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] = []
|
||||
63
backend/app/schemas/plant.py
Normal file
63
backend/app/schemas/plant.py
Normal 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
|
||||
38
backend/app/schemas/planting.py
Normal file
38
backend/app/schemas/planting.py
Normal 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
|
||||
0
backend/app/seeds/__init__.py
Normal file
0
backend/app/seeds/__init__.py
Normal file
171
backend/app/seeds/initial_data.py
Normal file
171
backend/app/seeds/initial_data.py
Normal 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())
|
||||
Reference in New Issue
Block a user