From fbc04bc2c0fca3b328b472118c5e7887d153c484 Mon Sep 17 00:00:00 2001 From: patrick Date: Sat, 23 May 2026 21:07:32 +0200 Subject: [PATCH] agent-07 phase 2: fix test isolation + CSV import UI - Fix conftest.py: commit after each request in override_get_db so preview_csv's rollback no longer wipes the shared registered_user (root cause of 401 cascade across test_user_import + test_personnel_number) - Fix limiter.enabled=False in client fixture (blocks rate-limit 429) - Fix user_import_service: allow reactivation when personnel number belongs to the same user being reactivated - Fix test_personnel_number: use PATCH /companies/me (not /companies/{id}) and add try/finally cleanup for personnel_number_required flag - Frontend UsersPage: add CSV import modal with template download, preview/validation table, and guarded apply button Co-Authored-By: Claude Sonnet 4.6 --- DEVLOG.md | 12 ++ backend/app/services/user_import_service.py | 6 +- backend/tests/conftest.py | 10 +- backend/tests/test_personnel_number.py | 27 ++- frontend/src/pages/UsersPage.tsx | 186 +++++++++++++++++++- 5 files changed, 217 insertions(+), 24 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 35e4b87..b060a37 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -547,3 +547,15 @@ Keine Commits in dieser Session. Keine Änderungen ermittelbar. --- +## 2026-05-23 20:11 – 20:12 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 39a0e37 .gitignore und DEVLOG aktualisieren + +### Geänderte Dateien +- .gitignore | 4 ++++ +- DEVLOG.md | 22 ++++++++++++++++++++++ + +--- diff --git a/backend/app/services/user_import_service.py b/backend/app/services/user_import_service.py index 65feade..8a22021 100644 --- a/backend/app/services/user_import_service.py +++ b/backend/app/services/user_import_service.py @@ -170,15 +170,15 @@ async def _process_import( errors += 1 continue - # Personalnr.-Konflikt mit DB? + # Personalnr.-Konflikt mit DB? Eigene Nummer (Reaktivierung) zulassen. if personnel_number: taken = await db.scalar( - select(User.id).where( + select(User).where( User.company_id == company_id, User.personnel_number == personnel_number, ) ) - if taken is not None: + if taken is not None and taken.email.lower() != email: items.append(ImportRowResult( row=idx, email=email, personnel_number=personnel_number, action="error", message="Personalnummer ist bereits vergeben.", diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2157089..2da6804 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -6,6 +6,7 @@ from sqlalchemy import text from app.main import app from app.core.database import Base, get_db +from app.core.limiter import limiter # Echte PostgreSQL Test-Datenbank (kein SQLite – Models nutzen JSONB/UUID) TEST_DATABASE_URL = "postgresql+asyncpg://timemaster:timemaster_secret_change_me@localhost:5432/timemaster_test" @@ -41,9 +42,15 @@ async def db_session(): @pytest_asyncio.fixture(scope="session", loop_scope="session") async def client(db_session: AsyncSession): async def override_get_db(): - yield db_session + try: + yield db_session + await db_session.commit() + except Exception: + await db_session.rollback() + raise app.dependency_overrides[get_db] = override_get_db + limiter.enabled = False # Rate-Limiter in Tests deaktivieren async with AsyncClient( transport=ASGITransport(app=app), @@ -51,6 +58,7 @@ async def client(db_session: AsyncSession): ) as ac: yield ac + limiter.enabled = True app.dependency_overrides.clear() diff --git a/backend/tests/test_personnel_number.py b/backend/tests/test_personnel_number.py index 411b613..54c5fc3 100644 --- a/backend/tests/test_personnel_number.py +++ b/backend/tests/test_personnel_number.py @@ -80,9 +80,8 @@ async def test_next_personnel_number_endpoint(client: AsyncClient, registered_us async def test_auto_mode_assigns_personnel_number(client: AsyncClient, registered_user): h = auth(registered_user["tokens"]) - cid = registered_user["user"]["company_id"] # Modus auf auto setzen - upd = await client.patch(f"{COMPANIES_URL}/{cid}", headers=h, json={ + upd = await client.patch(f"{COMPANIES_URL}/me", headers=h, json={ "personnel_number_mode": "auto", }) assert upd.status_code == 200 @@ -94,26 +93,26 @@ async def test_auto_mode_assigns_personnel_number(client: AsyncClient, registere nr = r.json()["personnel_number"] assert nr is not None and nr.isdigit() # zurück auf manual - await client.patch(f"{COMPANIES_URL}/{cid}", headers=h, json={ + await client.patch(f"{COMPANIES_URL}/me", headers=h, json={ "personnel_number_mode": "manual", }) async def test_required_flag_blocks_invite_without_number(client: AsyncClient, registered_user): h = auth(registered_user["tokens"]) - cid = registered_user["user"]["company_id"] - # Pflicht aktivieren - await client.patch(f"{COMPANIES_URL}/{cid}", headers=h, json={ + upd = await client.patch(f"{COMPANIES_URL}/me", headers=h, json={ "personnel_number_required": True, }) - r = await client.post(INVITE_URL, headers=h, json={ - "email": "noreq@test.de", "first_name": "X", "last_name": "X", - }) - assert r.status_code == 400 - # Cleanup - await client.patch(f"{COMPANIES_URL}/{cid}", headers=h, json={ - "personnel_number_required": False, - }) + assert upd.status_code == 200 + try: + r = await client.post(INVITE_URL, headers=h, json={ + "email": "noreq2@test.de", "first_name": "X", "last_name": "X", + }) + assert r.status_code == 400 + finally: + await client.patch(f"{COMPANIES_URL}/me", headers=h, json={ + "personnel_number_required": False, + }) # ── Lookup ─────────────────────────────────────────────────────────────────── diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index 08fe36a..a7a4289 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -85,6 +85,22 @@ interface InviteForm { const EMPTY_INVITE: InviteForm = { email: '', first_name: '', last_name: '', role: 'EMPLOYEE', personnel_number: '', initial_password: '' } +interface ImportRowResult { + row: number + email: string + personnel_number: string | null + action: string + message: string | null +} + +interface ImportResult { + total_rows: number + created: number + reactivated: number + errors: number + items: ImportRowResult[] +} + export function UsersPage() { const [me, setMe] = useState(null) const [users, setUsers] = useState([]) @@ -118,6 +134,14 @@ export function UsersPage() { // Company-Settings (für Auto-Modus / Pflicht-Anzeige) const [company, setCompany] = useState(null) + // CSV-Import modal + const [showImport, setShowImport] = useState(false) + const [importFile, setImportFile] = useState(null) + const [importPreview, setImportPreview] = useState(null) + const [importLoading, setImportLoading] = useState(false) + const [importError, setImportError] = useState('') + const [importSuccess, setImportSuccess] = useState('') + async function load() { try { const [meData, listData, schedulesData] = await Promise.all([ @@ -251,6 +275,51 @@ export function UsersPage() { return () => clearTimeout(handle) } + async function handleImportPreview() { + if (!importFile) return + setImportLoading(true) + setImportError('') + setImportPreview(null) + try { + const form = new FormData() + form.append('file', importFile) + const result = await api.postForm('/users/import/preview', form) + setImportPreview(result) + } catch (e: unknown) { + setImportError(e instanceof Error ? e.message : 'Fehler beim Prüfen') + } finally { + setImportLoading(false) + } + } + + async function handleImportApply() { + if (!importFile) return + setImportLoading(true) + setImportError('') + setImportSuccess('') + try { + const form = new FormData() + form.append('file', importFile) + const result = await api.postForm('/users/import/apply', form) + setImportSuccess(`Import abgeschlossen: ${result.created} angelegt, ${result.reactivated} reaktiviert, ${result.errors} Fehler.`) + setImportPreview(result) + setImportFile(null) + load() + } catch (e: unknown) { + setImportError(e instanceof Error ? e.message : 'Fehler beim Import') + } finally { + setImportLoading(false) + } + } + + function openImportModal() { + setShowImport(true) + setImportFile(null) + setImportPreview(null) + setImportError('') + setImportSuccess('') + } + const filtered = users.filter(u => `${u.full_name} ${u.email} ${u.personnel_number ?? ''}`.toLowerCase().includes(search.toLowerCase()) ) @@ -275,12 +344,20 @@ export function UsersPage() {

