feat(reports): DATEV-Monatsblatt-Export (Blanko-Vorlage) für Steuerberater
Neuer PDF-Export GET /reports/datev-monthly/export: ein Blatt pro Mitarbeiter/Monat im Layout der DATEV-Stundenaufzeichnungs-Vorlage (Beginn/Pause/Ende/Dauer + K/U/UU/F/SA/SU-Kürzel + Bemerkungen + Summe + Unterschriftsfelder). Führt time_entries, Absences und Feiertage pro Kalendertag zusammen. Außerdem: Mustervorlagen für neue Backend-Module (Model/Schema/Router/ Migration/Test) und eine Frontend-Page-Vorlage, abgeleitet vom hours_payouts-Modul als aktuellstem sauberen Muster. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CviFgc739S89xS97pvjszj
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from datetime import date, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -152,6 +152,28 @@ async def export_time_report(
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}.csv"})
|
||||
|
||||
|
||||
@router.get("/reports/datev-monthly/export")
|
||||
async def export_datev_monthly(
|
||||
current_user: CurrentUser,
|
||||
user_id: UUID,
|
||||
year: int = Query(..., ge=2000, le=2100),
|
||||
month: int = Query(..., ge=1, le=12),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""DATEV-Monatsblatt (Blanko-Layout) als PDF für Steuerberater-Übergabe.
|
||||
EMPLOYEE darf nur den eigenen Monat exportieren."""
|
||||
if current_user.role not in _manager_roles and user_id != current_user.id:
|
||||
raise HTTPException(403, "Nur eigenes Monatsblatt exportierbar")
|
||||
|
||||
sheet = await report_service.datev_monthly_report(
|
||||
current_user.company_id, user_id, year, month, db
|
||||
)
|
||||
content = report_service.datev_monthly_report_to_pdf(sheet)
|
||||
filename = f"datev_stundenaufzeichnung_{sheet.user_name.replace(' ', '_')}_{year}_{month:02d}.pdf"
|
||||
return Response(content=content, media_type="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"})
|
||||
|
||||
|
||||
@router.get("/reports/absences/export")
|
||||
async def export_absence_report(
|
||||
current_user: CurrentUser,
|
||||
|
||||
@@ -188,3 +188,29 @@ class OvertimeReportDetailed(BaseModel):
|
||||
total_employees: int
|
||||
total_overtime: float
|
||||
rows: list[OvertimeReportRowDetailed]
|
||||
|
||||
|
||||
# ── DATEV-Monatsblatt ──────────────────────────────────────────────────────────
|
||||
# Layout entspricht "Stundenaufzeichnungen Muster blanko DATEV ab 2015":
|
||||
# ein Blatt pro Mitarbeiter/Monat, Zeile pro Kalendertag.
|
||||
|
||||
class DatevDayRow(BaseModel):
|
||||
day: int # Kalendertag 1-31
|
||||
weekday_label: str # "Mo".."So" für Anzeige
|
||||
start_time: time | None = None
|
||||
break_minutes: int | None = None
|
||||
end_time: time | None = None
|
||||
duration_hours: float | None = None
|
||||
code: str | None = None # K/U/UU/F/SA/SU – siehe DATEV_CODES
|
||||
recorded_on: date | None = None # "aufgezeichnet am" (created_at des Eintrags)
|
||||
note: str | None = None # Bemerkungen (Absence-Typ-Name o.ä.)
|
||||
|
||||
|
||||
class DatevMonthlySheet(BaseModel):
|
||||
company_name: str
|
||||
user_name: str
|
||||
personnel_number: str | None
|
||||
year: int
|
||||
month: int
|
||||
rows: list[DatevDayRow]
|
||||
total_hours: float
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import calendar
|
||||
import csv
|
||||
import io
|
||||
from collections import defaultdict
|
||||
@@ -9,7 +10,7 @@ from sqlalchemy import distinct, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.absence import Absence, AbsenceStatus
|
||||
from app.models.absence_type import AbsenceType
|
||||
from app.models.absence_type import AbsenceCategory, AbsenceType
|
||||
from app.models.company import Company
|
||||
from app.models.department import Department
|
||||
from app.models.overtime_balance import OvertimeBalance
|
||||
@@ -21,6 +22,8 @@ from app.schemas.report import (
|
||||
AbsenceReport,
|
||||
AbsenceReportRow,
|
||||
CompanyDashboard,
|
||||
DatevDayRow,
|
||||
DatevMonthlySheet,
|
||||
DayEntry,
|
||||
EmployeeDashboard,
|
||||
HoursBreakdown,
|
||||
@@ -675,6 +678,195 @@ class ReportService:
|
||||
total_rows=len(rows), total_hours=round(total_hours, 2), rows=rows,
|
||||
)
|
||||
|
||||
# ── DATEV-Monatsblatt ────────────────────────────────────────────────────
|
||||
# Kürzel-Mapping laut Vorlage "Stundenaufzeichnungen Muster blanko DATEV ab 2015":
|
||||
# K=Krank, U=Urlaub, UU=unbezahlter Urlaub, F=Feiertag, SA=Stundenweise abwesend,
|
||||
# SU=Stundenweise Urlaub.
|
||||
|
||||
_WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||
|
||||
@staticmethod
|
||||
def _datev_code_for_absence(absence_type: AbsenceType, is_half_day: bool) -> str:
|
||||
if absence_type.category == AbsenceCategory.SICK:
|
||||
return "K"
|
||||
if absence_type.category == AbsenceCategory.VACATION:
|
||||
if is_half_day:
|
||||
return "SU"
|
||||
return "U" if absence_type.is_paid else "UU"
|
||||
# Alle übrigen Kategorien (overtime_comp, training, business_trip, other)
|
||||
# haben kein eigenes DATEV-Kürzel -> nur Bemerkungen-Spalte
|
||||
return "SA" if is_half_day else ""
|
||||
|
||||
async def datev_monthly_report(
|
||||
self,
|
||||
company_id: UUID,
|
||||
user_id: UUID,
|
||||
year: int,
|
||||
month: int,
|
||||
db: AsyncSession,
|
||||
) -> DatevMonthlySheet:
|
||||
"""Ein-Seite-pro-Mitarbeiter-Monatsblatt im DATEV-Blanko-Layout."""
|
||||
user = await db.get(User, user_id)
|
||||
if user is None or user.company_id != company_id:
|
||||
raise ValueError("Mitarbeiter nicht gefunden")
|
||||
company = await db.get(Company, company_id)
|
||||
|
||||
days_in_month = calendar.monthrange(year, month)[1]
|
||||
date_from = date(year, month, 1)
|
||||
date_to = date(year, month, days_in_month)
|
||||
|
||||
# Zeiteinträge des Monats
|
||||
entries_stmt = select(TimeEntry).where(
|
||||
TimeEntry.user_id == user_id,
|
||||
TimeEntry.date >= date_from,
|
||||
TimeEntry.date <= date_to,
|
||||
)
|
||||
entries_by_day: dict[int, TimeEntry] = {
|
||||
e.date.day: e for e in (await db.scalars(entries_stmt)).all()
|
||||
}
|
||||
|
||||
# Genehmigte Abwesenheiten, die den Monat überlappen
|
||||
absences_stmt = (
|
||||
select(Absence, AbsenceType)
|
||||
.join(AbsenceType, Absence.absence_type_id == AbsenceType.id)
|
||||
.where(
|
||||
Absence.user_id == user_id,
|
||||
Absence.status.in_([AbsenceStatus.APPROVED, AbsenceStatus.FIRST_APPROVED]),
|
||||
Absence.start_date <= date_to,
|
||||
Absence.end_date >= date_from,
|
||||
)
|
||||
)
|
||||
absences = list((await db.execute(absences_stmt)).all())
|
||||
|
||||
# Feiertage (nur wenn Bundesland konfiguriert)
|
||||
holidays: dict[date, tuple[str, bool]] = {}
|
||||
if company and company.state:
|
||||
holidays = await get_holidays_set(date_from, date_to, company.state, db)
|
||||
|
||||
rows: list[DatevDayRow] = []
|
||||
total_hours = 0.0
|
||||
|
||||
for day in range(1, days_in_month + 1):
|
||||
d = date(year, month, day)
|
||||
weekday_label = self._WEEKDAY_LABELS[d.weekday()]
|
||||
|
||||
row = DatevDayRow(day=day, weekday_label=weekday_label)
|
||||
|
||||
entry = entries_by_day.get(day)
|
||||
if entry is not None:
|
||||
row.start_time = entry.start_time
|
||||
row.end_time = entry.end_time
|
||||
row.break_minutes = entry.break_minutes
|
||||
row.duration_hours = entry.worked_hours
|
||||
row.recorded_on = entry.created_at.date() if entry.created_at else None
|
||||
if entry.worked_hours:
|
||||
total_hours += entry.worked_hours
|
||||
|
||||
# Feiertag hat Vorrang vor Abwesenheit (analog _categorize_hours-Logik)
|
||||
if d in holidays:
|
||||
row.code = "F"
|
||||
row.note = holidays[d][0]
|
||||
else:
|
||||
for absence, absence_type in absences:
|
||||
if absence.start_date <= d <= absence.end_date:
|
||||
is_half_day = (
|
||||
(d == absence.start_date and absence.half_day_start)
|
||||
or (d == absence.end_date and absence.half_day_end)
|
||||
)
|
||||
row.code = self._datev_code_for_absence(absence_type, is_half_day)
|
||||
row.note = absence_type.name
|
||||
break
|
||||
|
||||
rows.append(row)
|
||||
|
||||
return DatevMonthlySheet(
|
||||
company_name=company.name if company else "",
|
||||
user_name=f"{user.first_name} {user.last_name}",
|
||||
personnel_number=user.personnel_number,
|
||||
year=year, month=month,
|
||||
rows=rows, total_hours=round(total_hours, 2),
|
||||
)
|
||||
|
||||
def datev_monthly_report_to_pdf(self, sheet: DatevMonthlySheet) -> bytes:
|
||||
def fmt_t(t: time | None) -> str:
|
||||
return t.strftime("%H:%M") if t else ""
|
||||
|
||||
def fmt_h(h: float | None) -> str:
|
||||
return f"{h:.2f}".replace(".", ",") if h is not None else ""
|
||||
|
||||
rows_html = ""
|
||||
for r in sheet.rows:
|
||||
break_str = f"{r.break_minutes} min" if r.break_minutes else ""
|
||||
recorded = r.recorded_on.strftime("%d.%m.%Y") if r.recorded_on else ""
|
||||
rows_html += f"""<tr>
|
||||
<td>{r.day}. ({r.weekday_label})</td>
|
||||
<td>{fmt_t(r.start_time)}</td>
|
||||
<td>{break_str}</td>
|
||||
<td>{fmt_t(r.end_time)}</td>
|
||||
<td class="right">{fmt_h(r.duration_hours)}</td>
|
||||
<td class="bold" style="text-align:center">{r.code or ""}</td>
|
||||
<td>{recorded}</td>
|
||||
<td>{r.note or ""}</td>
|
||||
</tr>"""
|
||||
|
||||
month_names = [
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
]
|
||||
period = f"{month_names[sheet.month - 1]} {sheet.year}"
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
@page {{ margin: 1.2cm 1.5cm; }}
|
||||
body {{ font-family: Arial, Helvetica, sans-serif; font-size: 9px; color: #111827; }}
|
||||
h1 {{ font-size: 14px; margin: 0 0 10px; }}
|
||||
.meta {{ display: flex; gap: 30px; margin-bottom: 12px; font-size: 10px; }}
|
||||
.meta div span {{ display: block; color: #6b7280; font-size: 8px; text-transform: uppercase; }}
|
||||
table {{ width: 100%; border-collapse: collapse; }}
|
||||
thead th {{ background: #1e40af; color: white; padding: 4px 6px; text-align: left; font-size: 8px; text-transform: uppercase; }}
|
||||
td {{ padding: 3px 6px; border-bottom: 1px solid #e5e7eb; }}
|
||||
td.right {{ text-align: right; }}
|
||||
td.bold {{ font-weight: 700; }}
|
||||
tfoot td {{ background: #1e293b; color: white; font-weight: bold; padding: 5px 6px; }}
|
||||
.signatures {{ display: flex; justify-content: space-between; margin-top: 40px; }}
|
||||
.sig-line {{ width: 45%; border-top: 1px solid #111827; padding-top: 4px; font-size: 9px; text-align: center; }}
|
||||
.codes {{ margin-top: 16px; font-size: 8px; color: #4b5563; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Stundenaufzeichnung – {period}</h1>
|
||||
<div class="meta">
|
||||
<div><span>Firma</span>{sheet.company_name}</div>
|
||||
<div><span>Name des Mitarbeiters</span>{sheet.user_name}</div>
|
||||
<div><span>Pers.-Nr.</span>{sheet.personnel_number or "—"}</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Kalendertag</th><th>Beginn</th><th>Pause</th><th>Ende</th>
|
||||
<th>Dauer</th><th>*</th><th>aufgezeichnet am</th><th>Bemerkungen</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{rows_html}
|
||||
</tbody>
|
||||
<tfoot><tr>
|
||||
<td colspan="4">Summe</td>
|
||||
<td class="right">{fmt_h(sheet.total_hours)}</td>
|
||||
<td colspan="3"></td>
|
||||
</tr></tfoot>
|
||||
</table>
|
||||
<div class="codes">* K=Krank · U=Urlaub · UU=unbezahlter Urlaub · F=Feiertag · SA=Stundenweise abwesend · SU=Stundenweise Urlaub</div>
|
||||
<div class="signatures">
|
||||
<div class="sig-line">Datum, Unterschrift Arbeitnehmer</div>
|
||||
<div class="sig-line">Datum, Unterschrift Arbeitgeber</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
from weasyprint import HTML
|
||||
return HTML(string=html).write_pdf()
|
||||
|
||||
# ── Absence Report ───────────────────────────────────────────────────────
|
||||
|
||||
async def absence_report(
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Mustervorlagen (Backend)
|
||||
|
||||
Kopiervorlagen für ein neues Workflow-Modul (Model + Schema + Router + Migration + Test),
|
||||
abgeleitet vom aktuellsten sauberen Muster im Projekt: `hours_payouts` (Migration 0042).
|
||||
|
||||
Reihenfolge beim Bau eines neuen Moduls:
|
||||
|
||||
1. `model_template.py` → `app/models/<name>.py`
|
||||
2. `schema_template.py` → `app/schemas/<name>.py`
|
||||
3. `router_template.py` → `app/routers/<name>.py`, in `app/main.py` registrieren
|
||||
4. `migration_template.py` → `migrations/versions/00XX_<name>.py`, `conftest.py` RLS nachziehen
|
||||
5. `test_template.py` → `tests/test_<name>.py`
|
||||
|
||||
Jede Datei hat einen Anleitung-Docstring am Kopf. Kern-Fallstricke sind dort verlinkt
|
||||
(RLS-Bypass + mid-request commit, Company-Isolation, conftest.py-Replikation).
|
||||
|
||||
Diese Ordner werden NICHT von der App importiert (kein `__init__.py`) – reine Vorlagen.
|
||||
@@ -0,0 +1,69 @@
|
||||
"""TEMPLATE – Mustervorlage für Migration mit NEUER Tabelle + RLS-Policy.
|
||||
|
||||
Abgeleitet vom Muster in migrations/versions/0035_absence_comments.py.
|
||||
|
||||
WICHTIG (siehe project_dsgvo_multitenant_priority):
|
||||
- JEDE neue company-bezogene Tabelle braucht company_id-Spalte + RLS-Policy
|
||||
- NACH dieser Migration: tests/conftest.py muss die RLS-Policy manuell
|
||||
replizieren (Test-DB nutzt keine echten Migrationen) – sonst schlagen
|
||||
Company-Isolation-Tests unbemerkt fehl (false green)
|
||||
- Nur neue SPALTEN auf bereits RLS-gefencten Tabellen brauchen KEINE
|
||||
RLS-Änderung (siehe 0042_payout_requests.py als Gegenbeispiel)
|
||||
|
||||
Anleitung:
|
||||
1. Nach backend/migrations/versions/00XX_xxx_things.py kopieren
|
||||
2. revision/down_revision auf nächste freie Nummer setzen (siehe
|
||||
"Datenbank-Migrationen – Chronik" in docmost für die aktuell höchste Nummer)
|
||||
3. Tabellenname/Spalten anpassen
|
||||
4. conftest.py RLS-Replikation ergänzen
|
||||
5. Lokal (auf 137!) testen: alembic upgrade head, dann pytest
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision = "00XX" # TODO: nächste freie Nummer
|
||||
down_revision = "00XX-1" # TODO: vorherige Migration
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_BYPASS = "COALESCE(current_setting('app.bypass_rls', true), 'off') = 'on'"
|
||||
_CID = "company_id = NULLIF(current_setting('app.company_id', true), '')::uuid"
|
||||
_USING = f"({_BYPASS} OR {_CID})"
|
||||
|
||||
|
||||
def _exec(sql: str) -> None:
|
||||
op.execute(text(sql))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1) Tabelle anlegen
|
||||
_exec("""
|
||||
CREATE TABLE IF NOT EXISTS xxx_things (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
note TEXT,
|
||||
status VARCHAR(12) NOT NULL DEFAULT 'requested',
|
||||
rejection_reason TEXT,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
decided_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
decided_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
_exec("CREATE INDEX IF NOT EXISTS ix_xxx_things_company_id ON xxx_things(company_id)")
|
||||
_exec("CREATE INDEX IF NOT EXISTS ix_xxx_things_user_id ON xxx_things(user_id)")
|
||||
|
||||
# 2) RLS (company_id-gefenced)
|
||||
_exec("ALTER TABLE xxx_things ENABLE ROW LEVEL SECURITY")
|
||||
_exec("ALTER TABLE xxx_things FORCE ROW LEVEL SECURITY")
|
||||
for cmd in ("select", "insert", "update", "delete"):
|
||||
_exec(f"DROP POLICY IF EXISTS rls_xxx_things_{cmd} ON xxx_things")
|
||||
_exec(f"CREATE POLICY rls_xxx_things_select ON xxx_things FOR SELECT USING {_USING}")
|
||||
_exec(f"CREATE POLICY rls_xxx_things_insert ON xxx_things FOR INSERT WITH CHECK {_USING}")
|
||||
_exec(f"CREATE POLICY rls_xxx_things_update ON xxx_things FOR UPDATE USING {_USING} WITH CHECK {_USING}")
|
||||
_exec(f"CREATE POLICY rls_xxx_things_delete ON xxx_things FOR DELETE USING {_USING}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_exec("DROP TABLE IF EXISTS xxx_things")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""TEMPLATE – Mustervorlage für ein neues Model.
|
||||
|
||||
Anleitung:
|
||||
1. Datei nach app/models/<name>.py kopieren
|
||||
2. XxxThing, xxx_things, xxx_thing_id konsequent ersetzen
|
||||
3. Enum/Felder an das reale Fachmodell anpassen
|
||||
4. In app/models/__init__.py importieren (Alembic autodiscovery)
|
||||
5. RLS-Policy in der zugehörigen Migration NICHT vergessen (company_id-Fenced) –
|
||||
siehe conftest.py, dort muss die Policy für Tests manuell repliziert werden.
|
||||
"""
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.company import Company
|
||||
|
||||
|
||||
class XxxThingStatus(str, enum.Enum):
|
||||
REQUESTED = "requested"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class XxxThing(Base):
|
||||
"""Kurzbeschreibung was dieses Model fachlich abbildet."""
|
||||
__tablename__ = "xxx_things"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
# Mandanten-Fenced – Pflicht für RLS-Isolation (DSGVO)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True
|
||||
)
|
||||
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(12), nullable=False, default=XxxThingStatus.REQUESTED.value)
|
||||
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=False
|
||||
)
|
||||
decided_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
|
||||
)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship("User", foreign_keys=[user_id], lazy="noload")
|
||||
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by], lazy="noload")
|
||||
decider: Mapped["User"] = relationship("User", foreign_keys=[decided_by], lazy="noload")
|
||||
company: Mapped["Company"] = relationship("Company", lazy="noload")
|
||||
@@ -0,0 +1,168 @@
|
||||
"""TEMPLATE – Mustervorlage für einen neuen Router (Modul mit Workflow-Status).
|
||||
|
||||
Abgeleitet vom aktuellsten sauberen Muster im Projekt: app/routers/hours_payouts.py.
|
||||
|
||||
Kern-Regeln (aus CLAUDE.md + gelernte Fallstricke):
|
||||
- Company-Isolation: JEDE Query filtert auf company_id == current_user.company_id
|
||||
(zusätzlich zur DB-seitigen RLS – Python-Check ist die zweite Verteidigungslinie)
|
||||
- **RLS-Bypass + mid-request commit Falle**: Response-Objekt (_build_out) IMMER
|
||||
VOR db.commit() bauen. db.refresh() nur nach db.flush() (noch in Transaktion).
|
||||
Nach commit() ist app.bypass_rls/app.company_id verfallen -> Post-Commit-Reads
|
||||
sehen ggf. keine Zeilen mehr.
|
||||
- AuditLog bei JEDER schreibenden Aktion (create/approve/reject/cancel/delete)
|
||||
- HTTPException mit sprechendem detail, kein Hard-Delete
|
||||
- require_role() aus core.dependencies für Rollenprüfung
|
||||
|
||||
Anleitung:
|
||||
1. Nach app/routers/<name>.py kopieren, XxxThing/xxx_things ersetzen
|
||||
2. In app/main.py registrieren: app.include_router(xxx_things.router, prefix="/api/v1")
|
||||
3. Model + Schema aus model_template.py / schema_template.py zuerst anlegen
|
||||
4. Migration schreiben (Alembic) inkl. RLS-Policy + conftest.py nachziehen
|
||||
5. Tests in backend/tests/test_xxx_things.py (siehe test_hours_payouts.py als Muster)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import get_client_ip, require_role
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.xxx_thing import XxxThing, XxxThingStatus # TODO: Model anlegen
|
||||
from app.schemas.xxx_thing import ( # TODO: Schemas anlegen
|
||||
XxxThingCreate,
|
||||
XxxThingListResponse,
|
||||
XxxThingOut,
|
||||
XxxThingReject,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["Xxx-Thing"])
|
||||
|
||||
_hr_roles = (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||
_all_roles = (UserRole.EMPLOYEE, UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||
|
||||
|
||||
async def _build_out(item: XxxThing, db: AsyncSession) -> XxxThingOut:
|
||||
"""Response-Objekt VOR commit() bauen – siehe RLS-Bypass-Falle oben."""
|
||||
out = XxxThingOut.model_validate(item)
|
||||
user = await db.get(User, item.user_id)
|
||||
out.user_name = f"{user.first_name} {user.last_name}" if user else str(item.user_id)
|
||||
return out
|
||||
|
||||
|
||||
# ── GET /xxx-things ────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/xxx-things", response_model=XxxThingListResponse)
|
||||
async def list_items(
|
||||
status: str | None = Query(None),
|
||||
current_user: User = require_role(*_all_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Liste der eigenen Firma. EMPLOYEE/MANAGER sehen nur eigene Einträge."""
|
||||
filters = [XxxThing.company_id == current_user.company_id]
|
||||
if current_user.role not in _hr_roles:
|
||||
filters.append(XxxThing.user_id == current_user.id)
|
||||
if status is not None:
|
||||
filters.append(XxxThing.status == status)
|
||||
|
||||
total_count = await db.scalar(select(func.count()).select_from(XxxThing).where(*filters))
|
||||
rows = list(await db.scalars(
|
||||
select(XxxThing).where(*filters).order_by(XxxThing.created_at.desc())
|
||||
))
|
||||
result = [await _build_out(row, db) for row in rows]
|
||||
return XxxThingListResponse(items=result, total_count=total_count or 0)
|
||||
|
||||
|
||||
# ── POST /xxx-things ───────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/xxx-things", response_model=XxxThingOut, status_code=201)
|
||||
async def create_item(
|
||||
request: Request,
|
||||
data: XxxThingCreate,
|
||||
current_user: User = require_role(*_all_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
item = XxxThing(
|
||||
company_id=current_user.company_id,
|
||||
user_id=data.user_id,
|
||||
note=data.note,
|
||||
status=XxxThingStatus.REQUESTED.value,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.add(item)
|
||||
await db.flush()
|
||||
await db.refresh(item) # server_default created_at – noch in-Transaktion
|
||||
|
||||
db.add(AuditLog(
|
||||
company_id=current_user.company_id, user_id=current_user.id,
|
||||
action="xxx_thing_created", entity_type="xxx_thing", entity_id=item.id,
|
||||
new_value={"note": data.note},
|
||||
ip=get_client_ip(request),
|
||||
))
|
||||
out = await _build_out(item, db)
|
||||
await db.commit()
|
||||
return out
|
||||
|
||||
|
||||
# ── POST /xxx-things/{id}/approve ──────────────────────────────────────────
|
||||
|
||||
@router.post("/xxx-things/{item_id}/approve", response_model=XxxThingOut)
|
||||
async def approve_item(
|
||||
item_id: UUID,
|
||||
request: Request,
|
||||
current_user: User = require_role(*_hr_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
item = await db.get(XxxThing, item_id)
|
||||
if item is None or item.company_id != current_user.company_id:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
if item.status != XxxThingStatus.REQUESTED.value:
|
||||
raise HTTPException(409, "Nur offene Anträge können genehmigt werden.")
|
||||
|
||||
item.status = XxxThingStatus.APPROVED.value
|
||||
item.decided_by = current_user.id
|
||||
item.decided_at = datetime.utcnow()
|
||||
|
||||
db.add(AuditLog(
|
||||
company_id=current_user.company_id, user_id=current_user.id,
|
||||
action="xxx_thing_approved", entity_type="xxx_thing", entity_id=item.id,
|
||||
ip=get_client_ip(request),
|
||||
))
|
||||
out = await _build_out(item, db)
|
||||
await db.commit()
|
||||
return out
|
||||
|
||||
|
||||
# ── POST /xxx-things/{id}/reject ───────────────────────────────────────────
|
||||
|
||||
@router.post("/xxx-things/{item_id}/reject", response_model=XxxThingOut)
|
||||
async def reject_item(
|
||||
item_id: UUID,
|
||||
request: Request,
|
||||
data: XxxThingReject,
|
||||
current_user: User = require_role(*_hr_roles),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
item = await db.get(XxxThing, item_id)
|
||||
if item is None or item.company_id != current_user.company_id:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
if item.status != XxxThingStatus.REQUESTED.value:
|
||||
raise HTTPException(409, "Nur offene Anträge können abgelehnt werden.")
|
||||
|
||||
item.status = XxxThingStatus.REJECTED.value
|
||||
item.rejection_reason = data.rejection_reason
|
||||
item.decided_by = current_user.id
|
||||
item.decided_at = datetime.utcnow()
|
||||
|
||||
db.add(AuditLog(
|
||||
company_id=current_user.company_id, user_id=current_user.id,
|
||||
action="xxx_thing_rejected", entity_type="xxx_thing", entity_id=item.id,
|
||||
new_value={"rejection_reason": data.rejection_reason},
|
||||
ip=get_client_ip(request),
|
||||
))
|
||||
out = await _build_out(item, db)
|
||||
await db.commit()
|
||||
return out
|
||||
@@ -0,0 +1,42 @@
|
||||
"""TEMPLATE – Mustervorlage für Pydantic-v2-Schemas.
|
||||
|
||||
Anleitung:
|
||||
1. Nach app/schemas/<name>.py kopieren, XxxThing ersetzen
|
||||
2. Computed-Felder (z.B. *_name) im Router nachträglich befüllen, nie im Schema
|
||||
selbst berechnen (Router = einzige Stelle mit DB-Zugriff für den Response-Bau)
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class XxxThingCreate(BaseModel):
|
||||
user_id: uuid.UUID
|
||||
note: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class XxxThingReject(BaseModel):
|
||||
rejection_reason: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class XxxThingOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
company_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
user_name: str = "" # Computed im Router (first_name + last_name)
|
||||
note: str | None
|
||||
status: str = "requested"
|
||||
rejection_reason: str | None = None
|
||||
created_by: uuid.UUID
|
||||
created_by_name: str = "" # Computed im Router
|
||||
decided_by: uuid.UUID | None = None
|
||||
decided_by_name: str = "" # Computed im Router
|
||||
decided_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class XxxThingListResponse(BaseModel):
|
||||
items: list[XxxThingOut]
|
||||
total_count: int
|
||||
@@ -0,0 +1,66 @@
|
||||
"""TEMPLATE – Mustervorlage für Router-Tests. Abgeleitet von test_hours_payouts.py.
|
||||
|
||||
Anleitung:
|
||||
1. Nach backend/tests/test_xxx_things.py kopieren, Endpunkte/Felder anpassen
|
||||
2. pytest-Konvention: alle Fixtures scope="session" + loop_scope="session"
|
||||
(asyncpg + pytest-asyncio 1.x Anforderung – siehe project_pytest_asyncio)
|
||||
3. Ausführen NUR auf dem Server (root@192.168.1.137), nie lokal:
|
||||
ssh root@192.168.1.137 'cd /opt/timemaster/backend && source venv/bin/activate && python -m pytest tests/test_xxx_things.py -v'
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||
async def xxx_thing_headers(client: AsyncClient):
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"company_name": "XxxThing GmbH",
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"email": "admin@xxxthinggmbh.de",
|
||||
"password": "Secret123",
|
||||
})
|
||||
assert resp.status_code == 201, resp.text
|
||||
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_create_item(client: AsyncClient, xxx_thing_headers):
|
||||
r = await client.post("/api/v1/xxx-things",
|
||||
json={"user_id": "...", "note": "Test"}, headers=xxx_thing_headers)
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["status"] == "requested"
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_approve_item(client: AsyncClient, xxx_thing_headers):
|
||||
create = await client.post("/api/v1/xxx-things",
|
||||
json={"user_id": "...", "note": "Test"}, headers=xxx_thing_headers)
|
||||
item_id = create.json()["id"]
|
||||
|
||||
ap = await client.post(f"/api/v1/xxx-things/{item_id}/approve", json={}, headers=xxx_thing_headers)
|
||||
assert ap.status_code == 200, ap.text
|
||||
assert ap.json()["status"] == "approved"
|
||||
|
||||
# Doppel-Approve muss scheitern (409)
|
||||
ap2 = await client.post(f"/api/v1/xxx-things/{item_id}/approve", json={}, headers=xxx_thing_headers)
|
||||
assert ap2.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio(loop_scope="session")
|
||||
async def test_company_isolation(client: AsyncClient, xxx_thing_headers):
|
||||
"""Cross-Tenant-Zugriff muss 404 liefern, nicht 200/403 (verrät keine Existenz)."""
|
||||
other = await client.post("/api/v1/auth/register", json={
|
||||
"company_name": "Andere Firma GmbH",
|
||||
"first_name": "Other", "last_name": "Admin",
|
||||
"email": "admin@andere-firma.de", "password": "Secret123",
|
||||
})
|
||||
other_headers = {"Authorization": f"Bearer {other.json()['access_token']}"}
|
||||
|
||||
create = await client.post("/api/v1/xxx-things",
|
||||
json={"user_id": "...", "note": "Geheim"}, headers=xxx_thing_headers)
|
||||
item_id = create.json()["id"]
|
||||
|
||||
r = await client.get("/api/v1/xxx-things", headers=other_headers)
|
||||
assert all(item["id"] != item_id for item in r.json()["items"])
|
||||
Reference in New Issue
Block a user