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:
@@ -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 ++++++++++++++++++++++
|
||||
|
||||
---
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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():
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -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,24 +93,24 @@ 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,
|
||||
})
|
||||
assert upd.status_code == 200
|
||||
try:
|
||||
r = await client.post(INVITE_URL, headers=h, json={
|
||||
"email": "noreq@test.de", "first_name": "X", "last_name": "X",
|
||||
"email": "noreq2@test.de", "first_name": "X", "last_name": "X",
|
||||
})
|
||||
assert r.status_code == 400
|
||||
# Cleanup
|
||||
await client.patch(f"{COMPANIES_URL}/{cid}", headers=h, json={
|
||||
finally:
|
||||
await client.patch(f"{COMPANIES_URL}/me", headers=h, json={
|
||||
"personnel_number_required": False,
|
||||
})
|
||||
|
||||
|
||||
@@ -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<Me | null>(null)
|
||||
const [users, setUsers] = useState<UserOut[]>([])
|
||||
@@ -118,6 +134,14 @@ export function UsersPage() {
|
||||
// Company-Settings (für Auto-Modus / Pflicht-Anzeige)
|
||||
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() {
|
||||
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<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 =>
|
||||
`${u.full_name} ${u.email} ${u.personnel_number ?? ''}`.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
@@ -275,6 +344,13 @@ export function UsersPage() {
|
||||
<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>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<button
|
||||
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'
|
||||
>
|
||||
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'
|
||||
@@ -282,6 +358,7 @@ export function UsersPage() {
|
||||
+ Einladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<input
|
||||
@@ -480,6 +557,103 @@ export function UsersPage() {
|
||||
</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 */}
|
||||
{editUser && (
|
||||
<Modal title={`Mitarbeiter bearbeiten – ${editUser.full_name}`} onClose={() => setEditUser(null)}>
|
||||
|
||||
Reference in New Issue
Block a user