Feste Fächer-Liste je Objekttyp (Vorlagen-Positionen: Dropdown + Sonstiges)
CI / backend-tests (push) Successful in 1m0s
CI / frontend-build (push) Successful in 5m56s

Nutzer-Vorgabe: Rucksack-Fächer sollen aus einer festen, je Objekttyp
gepflegten Liste gewählt werden können statt jedes Mal Freitext einzutippen.
Neue Tabelle fach (Migration 0004), CRUD-Endpoints /faecher, Admin-Tab
Struktur bekommt Fach-Verwaltung, Vorlagen-Editor nutzt Dropdown mit
"Sonstiges"-Freitext-Fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
2026-09-04 10:24:15 +02:00
co-authored by Claude Sonnet 5
parent 2f11011ed1
commit a2877f5672
11 changed files with 493 additions and 17 deletions
+36
View File
@@ -0,0 +1,36 @@
"""fach-Tabelle: feste Fächer-Liste je Objekttyp
Revision ID: 0004_add_fach
Revises: 0003_add_benutzer_email
Create Date: 2026-09-04
Nutzer-Vorgabe: feste Fächer-Liste pro Objekttyp statt Freitext bei jeder
Vorlagenposition. Additive Schema-Erweiterung, bestehende Vorlagenpositionen
(freies Fach-Textfeld) bleiben unverändert nutzbar (Prompt-Vorgabe "optional
sonstiges für freies Textfeld").
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0004_add_fach"
down_revision: Union[str, None] = "0003_add_benutzer_email"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE fach (
id SERIAL PRIMARY KEY,
objekttyp_id INTEGER NOT NULL REFERENCES objekttyp(id),
name TEXT NOT NULL,
sortierung INTEGER NOT NULL DEFAULT 0
);
"""
)
def downgrade() -> None:
op.execute("DROP TABLE fach;")
+55 -1
View File
@@ -5,11 +5,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, require_roles
from app.db.session import get_db
from app.models.auth import RolleTyp
from app.models.stammdaten import Bereich, Kategorie, Material, Objekttyp, Standort
from app.models.stammdaten import Bereich, Fach, Kategorie, Material, Objekttyp, Standort
from app.schemas.stammdaten import (
BereichCreate,
BereichRead,
BereichUpdate,
FachCreate,
FachRead,
FachUpdate,
KategorieCreate,
KategorieRead,
KategorieUpdate,
@@ -179,6 +182,57 @@ async def aendere_objekttyp(
return objekttyp
# -- Fach (feste Fächer-Liste je Objekttyp) ----------------------------------
@router.get("/faecher", response_model=list[FachRead])
async def liste_faecher(
db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> list[Fach]:
result = await db.execute(select(Fach).order_by(Fach.objekttyp_id, Fach.sortierung, Fach.name))
return list(result.scalars().all())
@router.post("/faecher", response_model=FachRead, status_code=status.HTTP_201_CREATED)
async def erstelle_fach(
payload: FachCreate,
db: AsyncSession = Depends(get_db),
_=Depends(require_roles(RolleTyp.administration)),
) -> Fach:
fach = Fach(**payload.model_dump())
db.add(fach)
await db.flush()
return fach
@router.patch("/faecher/{fach_id}", response_model=FachRead)
async def aendere_fach(
fach_id: int,
payload: FachUpdate,
db: AsyncSession = Depends(get_db),
_=Depends(require_roles(RolleTyp.administration)),
) -> Fach:
fach = await db.get(Fach, fach_id)
if fach is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fach nicht gefunden")
for feld, wert in payload.model_dump(exclude_unset=True).items():
setattr(fach, feld, wert)
await db.flush()
return fach
@router.delete("/faecher/{fach_id}", status_code=status.HTTP_204_NO_CONTENT)
async def loesche_fach(
fach_id: int,
db: AsyncSession = Depends(get_db),
_=Depends(require_roles(RolleTyp.administration)),
) -> None:
fach = await db.get(Fach, fach_id)
if fach is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fach nicht gefunden")
await db.delete(fach)
await db.flush()
# -- Material (Prompt 07) --------------------------------------------------
@router.get("/materialien", response_model=list[MaterialRead])
+13
View File
@@ -50,6 +50,19 @@ class Objekttyp(Base):
name: Mapped[str] = mapped_column(String, nullable=False)
class Fach(Base):
"""Nutzer-Vorgabe: feste Fächer-Liste je Objekttyp, damit beim Anlegen einer
Vorlagenposition nicht jedes Mal Freitext getippt werden muss. `sortierung`
steuert die Anzeigereihenfolge (z. B. von oben nach unten im Rucksack)."""
__tablename__ = "fach"
id: Mapped[int] = mapped_column(primary_key=True)
objekttyp_id: Mapped[int] = mapped_column(ForeignKey("objekttyp.id"), nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
sortierung: Mapped[int] = mapped_column(default=0)
class Material(Base):
__tablename__ = "material"
+16
View File
@@ -67,6 +67,22 @@ class ObjekttypUpdate(BaseModel):
name: str | None = None
class FachCreate(BaseModel):
objekttyp_id: int
name: str
sortierung: int = 0
class FachRead(FachCreate):
model_config = ConfigDict(from_attributes=True)
id: int
class FachUpdate(BaseModel):
name: str | None = None
sortierung: int | None = None
class MaterialCreate(BaseModel):
name: str
artikelnummer: str | None = None
+56
View File
@@ -122,3 +122,59 @@ async def test_admin_can_update_objekttyp(client, admin_user):
)
assert response.status_code == 200
assert response.json()["name"] == "Neu"
@pytest.mark.asyncio
async def test_fach_lifecycle(client, admin_user):
"""Nutzer-Vorgabe: feste Fächer-Liste je Objekttyp."""
token = await login(client, "admin1")
bereich = await client.post("/api/v1/bereiche", json={"name": "B"}, headers=auth_header(token))
objekttyp = await client.post(
"/api/v1/objekttypen",
json={"bereich_id": bereich.json()["id"], "name": "Rucksack"},
headers=auth_header(token),
)
objekttyp_id = objekttyp.json()["id"]
erstellt = await client.post(
"/api/v1/faecher",
json={"objekttyp_id": objekttyp_id, "name": "Fach 1", "sortierung": 1},
headers=auth_header(token),
)
assert erstellt.status_code == 201
fach_id = erstellt.json()["id"]
liste = await client.get("/api/v1/faecher", headers=auth_header(token))
assert any(f["id"] == fach_id for f in liste.json())
patch_response = await client.patch(
f"/api/v1/faecher/{fach_id}", json={"name": "Fach 1 links"}, headers=auth_header(token)
)
assert patch_response.status_code == 200
assert patch_response.json()["name"] == "Fach 1 links"
delete_response = await client.delete(f"/api/v1/faecher/{fach_id}", headers=auth_header(token))
assert delete_response.status_code == 204
liste_nach_delete = await client.get("/api/v1/faecher", headers=auth_header(token))
assert not any(f["id"] == fach_id for f in liste_nach_delete.json())
@pytest.mark.asyncio
async def test_mitarbeiter_cannot_create_fach(client, mitarbeiter_user, admin_user, db_session):
from app.models.stammdaten import Bereich, Objekttyp
bereich = Bereich(name="B2")
db_session.add(bereich)
await db_session.flush()
objekttyp = Objekttyp(bereich_id=bereich.id, name="Rucksack2")
db_session.add(objekttyp)
await db_session.flush()
token = await login(client, "mitarbeiter1")
response = await client.post(
"/api/v1/faecher",
json={"objekttyp_id": objekttyp.id, "name": "Fach X"},
headers=auth_header(token),
)
assert response.status_code == 403