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.
|
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
|
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.",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user