Initial commit – TimeMaster Zeiterfassung & HR-Tool
Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer), Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst. Migrations 0001–0023 deployed auf 192.168.1.137 + .164. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
"""User CSV Bulk Import – validates, creates new users or reactivates deactivated ones."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import generate_invite_token, hash_password
|
||||
from app.models.company import Company, PersonnelNumberMode
|
||||
from app.models.user import User, UserRole
|
||||
from app.services.email_service import email_service
|
||||
from app.services.user_service import user_service
|
||||
|
||||
|
||||
REQUIRED_HEADERS = ["email", "first_name", "last_name"]
|
||||
OPTIONAL_HEADERS = ["role", "personnel_number", "kuerzel"]
|
||||
TEMPLATE_HEADERS = REQUIRED_HEADERS + OPTIONAL_HEADERS
|
||||
|
||||
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
PERSONNEL_RE = re.compile(r"^[0-9]+$")
|
||||
VALID_ROLES = {r.value for r in UserRole if r != UserRole.SUPER_ADMIN}
|
||||
|
||||
|
||||
# ── Datenstrukturen ──────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class ImportRowResult:
|
||||
row: int
|
||||
email: str
|
||||
personnel_number: str | None
|
||||
action: str # created | reactivated | error
|
||||
message: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportResult:
|
||||
total_rows: int
|
||||
created: int
|
||||
reactivated: int
|
||||
errors: int
|
||||
items: list[ImportRowResult]
|
||||
|
||||
|
||||
# ── CSV-Parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
def build_template_csv() -> str:
|
||||
"""CSV template returned via /users/import-template.csv."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(TEMPLATE_HEADERS)
|
||||
writer.writerow([
|
||||
"max@firma.de", "Max", "Mustermann",
|
||||
"EMPLOYEE", "0042", "MM",
|
||||
])
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _normalize(value: str | None) -> str:
|
||||
return (value or "").strip()
|
||||
|
||||
|
||||
def _parse_csv(content: bytes) -> tuple[list[dict[str, str]], list[str]]:
|
||||
"""Parse CSV bytes (BOM-safe). Returns (rows, header_errors)."""
|
||||
text = content.decode("utf-8-sig")
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
if reader.fieldnames is None:
|
||||
return [], ["CSV ist leer oder kein gültiger Header gefunden."]
|
||||
headers = [h.strip() for h in reader.fieldnames]
|
||||
missing = [h for h in REQUIRED_HEADERS if h not in headers]
|
||||
if missing:
|
||||
return [], [f"Pflicht-Spalten fehlen: {', '.join(missing)}"]
|
||||
rows = list(reader)
|
||||
return rows, []
|
||||
|
||||
|
||||
# ── Import-Kern (Preview & Apply gemeinsam) ──────────────────────────────────
|
||||
|
||||
async def _process_import(
|
||||
*,
|
||||
content: bytes,
|
||||
company_id: UUID,
|
||||
invited_by: User,
|
||||
db: AsyncSession,
|
||||
apply: bool,
|
||||
) -> ImportResult:
|
||||
"""Process CSV bulk import. apply=False = validation only (no DB writes, rolled back)."""
|
||||
rows, header_errors = _parse_csv(content)
|
||||
items: list[ImportRowResult] = []
|
||||
|
||||
if header_errors:
|
||||
for msg in header_errors:
|
||||
items.append(ImportRowResult(
|
||||
row=0, email="", personnel_number=None, action="error", message=msg,
|
||||
))
|
||||
return ImportResult(total_rows=0, created=0, reactivated=0, errors=len(items), items=items)
|
||||
|
||||
company = await db.get(Company, company_id)
|
||||
if company is None:
|
||||
items.append(ImportRowResult(
|
||||
row=0, email="", personnel_number=None, action="error", message="Firma nicht gefunden.",
|
||||
))
|
||||
return ImportResult(total_rows=0, created=0, reactivated=0, errors=1, items=items)
|
||||
|
||||
seen_emails_in_csv: set[str] = set()
|
||||
used_personnel_in_csv: set[str] = set()
|
||||
created = 0
|
||||
reactivated = 0
|
||||
errors = 0
|
||||
|
||||
for idx, raw in enumerate(rows, start=2): # CSV row numbers start at 2 (after header)
|
||||
email = _normalize(raw.get("email")).lower()
|
||||
first_name = _normalize(raw.get("first_name"))
|
||||
last_name = _normalize(raw.get("last_name"))
|
||||
role_str = _normalize(raw.get("role")) or UserRole.EMPLOYEE.value
|
||||
personnel_number = _normalize(raw.get("personnel_number")) or None
|
||||
kuerzel = _normalize(raw.get("kuerzel")) or None
|
||||
|
||||
# Validation
|
||||
if not email or not EMAIL_RE.match(email):
|
||||
items.append(ImportRowResult(
|
||||
row=idx, email=email, personnel_number=personnel_number,
|
||||
action="error", message="Ungültige E-Mail-Adresse.",
|
||||
))
|
||||
errors += 1
|
||||
continue
|
||||
if not first_name or not last_name:
|
||||
items.append(ImportRowResult(
|
||||
row=idx, email=email, personnel_number=personnel_number,
|
||||
action="error", message="Vor- und Nachname sind Pflicht.",
|
||||
))
|
||||
errors += 1
|
||||
continue
|
||||
if role_str not in VALID_ROLES:
|
||||
items.append(ImportRowResult(
|
||||
row=idx, email=email, personnel_number=personnel_number,
|
||||
action="error", message=f"Ungültige Rolle: {role_str}",
|
||||
))
|
||||
errors += 1
|
||||
continue
|
||||
if personnel_number is not None and not PERSONNEL_RE.match(personnel_number):
|
||||
items.append(ImportRowResult(
|
||||
row=idx, email=email, personnel_number=personnel_number,
|
||||
action="error", message="Personalnummer darf nur Ziffern enthalten.",
|
||||
))
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
# Doppelte Mail im Import → Fehler
|
||||
if email in seen_emails_in_csv:
|
||||
items.append(ImportRowResult(
|
||||
row=idx, email=email, personnel_number=personnel_number,
|
||||
action="error", message="E-Mail kommt im Import mehrfach vor.",
|
||||
))
|
||||
errors += 1
|
||||
continue
|
||||
seen_emails_in_csv.add(email)
|
||||
|
||||
# Doppelte Personalnr. im Import → Fehler
|
||||
if personnel_number and personnel_number in used_personnel_in_csv:
|
||||
items.append(ImportRowResult(
|
||||
row=idx, email=email, personnel_number=personnel_number,
|
||||
action="error", message="Personalnummer kommt im Import mehrfach vor.",
|
||||
))
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
# Personalnr.-Konflikt mit DB?
|
||||
if personnel_number:
|
||||
taken = await db.scalar(
|
||||
select(User.id).where(
|
||||
User.company_id == company_id,
|
||||
User.personnel_number == personnel_number,
|
||||
)
|
||||
)
|
||||
if taken is not None:
|
||||
items.append(ImportRowResult(
|
||||
row=idx, email=email, personnel_number=personnel_number,
|
||||
action="error", message="Personalnummer ist bereits vergeben.",
|
||||
))
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
# Auto-Vergabe wenn leer (auch im Manuell-Modus laut Anforderung)
|
||||
if not personnel_number:
|
||||
personnel_number = await user_service._next_personnel_number(company_id, db)
|
||||
|
||||
# E-Mail-Konflikt prüfen (auch deaktivierte User in derselben Firma)
|
||||
existing_user = await db.scalar(
|
||||
select(User).where(User.email == email)
|
||||
)
|
||||
|
||||
if existing_user is not None and existing_user.is_active:
|
||||
items.append(ImportRowResult(
|
||||
row=idx, email=email, personnel_number=personnel_number,
|
||||
action="error", message="E-Mail bereits aktiv vergeben.",
|
||||
))
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if existing_user is not None and not existing_user.is_active:
|
||||
# Reaktivieren
|
||||
if existing_user.company_id != company_id:
|
||||
items.append(ImportRowResult(
|
||||
row=idx, email=email, personnel_number=personnel_number,
|
||||
action="error", message="E-Mail existiert in anderer Firma.",
|
||||
))
|
||||
errors += 1
|
||||
continue
|
||||
existing_user.first_name = first_name
|
||||
existing_user.last_name = last_name
|
||||
existing_user.role = UserRole(role_str)
|
||||
if kuerzel:
|
||||
existing_user.kuerzel = kuerzel
|
||||
# Personalnr.: behalten, falls schon vorhanden (Reservierung), sonst setzen
|
||||
if not existing_user.personnel_number:
|
||||
existing_user.personnel_number = personnel_number
|
||||
else:
|
||||
personnel_number = existing_user.personnel_number
|
||||
existing_user.is_active = True
|
||||
used_personnel_in_csv.add(personnel_number)
|
||||
items.append(ImportRowResult(
|
||||
row=idx, email=email, personnel_number=personnel_number,
|
||||
action="reactivated",
|
||||
))
|
||||
reactivated += 1
|
||||
continue
|
||||
|
||||
# Neuanlage: Invite-Token generieren, User inaktiv (warten auf Annahme)
|
||||
raw_token, token_hash = generate_invite_token()
|
||||
new_user = User(
|
||||
company_id=company_id,
|
||||
email=email,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
role=UserRole(role_str),
|
||||
kuerzel=kuerzel,
|
||||
personnel_number=personnel_number,
|
||||
password_hash=hash_password(raw_token),
|
||||
invite_token_hash=token_hash,
|
||||
invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
is_active=False,
|
||||
)
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
used_personnel_in_csv.add(personnel_number)
|
||||
|
||||
if apply:
|
||||
try:
|
||||
await email_service.send_invite(new_user, invited_by, raw_token, db)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Mail-Fehler darf Import nicht abbrechen, wird aber gemeldet
|
||||
items.append(ImportRowResult(
|
||||
row=idx, email=email, personnel_number=personnel_number,
|
||||
action="created",
|
||||
message=f"Anlage OK, aber Einladungs-Mail fehlgeschlagen: {e}",
|
||||
))
|
||||
created += 1
|
||||
continue
|
||||
|
||||
items.append(ImportRowResult(
|
||||
row=idx, email=email, personnel_number=personnel_number,
|
||||
action="created",
|
||||
))
|
||||
created += 1
|
||||
|
||||
return ImportResult(
|
||||
total_rows=len(rows),
|
||||
created=created,
|
||||
reactivated=reactivated,
|
||||
errors=errors,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
async def preview_csv(
|
||||
content: bytes, company_id: UUID, invited_by: User, db: AsyncSession,
|
||||
) -> ImportResult:
|
||||
"""Validiert CSV ohne DB-Schreibvorgänge (Rollback am Ende)."""
|
||||
result = await _process_import(
|
||||
content=content,
|
||||
company_id=company_id,
|
||||
invited_by=invited_by,
|
||||
db=db,
|
||||
apply=False,
|
||||
)
|
||||
await db.rollback()
|
||||
return result
|
||||
|
||||
|
||||
async def apply_csv(
|
||||
content: bytes, company_id: UUID, invited_by: User, db: AsyncSession,
|
||||
) -> ImportResult:
|
||||
"""Führt Import durch und committet."""
|
||||
result = await _process_import(
|
||||
content=content,
|
||||
company_id=company_id,
|
||||
invited_by=invited_by,
|
||||
db=db,
|
||||
apply=True,
|
||||
)
|
||||
await db.commit()
|
||||
return result
|
||||
Reference in New Issue
Block a user