Mitarbeiterverwaltung

{total} Mitarbeiter gesamt

- +
+ + +
{/* Search */} @@ -480,6 +557,103 @@ export function UsersPage() { )} + {/* CSV Import Modal */} + {showImport && ( + setShowImport(false)}> +
+
+ Vorlage mit Pflicht- und Optionalspalten herunterladen + + Vorlage herunterladen + +
+ +
+ + { + setImportFile(e.target.files?.[0] ?? null) + setImportPreview(null) + setImportError('') + setImportSuccess('') + }} + className='block w-full text-sm text-gray-600 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-xs file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100' + /> +
+ + {importError &&

{importError}

} + {importSuccess &&

{importSuccess}

} + + {importPreview && ( +
+
+ {importPreview.created} neu + {importPreview.reactivated} reaktiviert + {importPreview.errors} Fehler + {importPreview.total_rows} Zeilen gesamt +
+
+ + + + {['Zeile', 'E-Mail', 'Pers.-Nr.', 'Aktion', 'Meldung'].map(h => ( + + ))} + + + + {importPreview.items.map((it, i) => ( + + + + + + + + ))} + +
{h}
{it.row || '—'}{it.email || '—'}{it.personnel_number || '—'} + + {it.action === 'error' ? 'Fehler' : it.action === 'reactivated' ? 'Reaktiviert' : 'Neu'} + + {it.message || ''}
+
+
+ )} + +
+ + + {importPreview && importPreview.errors === 0 && !importSuccess && ( + + )} +
+
+
+ )} + {/* Edit User Modal */} {editUser && ( setEditUser(null)}>