feat(customers): add initial admin bootstrap and csv import

This commit is contained in:
DS | Schubert 2026-07-03 09:14:56 +02:00
parent c816e9869d
commit ecab0fe6b6
27 changed files with 1197 additions and 34 deletions

View file

@ -4,3 +4,12 @@ SECRET_KEY=CHANGE_ME
APP_NAME=Hermes API
APP_VERSION=0.1.0
ACCESS_TOKEN_EXPIRE_MINUTES=60
JWT_ISSUER=hermes
LOG_LEVEL=INFO
INITIAL_ADMIN_USERNAME=
INITIAL_ADMIN_EMAIL=
INITIAL_ADMIN_PASSWORD=
INITIAL_ADMIN_FIRST_NAME=
INITIAL_ADMIN_LAST_NAME=

View file

@ -81,6 +81,8 @@ def login(
def me(current_user: User = Depends(get_current_active_user)):
return {
"id": current_user.id,
"first_name": current_user.first_name,
"last_name": current_user.last_name,
"username": current_user.username,
"email": current_user.email,
"role": current_user.primary_role.name,

View file

@ -1,10 +1,10 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission
from app.core.rbac import get_current_active_user, get_user_permission_names, require_permission
from app.db.database import get_db
from app.models.customer import Customer, CustomerContact
from app.models.user import User
@ -17,7 +17,13 @@ from app.schemas.customer import (
CustomerResponse,
CustomerUpdate,
)
from app.schemas.customer_import import (
CustomerImportCommitResponse,
CustomerImportPreviewResponse,
ImportMode,
)
from app.services.audit_service import sanitize, write_audit_log
from app.services.customer_import_service import CustomerImportService
logger = logging.getLogger(__name__)
@ -26,6 +32,23 @@ router = APIRouter(
tags=["Customers"],
)
MAX_IMPORT_SIZE_BYTES = 5 * 1024 * 1024
async def read_import_file(file: UploadFile) -> bytes:
content = await file.read(MAX_IMPORT_SIZE_BYTES + 1)
if len(content) > MAX_IMPORT_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="CSV-Datei darf maximal 5 MB groß sein",
)
if not content:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="CSV-Datei ist leer",
)
return content
def get_customer_or_404(db: Session, customer_id: int) -> Customer:
customer = CustomerRepository.get_by_id(db, customer_id)
@ -116,6 +139,44 @@ def create_customer(
return created_customer
@router.post("/import/preview", response_model=CustomerImportPreviewResponse)
async def preview_customer_import(
mode: ImportMode = Form(...),
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("customers.read")),
):
logger.info("customers.import.preview", extra={"actor_user_id": current_user.id, "mode": mode})
content = await read_import_file(file)
try:
return CustomerImportService.preview(db, content, mode)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.post("/import/commit", response_model=CustomerImportCommitResponse)
async def commit_customer_import(
request: Request,
mode: ImportMode = Form(...),
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
permissions = get_user_permission_names(current_user)
required_permissions = {"customers.create"} if mode == "create_only" else {"customers.update"}
if mode == "upsert":
required_permissions = {"customers.create", "customers.update"}
if not required_permissions.issubset(permissions):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Keine Berechtigung")
logger.info("customers.import.commit", extra={"actor_user_id": current_user.id, "mode": mode})
content = await read_import_file(file)
try:
return CustomerImportService.commit(db, content, mode, actor=current_user, request=request)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.put("/{customer_id}", response_model=CustomerResponse)
def update_customer(
customer_id: int,

View file

@ -10,6 +10,11 @@ class Settings(BaseSettings):
access_token_expire_minutes: int = 60
jwt_issuer: str = "hermes"
log_level: str = "INFO"
initial_admin_username: str | None = None
initial_admin_email: str | None = None
initial_admin_password: str | None = None
initial_admin_first_name: str = ""
initial_admin_last_name: str = ""
model_config = SettingsConfigDict(
env_file=".env",

View file

@ -20,6 +20,7 @@ from app.db.database import SessionLocal
from app.db.health import check_database
from app.core.logging import configure_logging
from app.rbac.seed import seed_rbac
from app.services.initial_admin_bootstrap import bootstrap_initial_admin
configure_logging()
@ -73,6 +74,7 @@ def startup_seed_rbac():
db = SessionLocal()
try:
seed_rbac(db)
bootstrap_initial_admin(db)
finally:
db.close()

View file

@ -0,0 +1,49 @@
from typing import Literal
from pydantic import BaseModel, Field
ImportMode = Literal["create_only", "update_existing", "upsert"]
ImportAction = Literal["create", "update", "skip", "error"]
class CustomerImportIssue(BaseModel):
row: int
field: str = ""
message: str
class CustomerImportPreviewRow(BaseModel):
row: int
customer_number: str = ""
company_name: str = ""
action: ImportAction
errors: list[CustomerImportIssue] = Field(default_factory=list)
warnings: list[CustomerImportIssue] = Field(default_factory=list)
class CustomerImportSummary(BaseModel):
total_rows: int
valid_rows: int
error_count: int
warning_count: int
duplicate_count: int
create_count: int
update_count: int
skip_count: int
class CustomerImportPreviewResponse(BaseModel):
mode: ImportMode
summary: CustomerImportSummary
rows: list[CustomerImportPreviewRow]
errors: list[CustomerImportIssue]
warnings: list[CustomerImportIssue]
duplicates: list[CustomerImportIssue]
class CustomerImportCommitResponse(BaseModel):
mode: ImportMode
summary: CustomerImportSummary
created: int
updated: int
skipped: int

View file

@ -68,6 +68,8 @@ class RoleResponse(BaseModel):
class CurrentUserResponse(BaseModel):
id: int
first_name: str
last_name: str
username: str
email: str
role: str

View file

@ -126,9 +126,11 @@ def action_title(action: str) -> str:
"customers.create": "Kunde erstellt",
"customers.update": "Kunde bearbeitet",
"customers.delete": "Kunde gelöscht",
"customers.import": "Kunden importiert",
"customer_contacts.create": "Ansprechpartner erstellt",
"customer_contacts.update": "Ansprechpartner bearbeitet",
"customer_contacts.delete": "Ansprechpartner gelöscht",
"users.initial_admin_bootstrap": "Initialer Administrator erstellt",
}
return labels.get(action, action)

View file

@ -0,0 +1,407 @@
import csv
import io
import uuid
from dataclasses import dataclass
from pydantic import EmailStr, HttpUrl, TypeAdapter, ValidationError
from sqlalchemy.orm import Session
from app.models.customer import Customer, CustomerAddress, CustomerContact
from app.models.user import User
from app.repositories.customer_repository import CustomerRepository
from app.schemas.customer_import import (
CustomerImportCommitResponse,
CustomerImportIssue,
CustomerImportPreviewResponse,
CustomerImportPreviewRow,
CustomerImportSummary,
ImportMode,
)
from app.services.audit_service import write_audit_log
CSV_COLUMNS = [
"customer_number",
"company_name",
"legal_name",
"customer_type",
"status",
"industry",
"website",
"email",
"phone",
"tax_number",
"vat_id",
"notes",
"address_type",
"street",
"postal_code",
"city",
"state",
"country",
"address_is_primary",
"contact_first_name",
"contact_last_name",
"contact_position",
"contact_email",
"contact_phone",
"contact_mobile",
"contact_is_primary",
"contact_notes",
]
VALID_STATUSES = {"lead", "active", "inactive", "blocked", "archived"}
VALID_TYPES = {"company", "private", "public_sector", "partner", "supplier"}
VALID_ADDRESS_TYPES = {"billing", "shipping", "primary", "other"}
TRUE_VALUES = {"1", "true", "yes", "ja", "j", "x"}
FALSE_VALUES = {"0", "false", "no", "nein", "n", ""}
email_adapter = TypeAdapter(EmailStr)
url_adapter = TypeAdapter(HttpUrl)
@dataclass
class ParsedImportRow:
row_number: int
data: dict[str, str]
action: str
errors: list[CustomerImportIssue]
warnings: list[CustomerImportIssue]
existing_customer: Customer | None = None
def get_customer_import_template() -> str:
return ";".join(CSV_COLUMNS) + "\n"
def decode_csv(content: bytes) -> str:
try:
return content.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise ValueError("CSV-Datei muss UTF-8-kodiert sein") from exc
def parse_bool(value: str) -> bool:
normalized = value.strip().lower()
if normalized in TRUE_VALUES:
return True
if normalized in FALSE_VALUES:
return False
return False
def parse_bool_default(value: str, default: bool = False) -> bool:
if value.strip() == "":
return default
return parse_bool(value)
def sniff_dialect(text: str) -> csv.Dialect:
sample = text[:4096]
try:
return csv.Sniffer().sniff(sample, delimiters=",;")
except csv.Error:
return csv.excel
def read_csv_rows(content: bytes) -> list[tuple[int, dict[str, str]]]:
text = decode_csv(content)
reader = csv.DictReader(io.StringIO(text), dialect=sniff_dialect(text))
rows: list[tuple[int, dict[str, str]]] = []
for index, row in enumerate(reader, start=2):
normalized = {column: (row.get(column) or "").strip() for column in CSV_COLUMNS}
if not any(normalized.values()):
continue
rows.append((index, normalized))
return rows
def validate_email(value: str, row_number: int, field: str, errors: list[CustomerImportIssue]) -> str:
if not value:
return ""
try:
return str(email_adapter.validate_python(value))
except ValidationError:
errors.append(CustomerImportIssue(row=row_number, field=field, message="E-Mail ist ungültig"))
return value
def validate_url(value: str, row_number: int, errors: list[CustomerImportIssue]) -> str:
if not value:
return ""
candidate = value if value.startswith(("http://", "https://")) else f"https://{value}"
try:
return str(url_adapter.validate_python(candidate))
except ValidationError:
errors.append(CustomerImportIssue(row=row_number, field="website", message="Website ist ungültig"))
return value
def planned_action(mode: ImportMode, existing_customer: Customer | None) -> str:
if existing_customer is None:
return "create" if mode in {"create_only", "upsert"} else "skip"
if mode == "create_only":
return "skip"
return "update"
class CustomerImportService:
@staticmethod
def preview(db: Session, content: bytes, mode: ImportMode) -> CustomerImportPreviewResponse:
rows = CustomerImportService._parse_and_validate(db, content, mode)
return CustomerImportService._build_preview(mode, rows)
@staticmethod
def commit(
db: Session,
content: bytes,
mode: ImportMode,
*,
actor: User,
request,
) -> CustomerImportCommitResponse:
rows = CustomerImportService._parse_and_validate(db, content, mode)
preview = CustomerImportService._build_preview(mode, rows)
if preview.summary.valid_rows == 0:
return CustomerImportCommitResponse(
mode=mode,
summary=preview.summary,
created=0,
updated=0,
skipped=preview.summary.skip_count,
)
created = 0
updated = 0
skipped = 0
for row in rows:
if row.errors or row.action == "error":
continue
if row.action == "skip":
skipped += 1
continue
if row.action == "create":
CustomerImportService._create_customer(db, row.data)
created += 1
elif row.action == "update" and row.existing_customer is not None:
CustomerImportService._update_customer(db, row.existing_customer, row.data)
updated += 1
db.commit()
write_audit_log(
db,
action="customers.import",
entity_type="customers",
actor=actor,
request=request,
metadata={
"mode": mode,
"total_rows": preview.summary.total_rows,
"created": created,
"updated": updated,
"skipped": skipped,
"errors": preview.summary.error_count,
"warnings": preview.summary.warning_count,
},
)
summary = CustomerImportService._build_preview(mode, rows).summary
return CustomerImportCommitResponse(
mode=mode,
summary=summary,
created=created,
updated=updated,
skipped=skipped,
)
@staticmethod
def _parse_and_validate(db: Session, content: bytes, mode: ImportMode) -> list[ParsedImportRow]:
csv_rows = read_csv_rows(content)
number_rows: dict[str, int] = {}
duplicates: set[str] = set()
for row_number, data in csv_rows:
customer_number = data["customer_number"]
if not customer_number:
continue
if customer_number in number_rows:
duplicates.add(customer_number)
else:
number_rows[customer_number] = row_number
parsed_rows: list[ParsedImportRow] = []
for row_number, data in csv_rows:
errors: list[CustomerImportIssue] = []
warnings: list[CustomerImportIssue] = []
if not data["company_name"]:
errors.append(CustomerImportIssue(row=row_number, field="company_name", message="Firmenname ist erforderlich"))
if not data["customer_type"]:
data["customer_type"] = "company"
warnings.append(CustomerImportIssue(row=row_number, field="customer_type", message="Typ fehlt und wird als company importiert"))
elif data["customer_type"] not in VALID_TYPES:
errors.append(CustomerImportIssue(row=row_number, field="customer_type", message="Typ ist ungültig"))
if not data["status"]:
data["status"] = "active"
warnings.append(CustomerImportIssue(row=row_number, field="status", message="Status fehlt und wird als active importiert"))
elif data["status"] not in VALID_STATUSES:
errors.append(CustomerImportIssue(row=row_number, field="status", message="Status ist ungültig"))
if not data["address_type"]:
data["address_type"] = "primary"
elif data["address_type"] not in VALID_ADDRESS_TYPES:
errors.append(CustomerImportIssue(row=row_number, field="address_type", message="Adresstyp ist ungültig"))
data["email"] = validate_email(data["email"], row_number, "email", errors)
data["contact_email"] = validate_email(data["contact_email"], row_number, "contact_email", errors)
data["website"] = validate_url(data["website"], row_number, errors)
if data["customer_number"] and data["customer_number"] in duplicates:
errors.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer ist in der CSV mehrfach vorhanden"))
if not data["customer_number"] and mode == "update_existing":
errors.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer ist für update_existing erforderlich"))
if not data["customer_number"]:
warnings.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer fehlt und wird beim Erstellen generiert"))
existing_customer = (
CustomerRepository.get_by_number(db, data["customer_number"])
if data["customer_number"]
else None
)
action = planned_action(mode, existing_customer)
if action == "skip":
reason = "Kunde existiert bereits" if existing_customer is not None else "Kunde existiert nicht"
warnings.append(CustomerImportIssue(row=row_number, field="customer_number", message=f"Zeile wird übersprungen: {reason}"))
if errors:
action = "error"
parsed_rows.append(
ParsedImportRow(
row_number=row_number,
data=data,
action=action,
errors=errors,
warnings=warnings,
existing_customer=existing_customer,
)
)
return parsed_rows
@staticmethod
def _build_preview(mode: ImportMode, rows: list[ParsedImportRow]) -> CustomerImportPreviewResponse:
preview_rows = [
CustomerImportPreviewRow(
row=row.row_number,
customer_number=row.data["customer_number"],
company_name=row.data["company_name"],
action=row.action,
errors=row.errors,
warnings=row.warnings,
)
for row in rows
]
errors = [issue for row in rows for issue in row.errors]
warnings = [issue for row in rows for issue in row.warnings]
duplicates = [issue for issue in errors if issue.field == "customer_number" and "mehrfach" in issue.message]
summary = CustomerImportSummary(
total_rows=len(rows),
valid_rows=sum(1 for row in rows if not row.errors and row.action in {"create", "update"}),
error_count=len(errors),
warning_count=len(warnings),
duplicate_count=len(duplicates),
create_count=sum(1 for row in rows if not row.errors and row.action == "create"),
update_count=sum(1 for row in rows if not row.errors and row.action == "update"),
skip_count=sum(1 for row in rows if not row.errors and row.action == "skip"),
)
return CustomerImportPreviewResponse(
mode=mode,
summary=summary,
rows=preview_rows,
errors=errors,
warnings=warnings,
duplicates=duplicates,
)
@staticmethod
def _customer_number(data: dict[str, str]) -> str:
return data["customer_number"] or f"IMP-{uuid.uuid4().hex[:10].upper()}"
@staticmethod
def _address(data: dict[str, str]) -> CustomerAddress:
return CustomerAddress(
type=data["address_type"],
street=data["street"],
postal_code=data["postal_code"],
city=data["city"],
state=data["state"],
country=data["country"] or "Deutschland",
is_primary=parse_bool_default(data["address_is_primary"], default=True),
)
@staticmethod
def _contact(data: dict[str, str]) -> CustomerContact | None:
if not any(data[key] for key in ("contact_first_name", "contact_last_name", "contact_email", "contact_phone", "contact_mobile")):
return None
return CustomerContact(
first_name=data["contact_first_name"],
last_name=data["contact_last_name"],
position=data["contact_position"],
email=data["contact_email"],
phone=data["contact_phone"],
mobile=data["contact_mobile"],
is_primary=parse_bool(data["contact_is_primary"]),
notes=data["contact_notes"],
)
@staticmethod
def _create_customer(db: Session, data: dict[str, str]) -> Customer:
customer = Customer(
customer_number=CustomerImportService._customer_number(data),
company_name=data["company_name"],
legal_name=data["legal_name"],
customer_type=data["customer_type"],
status=data["status"],
industry=data["industry"],
website=data["website"],
email=data["email"],
phone=data["phone"],
tax_number=data["tax_number"],
vat_id=data["vat_id"],
notes=data["notes"],
addresses=[CustomerImportService._address(data)],
)
contact = CustomerImportService._contact(data)
if contact is not None:
customer.contacts = [contact]
db.add(customer)
return customer
@staticmethod
def _update_customer(db: Session, customer: Customer, data: dict[str, str]) -> Customer:
customer.company_name = data["company_name"]
customer.legal_name = data["legal_name"]
customer.customer_type = data["customer_type"]
customer.status = data["status"]
customer.industry = data["industry"]
customer.website = data["website"]
customer.email = data["email"]
customer.phone = data["phone"]
customer.tax_number = data["tax_number"]
customer.vat_id = data["vat_id"]
customer.notes = data["notes"]
customer.addresses = [CustomerImportService._address(data)]
contact = CustomerImportService._contact(data)
if contact is not None:
if contact.is_primary:
for existing_contact in customer.contacts:
existing_contact.is_primary = False
customer.contacts.append(contact)
db.add(customer)
return customer

View file

@ -0,0 +1,82 @@
import logging
from pydantic import EmailStr, TypeAdapter, ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security import hash_password
from app.models.user import User
from app.repositories.rbac_repository import RbacRepository
from app.services.audit_service import write_audit_log
logger = logging.getLogger(__name__)
email_adapter = TypeAdapter(EmailStr)
def bootstrap_initial_admin(db: Session) -> None:
active_user_exists = db.scalar(select(User.id).where(User.is_active.is_(True)).limit(1))
if active_user_exists is not None:
logger.info("initial_admin.skipped_active_user_exists")
return
username = (settings.initial_admin_username or "").strip()
email = (settings.initial_admin_email or "").strip()
password = settings.initial_admin_password or ""
if not username or not email or not password:
logger.warning(
"initial_admin.not_configured",
extra={"reason": "missing_required_initial_admin_environment"},
)
return
try:
normalized_email = str(email_adapter.validate_python(email))
except ValidationError:
logger.warning("initial_admin.invalid_email")
return
administrator_role = RbacRepository.get_role_by_name(db, "administrator")
if administrator_role is None:
logger.warning("initial_admin.missing_administrator_role")
return
existing_user = db.scalar(select(User).where((User.username == username) | (User.email == normalized_email)))
if existing_user is not None:
logger.info("initial_admin.skipped_user_already_exists")
return
admin = User(
first_name=settings.initial_admin_first_name.strip(),
last_name=settings.initial_admin_last_name.strip(),
username=username,
email=normalized_email,
password_hash=hash_password(password),
role="administrator",
role_id=administrator_role.id,
is_active=True,
)
db.add(admin)
db.commit()
db.refresh(admin)
write_audit_log(
db,
action="users.initial_admin_bootstrap",
entity_type="users",
entity_id=admin.id,
entity_label=admin.username,
actor_username="system",
after_data={
"id": admin.id,
"username": admin.username,
"email": admin.email,
"role": admin.role,
"role_id": admin.role_id,
"is_active": admin.is_active,
},
metadata={"source": "initial_admin_bootstrap"},
)
logger.info("initial_admin.created", extra={"user_id": admin.id})

View file

@ -5,10 +5,15 @@ services:
restart: unless-stopped
environment:
DATABASE_URL: postgresql+psycopg://olympus:FsFs03285310!!!@olympus-db:5432/olympus
DATABASE_URL: ${DATABASE_URL}
APP_NAME: Hermes API
APP_VERSION: 0.1.0
SECRET_KEY: ${SECRET_KEY}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
INITIAL_ADMIN_FIRST_NAME: ${INITIAL_ADMIN_FIRST_NAME:-}
INITIAL_ADMIN_LAST_NAME: ${INITIAL_ADMIN_LAST_NAME:-}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
JWT_ISSUER: ${JWT_ISSUER:-hermes}
LOG_LEVEL: ${LOG_LEVEL:-INFO}