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 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 21:07:32 +02:00
parent 39a0e370bc
commit fbc04bc2c0
5 changed files with 217 additions and 24 deletions
+12
View File
@@ -547,3 +547,15 @@ Keine Commits in dieser Session.
Keine Änderungen ermittelbar. 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 ++++++++++++++++++++++
---
+3 -3
View File
@@ -170,15 +170,15 @@ async def _process_import(
errors += 1 errors += 1
continue continue
# Personalnr.-Konflikt mit DB? # Personalnr.-Konflikt mit DB? Eigene Nummer (Reaktivierung) zulassen.
if personnel_number: if personnel_number:
taken = await db.scalar( taken = await db.scalar(
select(User.id).where( select(User).where(
User.company_id == company_id, User.company_id == company_id,
User.personnel_number == personnel_number, User.personnel_number == personnel_number,
) )
) )
if taken is not None: if taken is not None and taken.email.lower() != email:
items.append(ImportRowResult( items.append(ImportRowResult(
row=idx, email=email, personnel_number=personnel_number, row=idx, email=email, personnel_number=personnel_number,
action="error", message="Personalnummer ist bereits vergeben.", action="error", message="Personalnummer ist bereits vergeben.",
+9 -1
View File
@@ -6,6 +6,7 @@ from sqlalchemy import text
from app.main import app from app.main import app
from app.core.database import Base, get_db from app.core.database import Base, get_db
from app.core.limiter import limiter
# Echte PostgreSQL Test-Datenbank (kein SQLite Models nutzen JSONB/UUID) # Echte PostgreSQL Test-Datenbank (kein SQLite Models nutzen JSONB/UUID)
TEST_DATABASE_URL = "postgresql+asyncpg://timemaster:timemaster_secret_change_me@localhost:5432/timemaster_test" 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") @pytest_asyncio.fixture(scope="session", loop_scope="session")
async def client(db_session: AsyncSession): async def client(db_session: AsyncSession):
async def override_get_db(): 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 app.dependency_overrides[get_db] = override_get_db
limiter.enabled = False # Rate-Limiter in Tests deaktivieren
async with AsyncClient( async with AsyncClient(
transport=ASGITransport(app=app), transport=ASGITransport(app=app),
@@ -51,6 +58,7 @@ async def client(db_session: AsyncSession):
) as ac: ) as ac:
yield ac yield ac
limiter.enabled = True
app.dependency_overrides.clear() app.dependency_overrides.clear()
+13 -14
View File
@@ -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): async def test_auto_mode_assigns_personnel_number(client: AsyncClient, registered_user):
h = auth(registered_user["tokens"]) h = auth(registered_user["tokens"])
cid = registered_user["user"]["company_id"]
# Modus auf auto setzen # 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", "personnel_number_mode": "auto",
}) })
assert upd.status_code == 200 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"] nr = r.json()["personnel_number"]
assert nr is not None and nr.isdigit() assert nr is not None and nr.isdigit()
# zurück auf manual # 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", "personnel_number_mode": "manual",
}) })
async def test_required_flag_blocks_invite_without_number(client: AsyncClient, registered_user): async def test_required_flag_blocks_invite_without_number(client: AsyncClient, registered_user):
h = auth(registered_user["tokens"]) h = auth(registered_user["tokens"])
cid = registered_user["user"]["company_id"] upd = await client.patch(f"{COMPANIES_URL}/me", headers=h, json={
# Pflicht aktivieren
await client.patch(f"{COMPANIES_URL}/{cid}", headers=h, json={
"personnel_number_required": True, "personnel_number_required": True,
}) })
r = await client.post(INVITE_URL, headers=h, json={ assert upd.status_code == 200
"email": "noreq@test.de", "first_name": "X", "last_name": "X", try:
}) r = await client.post(INVITE_URL, headers=h, json={
assert r.status_code == 400 "email": "noreq2@test.de", "first_name": "X", "last_name": "X",
# Cleanup })
await client.patch(f"{COMPANIES_URL}/{cid}", headers=h, json={ assert r.status_code == 400
"personnel_number_required": False, finally:
}) await client.patch(f"{COMPANIES_URL}/me", headers=h, json={
"personnel_number_required": False,
})
# ── Lookup ─────────────────────────────────────────────────────────────────── # ── Lookup ───────────────────────────────────────────────────────────────────
+180 -6
View File
@@ -85,6 +85,22 @@ interface InviteForm {
const EMPTY_INVITE: InviteForm = { email: '', first_name: '', last_name: '', role: 'EMPLOYEE', personnel_number: '', initial_password: '' } 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() { export function UsersPage() {
const [me, setMe] = useState<Me | null>(null) const [me, setMe] = useState<Me | null>(null)
const [users, setUsers] = useState<UserOut[]>([]) const [users, setUsers] = useState<UserOut[]>([])
@@ -118,6 +134,14 @@ export function UsersPage() {
// Company-Settings (für Auto-Modus / Pflicht-Anzeige) // Company-Settings (für Auto-Modus / Pflicht-Anzeige)
const [company, setCompany] = useState<CompanyOut | null>(null) const [company, setCompany] = useState<CompanyOut | null>(null)
// CSV-Import modal
const [showImport, setShowImport] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
const [importPreview, setImportPreview] = useState<ImportResult | null>(null)
const [importLoading, setImportLoading] = useState(false)
const [importError, setImportError] = useState('')
const [importSuccess, setImportSuccess] = useState('')
async function load() { async function load() {
try { try {
const [meData, listData, schedulesData] = await Promise.all([ const [meData, listData, schedulesData] = await Promise.all([
@@ -251,6 +275,51 @@ export function UsersPage() {
return () => clearTimeout(handle) 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<ImportResult>('/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<ImportResult>('/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 => const filtered = users.filter(u =>
`${u.full_name} ${u.email} ${u.personnel_number ?? ''}`.toLowerCase().includes(search.toLowerCase()) `${u.full_name} ${u.email} ${u.personnel_number ?? ''}`.toLowerCase().includes(search.toLowerCase())
) )
@@ -275,12 +344,20 @@ export function UsersPage() {
<h1 className='text-xl font-bold text-gray-800'>Mitarbeiterverwaltung</h1> <h1 className='text-xl font-bold text-gray-800'>Mitarbeiterverwaltung</h1>
<p className='text-sm text-gray-500 mt-0.5'>{total} Mitarbeiter gesamt</p> <p className='text-sm text-gray-500 mt-0.5'>{total} Mitarbeiter gesamt</p>
</div> </div>
<button <div className='flex gap-2'>
onClick={() => { setShowInvite(true); setInviteSuccess(''); setInviteError('') }} <button
className='px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 transition-colors' onClick={openImportModal}
> className='px-4 py-2 bg-gray-100 text-gray-700 text-sm font-semibold rounded-lg hover:bg-gray-200 transition-colors border border-gray-200'
+ Einladen >
</button> CSV Import
</button>
<button
onClick={() => { setShowInvite(true); setInviteSuccess(''); setInviteError('') }}
className='px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 transition-colors'
>
+ Einladen
</button>
</div>
</div> </div>
{/* Search */} {/* Search */}
@@ -480,6 +557,103 @@ export function UsersPage() {
</Modal> </Modal>
)} )}
{/* CSV Import Modal */}
{showImport && (
<Modal title='Mitarbeiter per CSV importieren' onClose={() => setShowImport(false)}>
<div className='space-y-4'>
<div className='flex items-center justify-between bg-blue-50 border border-blue-100 rounded-lg px-3 py-2'>
<span className='text-xs text-blue-700'>Vorlage mit Pflicht- und Optionalspalten herunterladen</span>
<a
href='/api/v1/users/import-template.csv'
download='import-vorlage.csv'
className='text-xs font-semibold text-blue-600 hover:underline ml-4 whitespace-nowrap'
>
Vorlage herunterladen
</a>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 mb-1'>CSV-Datei auswählen</label>
<input
type='file'
accept='.csv,text/csv'
onChange={e => {
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'
/>
</div>
{importError && <p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{importError}</p>}
{importSuccess && <p className='text-sm text-green-700 bg-green-50 border border-green-200 rounded px-3 py-2'>{importSuccess}</p>}
{importPreview && (
<div className='space-y-2'>
<div className='flex gap-4 text-sm'>
<span className='text-green-700 font-medium'>{importPreview.created} neu</span>
<span className='text-blue-700 font-medium'>{importPreview.reactivated} reaktiviert</span>
<span className='text-red-600 font-medium'>{importPreview.errors} Fehler</span>
<span className='text-gray-500'>{importPreview.total_rows} Zeilen gesamt</span>
</div>
<div className='max-h-56 overflow-y-auto border border-gray-200 rounded-lg'>
<table className='w-full text-xs'>
<thead className='bg-gray-50 text-gray-500 sticky top-0'>
<tr>
{['Zeile', 'E-Mail', 'Pers.-Nr.', 'Aktion', 'Meldung'].map(h => (
<th key={h} className='px-3 py-2 text-left font-medium'>{h}</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{importPreview.items.map((it, i) => (
<tr key={i} className={it.action === 'error' ? 'bg-red-50' : it.action === 'reactivated' ? 'bg-blue-50' : ''}>
<td className='px-3 py-1.5 text-gray-500'>{it.row || '—'}</td>
<td className='px-3 py-1.5 font-mono'>{it.email || '—'}</td>
<td className='px-3 py-1.5 font-mono'>{it.personnel_number || '—'}</td>
<td className='px-3 py-1.5'>
<span className={`font-semibold ${
it.action === 'error' ? 'text-red-600' :
it.action === 'reactivated' ? 'text-blue-600' :
'text-green-600'
}`}>
{it.action === 'error' ? 'Fehler' : it.action === 'reactivated' ? 'Reaktiviert' : 'Neu'}
</span>
</td>
<td className='px-3 py-1.5 text-gray-500'>{it.message || ''}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className='flex justify-end gap-2 pt-1'>
<button onClick={() => setShowImport(false)} className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>Schließen</button>
<button
onClick={handleImportPreview}
disabled={!importFile || importLoading}
className='px-4 py-2 text-sm font-semibold text-white bg-gray-600 rounded-lg hover:bg-gray-700 disabled:opacity-50'
>
{importLoading && !importPreview ? 'Prüfe…' : 'Vorschau prüfen'}
</button>
{importPreview && importPreview.errors === 0 && !importSuccess && (
<button
onClick={handleImportApply}
disabled={importLoading}
className='px-4 py-2 text-sm font-semibold text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50'
>
{importLoading ? 'Importiere…' : `${importPreview.created + importPreview.reactivated} Benutzer importieren`}
</button>
)}
</div>
</div>
</Modal>
)}
{/* Edit User Modal */} {/* Edit User Modal */}
{editUser && ( {editUser && (
<Modal title={`Mitarbeiter bearbeiten ${editUser.full_name}`} onClose={() => setEditUser(null)}> <Modal title={`Mitarbeiter bearbeiten ${editUser.full_name}`} onClose={() => setEditUser(null)}>