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
156 lines
7.9 KiB
Python
156 lines
7.9 KiB
Python
"""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")
|