Sprint 0: FastAPI-Skeleton, vollständiges DB-Schema (Alembic), Auth (JWT/bcrypt), Rollen-Dependency, CI
CI / backend-tests (push) Failing after 2s
CI / backend-tests (push) Failing after 2s
- Vollständiges Ziel-Schema aus Prompt 20 als initiale Migration (inkl. Karte-13-Vorbereitung: UUID-PKs, systemknoten) - Seed-Migration für Hauptserver-Datensatz (Sprintplan E6) - JWT-Login + /auth/me, require_roles-Dependency (Prompt 05 Berechtigungsmatrix) - Tests: health, login/me, Rollen-Ablehnung/-Zulassung (S0-Abnahmekriterien) - Gitea-Actions-CI: install -> migrate -> pytest mit Coverage-Gate 50% Nur Code/Config erzeugt, nicht lokal installiert oder ausgeführt (Deployment-Regel). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e4ee4e5458
commit
cedc39e075
@@ -0,0 +1,51 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.core.app_settings import settings
|
||||
from app.db.base import Base
|
||||
import app.models # noqa: F401 (registriert alle Modelle an Base.metadata)
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_url() -> str:
|
||||
return settings.database_url
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=get_url(),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_online() -> None:
|
||||
connectable = create_async_engine(get_url(), poolclass=pool.NullPool)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Initiales Schema (Prompt 20 / ergebnisse/20_datenbank_schema.md)
|
||||
|
||||
Revision ID: 0001_initial_schema
|
||||
Revises:
|
||||
Create Date: 2026-09-03
|
||||
|
||||
Vollständiges Ziel-Schema aus Prompt 20 (inkl. Karte-13-Vorbereitung: UUID auf
|
||||
sync-relevanten Tabellen, systemknoten). Sprint 0 legt das komplette Schema an,
|
||||
auch wenn erst spätere Sprints die zugehörigen Endpunkte/Business-Logik bauen –
|
||||
so entfällt ein späterer Schema-Umbau (Designziel, Prompt 06/19).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0001_initial_schema"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
UPGRADE_SQL = """
|
||||
-- 1. Stammdaten
|
||||
CREATE TABLE bereich (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
beschreibung TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE kategorie (
|
||||
id SERIAL PRIMARY KEY,
|
||||
bereich_id INTEGER NOT NULL REFERENCES bereich(id),
|
||||
name TEXT NOT NULL,
|
||||
ueberkategorie_id INTEGER REFERENCES kategorie(id),
|
||||
UNIQUE (bereich_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE standort (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
adresse TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE objekttyp (
|
||||
id SERIAL PRIMARY KEY,
|
||||
bereich_id INTEGER NOT NULL REFERENCES bereich(id),
|
||||
kategorie_id INTEGER REFERENCES kategorie(id),
|
||||
name TEXT NOT NULL,
|
||||
UNIQUE (bereich_id, name)
|
||||
);
|
||||
|
||||
CREATE TYPE materialtyp AS ENUM ('standard', 'ablauf_charge', 'geraet_sn');
|
||||
|
||||
CREATE TABLE material (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
artikelnummer TEXT,
|
||||
einheit TEXT NOT NULL,
|
||||
materialtyp materialtyp NOT NULL,
|
||||
kategorie_id INTEGER REFERENCES kategorie(id),
|
||||
hersteller TEXT,
|
||||
beschreibung TEXT,
|
||||
code TEXT UNIQUE,
|
||||
warnzeitraum_tage INTEGER,
|
||||
aktiv BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- 2. Vorlagen
|
||||
CREATE TYPE vorlage_status AS ENUM ('aktiv', 'veraltet');
|
||||
|
||||
CREATE TABLE beladungsvorlage (
|
||||
id SERIAL PRIMARY KEY,
|
||||
objekttyp_id INTEGER NOT NULL REFERENCES objekttyp(id),
|
||||
name TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
gueltig_ab TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
status vorlage_status NOT NULL DEFAULT 'aktiv',
|
||||
UNIQUE (objekttyp_id, name, version)
|
||||
);
|
||||
|
||||
CREATE TABLE vorlagenposition (
|
||||
id SERIAL PRIMARY KEY,
|
||||
vorlage_id INTEGER NOT NULL REFERENCES beladungsvorlage(id),
|
||||
material_id INTEGER NOT NULL REFERENCES material(id),
|
||||
fach TEXT,
|
||||
sollmenge NUMERIC NOT NULL,
|
||||
UNIQUE (vorlage_id, material_id)
|
||||
);
|
||||
|
||||
-- 3. Objekte
|
||||
CREATE TYPE knoten_typ AS ENUM ('haupt', 'satellit');
|
||||
|
||||
CREATE TABLE systemknoten (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
typ knoten_typ NOT NULL DEFAULT 'satellit'
|
||||
);
|
||||
|
||||
CREATE TYPE objekt_status AS ENUM ('aktiv', 'ausser_dienst');
|
||||
|
||||
CREATE TABLE objekt (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
objekttyp_id INTEGER NOT NULL REFERENCES objekttyp(id),
|
||||
vorlage_id INTEGER REFERENCES beladungsvorlage(id),
|
||||
standort_id INTEGER NOT NULL REFERENCES standort(id),
|
||||
status objekt_status NOT NULL DEFAULT 'aktiv',
|
||||
zustaendiger_server_id INTEGER NOT NULL REFERENCES systemknoten(id)
|
||||
);
|
||||
|
||||
CREATE TYPE objektposition_status AS ENUM ('aktiv', 'entfernt');
|
||||
|
||||
CREATE TABLE objektposition (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
objekt_id INTEGER NOT NULL REFERENCES objekt(id),
|
||||
material_id INTEGER NOT NULL REFERENCES material(id),
|
||||
sollmenge_override NUMERIC,
|
||||
ist_status objektposition_status NOT NULL DEFAULT 'aktiv',
|
||||
istmenge NUMERIC NOT NULL DEFAULT 0,
|
||||
seriennummer TEXT,
|
||||
ablaufdatum DATE,
|
||||
chargennummer TEXT,
|
||||
zuletzt_geaendert_am TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (objekt_id, material_id)
|
||||
);
|
||||
|
||||
-- 4. Zuständigkeiten, Benutzer, Rollen
|
||||
CREATE TABLE benutzer (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
passwort_hash TEXT NOT NULL,
|
||||
aktiv BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE TYPE rolle_typ AS ENUM ('mitarbeiter', 'materialverantwortlicher', 'leitungsverantwortlicher', 'administration');
|
||||
|
||||
CREATE TABLE benutzer_rolle (
|
||||
benutzer_id INTEGER NOT NULL REFERENCES benutzer(id),
|
||||
rolle rolle_typ NOT NULL,
|
||||
PRIMARY KEY (benutzer_id, rolle)
|
||||
);
|
||||
|
||||
CREATE TABLE zustaendigkeit (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
benutzer_id INTEGER NOT NULL REFERENCES benutzer(id),
|
||||
standort_id INTEGER REFERENCES standort(id),
|
||||
objekt_id INTEGER REFERENCES objekt(id),
|
||||
CHECK (standort_id IS NOT NULL OR objekt_id IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE TABLE kontrollverantwortung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
objekt_id INTEGER NOT NULL REFERENCES objekt(id),
|
||||
benutzer_id INTEGER REFERENCES benutzer(id),
|
||||
gruppe TEXT
|
||||
);
|
||||
|
||||
-- 5. Kontrolle
|
||||
CREATE TYPE kontroll_status AS ENUM ('nicht_gestartet', 'in_bearbeitung', 'abgeschlossen', 'abgebrochen');
|
||||
|
||||
CREATE TABLE kontrolle (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
erzeugt_von_server_id INTEGER NOT NULL REFERENCES systemknoten(id),
|
||||
objekt_id INTEGER NOT NULL REFERENCES objekt(id),
|
||||
benutzer_id INTEGER NOT NULL REFERENCES benutzer(id),
|
||||
status kontroll_status NOT NULL DEFAULT 'in_bearbeitung',
|
||||
gestartet_am TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
beendet_am TIMESTAMPTZ,
|
||||
abbruch_grund TEXT,
|
||||
signatur BYTEA
|
||||
);
|
||||
|
||||
CREATE TABLE kontrollposition (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kontrolle_id UUID NOT NULL REFERENCES kontrolle(id),
|
||||
material_id INTEGER NOT NULL REFERENCES material(id),
|
||||
sollmenge_snapshot NUMERIC NOT NULL,
|
||||
istmenge_erfasst NUMERIC NOT NULL,
|
||||
abweichung BOOLEAN NOT NULL
|
||||
);
|
||||
|
||||
-- 6. Fehlbestand, Nachfüllung, Mindermenge
|
||||
CREATE TYPE fehlbestand_status AS ENUM ('offen', 'in_bearbeitung', 'nachgefuellt_teilweise', 'erledigt');
|
||||
|
||||
CREATE TABLE fehlbestand (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
erzeugt_von_server_id INTEGER NOT NULL REFERENCES systemknoten(id),
|
||||
objekt_id INTEGER NOT NULL REFERENCES objekt(id),
|
||||
material_id INTEGER NOT NULL REFERENCES material(id),
|
||||
standort_id INTEGER NOT NULL REFERENCES standort(id),
|
||||
sollmenge NUMERIC NOT NULL,
|
||||
istmenge NUMERIC NOT NULL,
|
||||
fehlmenge NUMERIC NOT NULL,
|
||||
entstanden_am TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
festgestellt_von INTEGER NOT NULL REFERENCES benutzer(id),
|
||||
kontrolle_id UUID REFERENCES kontrolle(id),
|
||||
ursache TEXT,
|
||||
verantwortlicher_id INTEGER REFERENCES benutzer(id),
|
||||
status fehlbestand_status NOT NULL DEFAULT 'offen',
|
||||
erledigt_am TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE nachfuellung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
fehlbestand_id UUID REFERENCES fehlbestand(id),
|
||||
objekt_id INTEGER NOT NULL REFERENCES objekt(id),
|
||||
material_id INTEGER NOT NULL REFERENCES material(id),
|
||||
menge NUMERIC NOT NULL,
|
||||
benutzer_id INTEGER NOT NULL REFERENCES benutzer(id),
|
||||
zeitpunkt TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TYPE mindermenge_status AS ENUM ('aktiv', 'abgelaufen', 'beendet_durch_erledigung');
|
||||
|
||||
CREATE TABLE mindermengen_genehmigung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
fehlbestand_id UUID NOT NULL REFERENCES fehlbestand(id),
|
||||
genehmigt_von INTEGER NOT NULL REFERENCES benutzer(id),
|
||||
begruendung TEXT NOT NULL,
|
||||
genehmigt_am TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ausloesende_kontrolle_id UUID NOT NULL REFERENCES kontrolle(id),
|
||||
status mindermenge_status NOT NULL DEFAULT 'aktiv',
|
||||
beendet_am TIMESTAMPTZ,
|
||||
beendende_kontrolle_id UUID REFERENCES kontrolle(id)
|
||||
);
|
||||
|
||||
-- 7. Historie/Audit
|
||||
CREATE TABLE historie (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
erzeugt_von_server_id INTEGER NOT NULL REFERENCES systemknoten(id),
|
||||
zeitpunkt TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
benutzer_id INTEGER REFERENCES benutzer(id),
|
||||
ereignistyp TEXT NOT NULL,
|
||||
entitaet_typ TEXT NOT NULL,
|
||||
entitaet_id TEXT NOT NULL,
|
||||
alter_wert JSONB,
|
||||
neuer_wert JSONB,
|
||||
begruendung TEXT
|
||||
);
|
||||
|
||||
-- 8. Indizes
|
||||
CREATE INDEX idx_fehlbestand_status ON fehlbestand(status);
|
||||
CREATE INDEX idx_fehlbestand_objekt ON fehlbestand(objekt_id);
|
||||
CREATE INDEX idx_fehlbestand_entstanden ON fehlbestand(entstanden_am);
|
||||
CREATE INDEX idx_objektposition_objekt ON objektposition(objekt_id);
|
||||
CREATE INDEX idx_historie_entitaet ON historie(entitaet_typ, entitaet_id);
|
||||
CREATE INDEX idx_zustaendigkeit_benutzer ON zustaendigkeit(benutzer_id);
|
||||
"""
|
||||
|
||||
DOWNGRADE_SQL = """
|
||||
DROP TABLE IF EXISTS historie;
|
||||
DROP TABLE IF EXISTS mindermengen_genehmigung;
|
||||
DROP TABLE IF EXISTS nachfuellung;
|
||||
DROP TABLE IF EXISTS fehlbestand;
|
||||
DROP TABLE IF EXISTS kontrollposition;
|
||||
DROP TABLE IF EXISTS kontrolle;
|
||||
DROP TABLE IF EXISTS kontrollverantwortung;
|
||||
DROP TABLE IF EXISTS zustaendigkeit;
|
||||
DROP TABLE IF EXISTS benutzer_rolle;
|
||||
DROP TABLE IF EXISTS benutzer;
|
||||
DROP TABLE IF EXISTS objektposition;
|
||||
DROP TABLE IF EXISTS objekt;
|
||||
DROP TABLE IF EXISTS systemknoten;
|
||||
DROP TABLE IF EXISTS vorlagenposition;
|
||||
DROP TABLE IF EXISTS beladungsvorlage;
|
||||
DROP TABLE IF EXISTS material;
|
||||
DROP TABLE IF EXISTS objekttyp;
|
||||
DROP TABLE IF EXISTS standort;
|
||||
DROP TABLE IF EXISTS kategorie;
|
||||
DROP TABLE IF EXISTS bereich;
|
||||
|
||||
DROP TYPE IF EXISTS mindermenge_status;
|
||||
DROP TYPE IF EXISTS fehlbestand_status;
|
||||
DROP TYPE IF EXISTS kontroll_status;
|
||||
DROP TYPE IF EXISTS rolle_typ;
|
||||
DROP TYPE IF EXISTS objektposition_status;
|
||||
DROP TYPE IF EXISTS objekt_status;
|
||||
DROP TYPE IF EXISTS knoten_typ;
|
||||
DROP TYPE IF EXISTS vorlage_status;
|
||||
DROP TYPE IF EXISTS materialtyp;
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(UPGRADE_SQL)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(DOWNGRADE_SQL)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Seed: genau ein systemknoten-Datensatz mit typ='haupt' (Sprintplan E6)
|
||||
|
||||
Revision ID: 0002_seed_hauptserver
|
||||
Revises: 0001_initial_schema
|
||||
Create Date: 2026-09-03
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002_seed_hauptserver"
|
||||
down_revision: Union[str, None] = "0001_initial_schema"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"INSERT INTO systemknoten (name, typ) VALUES ('Hauptserver', 'haupt') "
|
||||
"ON CONFLICT (name) DO NOTHING;"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DELETE FROM systemknoten WHERE name = 'Hauptserver' AND typ = 'haupt';")
|
||||
Reference in New Issue
Block a user