feat(customers): add initial admin bootstrap and csv import
This commit is contained in:
parent
c816e9869d
commit
ecab0fe6b6
27 changed files with 1197 additions and 34 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
49
backend/hermes/app/schemas/customer_import.py
Normal file
49
backend/hermes/app/schemas/customer_import.py
Normal 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
|
||||
|
|
@ -68,6 +68,8 @@ class RoleResponse(BaseModel):
|
|||
|
||||
class CurrentUserResponse(BaseModel):
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
username: str
|
||||
email: str
|
||||
role: str
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
407
backend/hermes/app/services/customer_import_service.py
Normal file
407
backend/hermes/app/services/customer_import_service.py
Normal 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
|
||||
82
backend/hermes/app/services/initial_admin_bootstrap.py
Normal file
82
backend/hermes/app/services/initial_admin_bootstrap.py
Normal 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})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue