feat: agent-11 PR1 – Vertretung, Storno-Re-Genehmigung, Kommentare
Abwesenheits-Modul abgerundet (Feature-Parität mit Urlaubsverwaltung):
- Vertretung: Overlap-Warnung beim Anlegen, E-Mail an Vertretung bei
Genehmigung, GET /absences/?as_substitute=true, neuer schlanker
GET /users/colleagues (alle Rollen, RLS-gefenced) für die Auswahl;
Vertreter-Dropdown + Anzeige in der Liste.
- Stornierung mit Re-Genehmigung: neuer Status CANCELLATION_REQUESTED,
POST /absences/{id}/request-cancellation; Manager genehmigt/lehnt über
bestehende approve/reject ab (Urlaub + FZA-Rückbuchung via _apply_cancellation).
- Kommentare: Model AbsenceComment (company_id-RLS), GET/POST comments,
System-Kommentare bei Statuswechsel, AbsenceCommentsModal.
- Fix: CalDAV fire-and-forget nutzte die Request-Session weiter (in Tests
geteilt -> "another operation in progress"); jetzt sync_*_bg mit eigener
Session + RLS-Bypass.
Migration 0035. 178/178 Tests grün. Deployed auf 137 + 164.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ def _rls_using_join(): return (
|
||||
)
|
||||
|
||||
_COMPANY_COL_TABLES = [
|
||||
"absence_types", "audit_logs", "caldav_company_configs", "departments",
|
||||
"absence_comments", "absence_types", "audit_logs", "caldav_company_configs", "departments",
|
||||
"kiosk_devices", "ldap_configs", "overtime_balances", "smtp_configs",
|
||||
"special_assignments", "users", "work_schedules",
|
||||
]
|
||||
|
||||
@@ -481,3 +481,147 @@ async def test_sick_stats_bradford_factor(client: AsyncClient, abs_headers):
|
||||
# Bradford-Formel verifizieren
|
||||
expected = float(row["episodes"]) ** 2 * row["total_days"]
|
||||
assert abs(row["bradford_factor"] - expected) < 0.001
|
||||
|
||||
|
||||
# ── agent-11 PR1: Vertretung · Stornierung · Kommentare ────────────────────────
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||
async def substitute_user_id(client: AsyncClient, abs_headers):
|
||||
"""Mitarbeiter, der als Vertretung eingetragen werden kann."""
|
||||
resp = await client.post("/api/v1/users/invite", json={
|
||||
"first_name": "Sub", "last_name": "Stitute",
|
||||
"email": "sub@absenceag.de", "role": "EMPLOYEE",
|
||||
"initial_password": "Secret123",
|
||||
}, headers=abs_headers)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
def _future_monday(weeks: int) -> date:
|
||||
return date.today() + timedelta(days=(7 - date.today().weekday()) + 7 * weeks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancellation_request_flow(
|
||||
client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id
|
||||
):
|
||||
"""Genehmigten Antrag → Stornoantrag → Manager genehmigt → cancelled + Urlaub zurück."""
|
||||
start = _future_monday(7)
|
||||
create = await client.post("/api/v1/absences/", json={
|
||||
"type_id": str(vacation_type_id),
|
||||
"start_date": str(start), "end_date": str(start + timedelta(days=4)),
|
||||
}, headers=abs_headers)
|
||||
aid = create.json()["id"]
|
||||
working_days = create.json()["working_days"]
|
||||
|
||||
approve = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
|
||||
assert approve.status_code == 200
|
||||
used_after_approve = (await client.get(
|
||||
"/api/v1/absences/balance", params={"year": start.year}, headers=abs_headers
|
||||
)).json()["used_days"]
|
||||
|
||||
# Stornoantrag durch Mitarbeiter (Owner)
|
||||
req = await client.post(
|
||||
f"/api/v1/absences/{aid}/request-cancellation",
|
||||
json={"reason": "Plan geaendert"}, headers=abs_headers,
|
||||
)
|
||||
assert req.status_code == 200, req.text
|
||||
assert req.json()["status"] == "cancellation_requested"
|
||||
|
||||
# Manager genehmigt die Stornierung
|
||||
ok = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
|
||||
assert ok.status_code == 200, ok.text
|
||||
assert ok.json()["status"] == "cancelled"
|
||||
|
||||
used_after_cancel = (await client.get(
|
||||
"/api/v1/absences/balance", params={"year": start.year}, headers=abs_headers
|
||||
)).json()["used_days"]
|
||||
assert used_after_cancel == used_after_approve - int(working_days)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancellation_request_rejected_keeps_approved(
|
||||
client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id
|
||||
):
|
||||
start = _future_monday(9)
|
||||
create = await client.post("/api/v1/absences/", json={
|
||||
"type_id": str(vacation_type_id),
|
||||
"start_date": str(start), "end_date": str(start + timedelta(days=2)),
|
||||
}, headers=abs_headers)
|
||||
aid = create.json()["id"]
|
||||
await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
|
||||
await client.post(f"/api/v1/absences/{aid}/request-cancellation", json={}, headers=abs_headers)
|
||||
|
||||
rej = await client.post(
|
||||
f"/api/v1/absences/{aid}/reject",
|
||||
json={"rejection_reason": "Vertretung fehlt"}, headers=abs_approver_headers,
|
||||
)
|
||||
assert rej.status_code == 200, rej.text
|
||||
assert rej.json()["status"] == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_cancellation_requires_approved(
|
||||
client: AsyncClient, abs_headers, vacation_type_id
|
||||
):
|
||||
"""PENDING-Antrag kann nicht zur Stornierung eingereicht werden (nur direkt löschen)."""
|
||||
start = _future_monday(11)
|
||||
create = await client.post("/api/v1/absences/", json={
|
||||
"type_id": str(vacation_type_id),
|
||||
"start_date": str(start), "end_date": str(start + timedelta(days=1)),
|
||||
}, headers=abs_headers)
|
||||
aid = create.json()["id"]
|
||||
req = await client.post(f"/api/v1/absences/{aid}/request-cancellation", json={}, headers=abs_headers)
|
||||
assert req.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_substitute_filter_and_notification(
|
||||
client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id, substitute_user_id
|
||||
):
|
||||
"""Antrag mit Vertretung → Vertreter sieht ihn unter ?as_substitute=true."""
|
||||
start = _future_monday(13)
|
||||
create = await client.post("/api/v1/absences/", json={
|
||||
"type_id": str(vacation_type_id),
|
||||
"start_date": str(start), "end_date": str(start + timedelta(days=2)),
|
||||
"substitute_id": substitute_user_id,
|
||||
}, headers=abs_headers)
|
||||
assert create.status_code == 201, create.text
|
||||
aid = create.json()["id"]
|
||||
assert create.json()["substitute_id"] == substitute_user_id
|
||||
await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers)
|
||||
|
||||
sub_login = await client.post("/api/v1/auth/login", json={
|
||||
"email": "sub@absenceag.de", "password": "Secret123",
|
||||
})
|
||||
sub_headers = {"Authorization": f"Bearer {sub_login.json()['access_token']}"}
|
||||
lst = await client.get("/api/v1/absences/?as_substitute=true", headers=sub_headers)
|
||||
assert lst.status_code == 200, lst.text
|
||||
assert any(a["id"] == aid for a in lst.json()["items"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absence_comments(
|
||||
client: AsyncClient, abs_headers, vacation_type_id
|
||||
):
|
||||
"""Kommentar posten + System-Kommentar bei Stornoantrag erscheint im Thread."""
|
||||
start = _future_monday(15)
|
||||
create = await client.post("/api/v1/absences/", json={
|
||||
"type_id": str(vacation_type_id),
|
||||
"start_date": str(start), "end_date": str(start + timedelta(days=1)),
|
||||
"note": "Brueckentag",
|
||||
}, headers=abs_headers)
|
||||
aid = create.json()["id"]
|
||||
|
||||
add = await client.post(
|
||||
f"/api/v1/absences/{aid}/comments",
|
||||
json={"body": "Bitte zuegig pruefen"}, headers=abs_headers,
|
||||
)
|
||||
assert add.status_code == 201, add.text
|
||||
assert add.json()["is_system"] is False
|
||||
assert add.json()["author_name"]
|
||||
|
||||
lst = await client.get(f"/api/v1/absences/{aid}/comments", headers=abs_headers)
|
||||
assert lst.status_code == 200
|
||||
bodies = [c["body"] for c in lst.json()]
|
||||
assert "Bitte zuegig pruefen" in bodies
|
||||
|
||||
Reference in New Issue
Block a user