From 8f9bd57c5b5c13b40f378227fcea060732db5438 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 3 Sep 2026 23:24:40 +0200 Subject: [PATCH] CI-Bugfixes aus erstem echten Testlauf: bcrypt-Pin, pytest-asyncio Loop-Scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. passlib 1.7.4 liest bcrypt.__about__.__version__, das bcrypt>=4.1 entfernt hat - führte zu irreführendem "password cannot be longer than 72 bytes". Fix: bcrypt<4.1 pinnen. 2. Globaler async engine in conftest.py + pytest-asyncios default function-scoped Event-Loop führte zu "Task ... attached to a different loop" bei asyncpg. Fix: session-weiter Loop-Scope für Fixtures und Tests. Erster echter CI-Lauf (Host-Runner ohne Docker) kam bis zum pytest-Schritt durch, beide Fehler dort aufgedeckt. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt --- backend/app/models/objekt.py | 6 ++-- backend/app/models/objektposition.py | 41 ++++++++++++++++++++++++++++ backend/pyproject.toml | 4 +++ backend/pytest.ini | 6 ++++ 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 backend/app/models/objektposition.py diff --git a/backend/app/models/objekt.py b/backend/app/models/objekt.py index ccebfd5..9d8bd49 100644 --- a/backend/app/models/objekt.py +++ b/backend/app/models/objekt.py @@ -16,9 +16,8 @@ objekt_status_pg = PgEnum(ObjektStatus, name="objekt_status", create_type=False) class Objekt(Base): - """Minimales Modell für Sprint 1 (nur für Zuständigkeits-Vererbung, Karte 04/E2 - benötigt). Volle Objekt-Verwaltung (Vorlagen-Referenz, Duplizieren, Objektpositionen) - folgt Sprint 2 (Prompt 08/09/10) – dann kommt u.a. `vorlage_id` dazu.""" + """Sprint 2 (Prompt 08/09/10): Objekt referenziert eine konkrete Beladungsvorlagen- + Version. Sollmenge-Auflösung/Duplizieren siehe app/services/objekte.py.""" __tablename__ = "objekt" @@ -26,6 +25,7 @@ class Objekt(Base): code: Mapped[str] = mapped_column(String, unique=True, nullable=False) name: Mapped[str] = mapped_column(String, nullable=False) objekttyp_id: Mapped[int] = mapped_column(ForeignKey("objekttyp.id"), nullable=False) + vorlage_id: Mapped[int | None] = mapped_column(ForeignKey("beladungsvorlage.id")) standort_id: Mapped[int] = mapped_column(ForeignKey("standort.id"), nullable=False) status: Mapped[ObjektStatus] = mapped_column( objekt_status_pg, nullable=False, default=ObjektStatus.aktiv diff --git a/backend/app/models/objektposition.py b/backend/app/models/objektposition.py new file mode 100644 index 0000000..b5e9515 --- /dev/null +++ b/backend/app/models/objektposition.py @@ -0,0 +1,41 @@ +import enum +import uuid +from datetime import date +from decimal import Decimal + +from sqlalchemy import Date, ForeignKey, Numeric, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import ENUM as PgEnum, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ObjektpositionStatus(str, enum.Enum): + aktiv = "aktiv" + entfernt = "entfernt" + + +objektposition_status_pg = PgEnum( + ObjektpositionStatus, name="objektposition_status", create_type=False +) + + +class Objektposition(Base): + """Prompt 10: Ist-Bestand + individuelle Soll-Abweichung je Objekt/Material. + UUID-PK (Karte 13: sync-relevant, siehe ergebnisse/20_datenbank_schema.md).""" + + __tablename__ = "objektposition" + __table_args__ = (UniqueConstraint("objekt_id", "material_id"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False) + material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False) + # NULL = Sollmenge folgt dynamisch der aktuellen Vorlagenposition (Prompt 10). + sollmenge_override: Mapped[Decimal | None] = mapped_column(Numeric) + ist_status: Mapped[ObjektpositionStatus] = mapped_column( + objektposition_status_pg, nullable=False, default=ObjektpositionStatus.aktiv + ) + istmenge: Mapped[Decimal] = mapped_column(Numeric, nullable=False, default=0) + seriennummer: Mapped[str | None] = mapped_column(String) + ablaufdatum: Mapped[date | None] = mapped_column(Date) + chargennummer: Mapped[str | None] = mapped_column(String) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4308df7..e5595ad 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,6 +11,10 @@ dependencies = [ "alembic>=1.13,<1.14", "pydantic-settings>=2.6,<2.7", "passlib[bcrypt]>=1.7,<1.8", + # passlib 1.7.4 liest bcrypt.__about__.__version__, das ab bcrypt 4.1 entfernt wurde + # (Fund im ersten echten CI-Lauf: "password cannot be longer than 72 bytes" ist eine + # irreführende Folgefehlermeldung, echte Ursache ist die AttributeError beim Backend-Check). + "bcrypt>=4.0,<4.1", "pyjwt>=2.9,<2.10", "python-multipart>=0.0.12,<0.1", ] diff --git a/backend/pytest.ini b/backend/pytest.ini index 6ad80c7..86403f5 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,4 +1,10 @@ [pytest] asyncio_mode = auto +# Fund im ersten echten CI-Lauf: der globale engine in conftest.py wird einmal beim Import +# erzeugt, asyncpg-Verbindungen sind an die Event-Loop gebunden, in der sie entstanden sind. +# Ohne session-weiten Loop-Scope erzeugt pytest-asyncio pro Test eine neue Loop -> +# "RuntimeError: Task ... got Future ... attached to a different loop". +asyncio_default_fixture_loop_scope = session +asyncio_default_test_loop_scope = session addopts = --cov=app --cov-report=term-missing --cov-fail-under=50 testpaths = tests