feat(validation): complete validation editor and master data modules

This commit is contained in:
Schubert Ferenc 2026-07-10 22:42:03 +02:00
parent 2b5c765e41
commit f73a24df13
73 changed files with 10194 additions and 0 deletions

View file

@ -0,0 +1,26 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev libcairo2 pango1.0-tools libpango-1.0-0 libgdk-pixbuf-2.0-0 libffi-dev \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml .
RUN pip install --no-cache-dir .
COPY alembic.ini .
COPY alembic ./alembic
COPY app ./app
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000", "--workers", "2"]

View file

@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+psycopg://validation:validation123@postgres:5432/validation_suite
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

View file

@ -0,0 +1,48 @@
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.db.base import Base
from app.models import contact, customer, device, document, equipment, location, program, user, validation
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,209 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "202607100001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
user_role = sa.Enum("admin", "employee", "auditor", name="userrole")
customer_type = sa.Enum("practice", "clinic", name="customertype")
equipment_kind = sa.Enum("temperature_logger", "pressure_logger", "sensor", name="equipmentkind")
equipment_status = sa.Enum("green", "yellow", "red", name="equipmentstatus")
validation_status = sa.Enum("draft", "in_progress", "ready_for_report", "completed", name="validationstatus")
document_owner_type = sa.Enum("customer", "device", "validation", "equipment", name="documentownertype")
op.create_table(
"users",
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("full_name", sa.String(length=160), nullable=False),
sa.Column("role", user_role, nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_index("ix_users_email", "users", ["email"])
op.create_table(
"customers",
sa.Column("customer_type", customer_type, nullable=False),
sa.Column("name", sa.String(length=220), nullable=False),
sa.Column("street", sa.String(length=220), nullable=True),
sa.Column("postal_code", sa.String(length=20), nullable=True),
sa.Column("city", sa.String(length=120), nullable=True),
sa.Column("phone", sa.String(length=80), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("hygiene_officer", sa.String(length=160), nullable=True),
sa.Column("quality_manager", sa.String(length=160), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_customers_name", "customers", ["name"])
op.create_table(
"equipment",
sa.Column("kind", equipment_kind, nullable=False),
sa.Column("manufacturer", sa.String(length=140), nullable=True),
sa.Column("model", sa.String(length=140), nullable=True),
sa.Column("serial_number", sa.String(length=140), nullable=False),
sa.Column("calibrated_on", sa.Date(), nullable=True),
sa.Column("calibration_due_on", sa.Date(), nullable=True),
sa.Column("certificate_document_id", sa.String(length=80), nullable=True),
sa.Column("status", equipment_status, nullable=False),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("serial_number"),
)
op.create_index("ix_equipment_serial_number", "equipment", ["serial_number"])
op.create_table(
"contacts",
sa.Column("customer_id", sa.String(), nullable=False),
sa.Column("full_name", sa.String(length=160), nullable=False),
sa.Column("function", sa.String(length=120), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("phone", sa.String(length=80), nullable=True),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_contacts_customer_id", "contacts", ["customer_id"])
op.create_table(
"locations",
sa.Column("customer_id", sa.String(), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("street", sa.String(length=220), nullable=True),
sa.Column("postal_code", sa.String(length=20), nullable=True),
sa.Column("city", sa.String(length=120), nullable=True),
sa.Column("room", sa.String(length=120), nullable=True),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_locations_customer_id", "locations", ["customer_id"])
op.create_table(
"documents",
sa.Column("owner_type", document_owner_type, nullable=False),
sa.Column("owner_id", sa.String(length=80), nullable=False),
sa.Column("filename", sa.String(length=255), nullable=False),
sa.Column("content_type", sa.String(length=120), nullable=False),
sa.Column("storage_path", sa.String(length=500), nullable=False),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_documents_owner_id", "documents", ["owner_id"])
op.create_index("ix_documents_owner_type", "documents", ["owner_type"])
op.create_table(
"devices",
sa.Column("customer_id", sa.String(), nullable=False),
sa.Column("location_id", sa.String(), nullable=True),
sa.Column("manufacturer", sa.String(length=140), nullable=False),
sa.Column("model", sa.String(length=140), nullable=False),
sa.Column("device_type", sa.String(length=120), nullable=True),
sa.Column("serial_number", sa.String(length=140), nullable=False),
sa.Column("year_built", sa.Integer(), nullable=True),
sa.Column("commissioned_on", sa.Date(), nullable=True),
sa.Column("chamber_volume_liters", sa.Integer(), nullable=True),
sa.Column("steam_generation", sa.String(length=220), nullable=True),
sa.Column("water_treatment", sa.String(length=220), nullable=True),
sa.Column("documentation", sa.Text(), nullable=True),
sa.Column("supplier", sa.String(length=180), nullable=True),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"]),
sa.ForeignKeyConstraint(["location_id"], ["locations.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("serial_number"),
)
op.create_index("ix_devices_customer_id", "devices", ["customer_id"])
op.create_index("ix_devices_location_id", "devices", ["location_id"])
op.create_index("ix_devices_serial_number", "devices", ["serial_number"])
op.create_table(
"programs",
sa.Column("device_id", sa.String(), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("temperature_celsius", sa.Integer(), nullable=True),
sa.Column("holding_time_minutes", sa.Integer(), nullable=True),
sa.Column("drying_time_minutes", sa.Integer(), nullable=True),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["device_id"], ["devices.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_programs_device_id", "programs", ["device_id"])
op.create_table(
"validations",
sa.Column("report_number", sa.String(length=80), nullable=False),
sa.Column("customer_id", sa.String(), nullable=False),
sa.Column("location_id", sa.String(), nullable=True),
sa.Column("device_id", sa.String(), nullable=True),
sa.Column("validation_type", sa.String(length=120), nullable=False),
sa.Column("scheduled_on", sa.Date(), nullable=True),
sa.Column("performed_on", sa.Date(), nullable=True),
sa.Column("next_validation_on", sa.Date(), nullable=True),
sa.Column("examiner_id", sa.String(), nullable=True),
sa.Column("status", validation_status, nullable=False),
sa.Column("result", sa.String(length=120), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"]),
sa.ForeignKeyConstraint(["device_id"], ["devices.id"]),
sa.ForeignKeyConstraint(["examiner_id"], ["users.id"]),
sa.ForeignKeyConstraint(["location_id"], ["locations.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("report_number"),
)
op.create_index("ix_validations_customer_id", "validations", ["customer_id"])
op.create_index("ix_validations_device_id", "validations", ["device_id"])
op.create_index("ix_validations_location_id", "validations", ["location_id"])
op.create_index("ix_validations_report_number", "validations", ["report_number"])
def downgrade() -> None:
op.drop_table("validations")
op.drop_table("programs")
op.drop_table("devices")
op.drop_table("documents")
op.drop_table("locations")
op.drop_table("contacts")
op.drop_table("equipment")
op.drop_table("customers")
op.drop_table("users")
for enum_name in [
"documentownertype",
"validationstatus",
"equipmentstatus",
"equipmentkind",
"customertype",
"userrole",
]:
sa.Enum(name=enum_name).drop(op.get_bind(), checkfirst=True)

View file

@ -0,0 +1,54 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "202607100002"
down_revision = "202607100001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("validations", sa.Column("contact_id", sa.String(), nullable=True))
op.add_column("validations", sa.Column("project", sa.String(length=180), nullable=True))
op.add_column("validations", sa.Column("test_location", sa.String(length=180), nullable=True))
op.add_column("validations", sa.Column("examiner_name", sa.String(length=180), nullable=True))
op.add_column("validations", sa.Column("participants", sa.Text(), nullable=True))
op.add_column("validations", sa.Column("operator_name", sa.String(length=180), nullable=True))
op.add_column("validations", sa.Column("equipment_ids", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("validations", sa.Column("environment_conditions", sa.JSON(), nullable=False, server_default="{}"))
op.add_column("validations", sa.Column("documentation_checklist", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("validations", sa.Column("performance_checklist", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("validations", sa.Column("programs", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("validations", sa.Column("loading_patterns", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("validations", sa.Column("measurement_data", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("validations", sa.Column("drying", sa.JSON(), nullable=False, server_default="{}"))
op.add_column("validations", sa.Column("recommendations", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("validations", sa.Column("attachments", sa.JSON(), nullable=False, server_default="[]"))
op.create_index("ix_validations_contact_id", "validations", ["contact_id"])
op.create_foreign_key("fk_validations_contact_id_contacts", "validations", "contacts", ["contact_id"], ["id"])
def downgrade() -> None:
op.drop_constraint("fk_validations_contact_id_contacts", "validations", type_="foreignkey")
op.drop_index("ix_validations_contact_id", table_name="validations")
for column in [
"attachments",
"recommendations",
"drying",
"measurement_data",
"loading_patterns",
"programs",
"performance_checklist",
"documentation_checklist",
"environment_conditions",
"equipment_ids",
"operator_name",
"participants",
"examiner_name",
"test_location",
"project",
"contact_id",
]:
op.drop_column("validations", column)

View file

@ -0,0 +1,32 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_session
from app.models.user import User
bearer = HTTPBearer()
def current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer),
session: Session = Depends(get_session),
) -> User:
try:
payload = jwt.decode(
credentials.credentials,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
user_id = payload.get("sub")
except JWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
user = session.get(User, user_id)
if user is None or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
return user

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.dependencies import current_user
from app.db.session import get_session
from app.models.user import User
from app.schemas.auth import LoginRequest, TokenResponse, UserRead
from app.services.auth_service import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
def login(payload: LoginRequest, session: Session = Depends(get_session)) -> TokenResponse:
token = AuthService(session).login(payload.email, payload.password)
return TokenResponse(access_token=token)
@router.get("/me", response_model=UserRead)
def me(user: User = Depends(current_user)) -> User:
return user

View file

@ -0,0 +1,216 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.dependencies import current_user
from app.db.session import get_session
from app.models.contact import Contact
from app.models.customer import Customer
from app.models.device import Device
from app.models.equipment import Equipment
from app.models.location import Location
from app.models.validation import Validation
from app.schemas.common import PaginatedResponse
from app.schemas.domain import (
ContactCreate,
ContactRead,
ContactUpdate,
CustomerCreate,
CustomerRead,
CustomerUpdate,
DeviceCreate,
DeviceRead,
DeviceUpdate,
EquipmentCreate,
EquipmentRead,
EquipmentUpdate,
LocationCreate,
LocationRead,
LocationUpdate,
ValidationCreate,
ValidationRead,
ValidationUpdate,
)
from app.services.domain_service import CrudService, DomainServices
router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)])
@router.get("/dashboard")
def dashboard(session: Session = Depends(get_session)) -> dict[str, int]:
return {
"customers": session.scalar(select(func.count()).select_from(Customer)) or 0,
"locations": session.scalar(select(func.count()).select_from(Location)) or 0,
"contacts": session.scalar(select(func.count()).select_from(Contact)) or 0,
"devices": session.scalar(select(func.count()).select_from(Device)) or 0,
"equipment": session.scalar(select(func.count()).select_from(Equipment)) or 0,
"validations": session.scalar(select(func.count()).select_from(Validation)) or 0,
}
def paging(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
search: str | None = Query(default=None, max_length=120),
) -> dict[str, Any]:
return {"page": page, "page_size": page_size, "search": search}
def commit_create(session: Session, service: CrudService, payload):
item = service.create(payload.model_dump())
session.commit()
session.refresh(item)
return item
def commit_update(session: Session, service: CrudService, item_id: str, payload):
item = service.update(item_id, payload.model_dump())
session.commit()
session.refresh(item)
return item
def commit_delete(session: Session, service: CrudService, item_id: str) -> Response:
service.delete(item_id)
session.commit()
return Response(status_code=204)
@router.get("/customers", response_model=PaginatedResponse[CustomerRead])
def list_customers(params: dict = Depends(paging), session: Session = Depends(get_session)):
return DomainServices(session).customers.list(**params)
@router.post("/customers", response_model=CustomerRead, status_code=201)
def create_customer(payload: CustomerCreate, session: Session = Depends(get_session)):
return commit_create(session, DomainServices(session).customers, payload)
@router.put("/customers/{item_id}", response_model=CustomerRead)
def update_customer(item_id: str, payload: CustomerUpdate, session: Session = Depends(get_session)):
return commit_update(session, DomainServices(session).customers, item_id, payload)
@router.delete("/customers/{item_id}", status_code=204)
def delete_customer(item_id: str, session: Session = Depends(get_session)):
return commit_delete(session, DomainServices(session).customers, item_id)
@router.get("/locations", response_model=PaginatedResponse[LocationRead])
def list_locations(params: dict = Depends(paging), session: Session = Depends(get_session)):
return DomainServices(session).locations.list(**params)
@router.post("/locations", response_model=LocationRead, status_code=201)
def create_location(payload: LocationCreate, session: Session = Depends(get_session)):
return commit_create(session, DomainServices(session).locations, payload)
@router.put("/locations/{item_id}", response_model=LocationRead)
def update_location(item_id: str, payload: LocationUpdate, session: Session = Depends(get_session)):
return commit_update(session, DomainServices(session).locations, item_id, payload)
@router.delete("/locations/{item_id}", status_code=204)
def delete_location(item_id: str, session: Session = Depends(get_session)):
return commit_delete(session, DomainServices(session).locations, item_id)
@router.get("/contacts", response_model=PaginatedResponse[ContactRead])
def list_contacts(params: dict = Depends(paging), session: Session = Depends(get_session)):
return DomainServices(session).contacts.list(**params)
@router.post("/contacts", response_model=ContactRead, status_code=201)
def create_contact(payload: ContactCreate, session: Session = Depends(get_session)):
return commit_create(session, DomainServices(session).contacts, payload)
@router.put("/contacts/{item_id}", response_model=ContactRead)
def update_contact(item_id: str, payload: ContactUpdate, session: Session = Depends(get_session)):
return commit_update(session, DomainServices(session).contacts, item_id, payload)
@router.delete("/contacts/{item_id}", status_code=204)
def delete_contact(item_id: str, session: Session = Depends(get_session)):
return commit_delete(session, DomainServices(session).contacts, item_id)
@router.get("/devices", response_model=PaginatedResponse[DeviceRead])
def list_devices(params: dict = Depends(paging), session: Session = Depends(get_session)):
return DomainServices(session).devices.list(**params)
@router.post("/devices", response_model=DeviceRead, status_code=201)
def create_device(payload: DeviceCreate, session: Session = Depends(get_session)):
return commit_create(session, DomainServices(session).devices, payload)
@router.put("/devices/{item_id}", response_model=DeviceRead)
def update_device(item_id: str, payload: DeviceUpdate, session: Session = Depends(get_session)):
return commit_update(session, DomainServices(session).devices, item_id, payload)
@router.delete("/devices/{item_id}", status_code=204)
def delete_device(item_id: str, session: Session = Depends(get_session)):
return commit_delete(session, DomainServices(session).devices, item_id)
@router.get("/equipment", response_model=PaginatedResponse[EquipmentRead])
def list_equipment(params: dict = Depends(paging), session: Session = Depends(get_session)):
return DomainServices(session).equipment.list(**params)
@router.post("/equipment", response_model=EquipmentRead, status_code=201)
def create_equipment(payload: EquipmentCreate, session: Session = Depends(get_session)):
return commit_create(session, DomainServices(session).equipment, payload)
@router.put("/equipment/{item_id}", response_model=EquipmentRead)
def update_equipment(item_id: str, payload: EquipmentUpdate, session: Session = Depends(get_session)):
return commit_update(session, DomainServices(session).equipment, item_id, payload)
@router.delete("/equipment/{item_id}", status_code=204)
def delete_equipment(item_id: str, session: Session = Depends(get_session)):
return commit_delete(session, DomainServices(session).equipment, item_id)
@router.get("/validations", response_model=PaginatedResponse[ValidationRead])
def list_validations(params: dict = Depends(paging), session: Session = Depends(get_session)):
return DomainServices(session).validations.list(**params)
@router.get("/validations/next-report-number")
def next_report_number(session: Session = Depends(get_session)) -> dict[str, str]:
count = session.scalar(select(func.count()).select_from(Validation)) or 0
return {"report_number": f"VAL-{count + 1:05d}"}
@router.get("/validations/{item_id}", response_model=ValidationRead)
def get_validation(item_id: str, session: Session = Depends(get_session)):
item = DomainServices(session).validations.repository.get(item_id)
if item is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Resource not found")
return item
@router.post("/validations", response_model=ValidationRead, status_code=201)
def create_validation(payload: ValidationCreate, session: Session = Depends(get_session)):
return commit_create(session, DomainServices(session).validations, payload)
@router.put("/validations/{item_id}", response_model=ValidationRead)
def update_validation(item_id: str, payload: ValidationUpdate, session: Session = Depends(get_session)):
return commit_update(session, DomainServices(session).validations, item_id, payload)
@router.delete("/validations/{item_id}", status_code=204)
def delete_validation(item_id: str, session: Session = Depends(get_session)):
return commit_delete(session, DomainServices(session).validations, item_id)

View file

@ -0,0 +1,10 @@
from __future__ import annotations
from fastapi import APIRouter
from app.api.v1 import auth, domain
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router)
api_router.include_router(domain.router)

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "Validation Suite"
environment: str = "local"
database_url: str = Field(
default="postgresql+psycopg://validation:validation123@postgres:5432/validation_suite",
alias="DATABASE_URL",
)
jwt_secret: str = Field(default="change-me-in-production", alias="JWT_SECRET")
jwt_algorithm: str = "HS256"
access_token_minutes: int = 60 * 8
cors_origins: list[str] = ["http://localhost:3000"]
admin_email: str = Field(default="admin@schubamed.de", alias="ADMIN_EMAIL")
admin_password: str = Field(default="ValidationSuite!2026", alias="ADMIN_PASSWORD")
settings = Settings()

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import bcrypt
from jose import jwt
from app.core.config import settings
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
def create_access_token(subject: str, role: str) -> str:
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_minutes)
payload = {"sub": subject, "role": role, "exp": expires_at}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)

View file

@ -0,0 +1,31 @@
from __future__ import annotations
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, MetaData, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=convention)
class UUIDMixin:
id: Mapped[str] = mapped_column(primary_key=True, default=lambda: str(uuid4()))
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from sqlalchemy import select
from app.core.config import settings
from app.core.security import hash_password
from app.db.session import SessionLocal
from app.models.user import User, UserRole
def seed_admin() -> None:
with SessionLocal() as session:
existing = session.scalar(select(User).where(User.email == settings.admin_email.lower()))
if existing is not None:
return
session.add(
User(
email=settings.admin_email.lower(),
full_name="Validation Suite Administrator",
role=UserRole.admin,
password_hash=hash_password(settings.admin_password),
is_active=True,
)
)
session.commit()
if __name__ == "__main__":
seed_admin()

View file

@ -0,0 +1,20 @@
from __future__ import annotations
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def get_session() -> Generator[Session, None, None]:
session = SessionLocal()
try:
yield session
finally:
session.close()

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.router import api_router
from app.core.config import settings
app = FastAPI(title=settings.app_name, version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}

View file

@ -0,0 +1,21 @@
from app.models.contact import Contact
from app.models.customer import Customer
from app.models.device import Device
from app.models.document import Document
from app.models.equipment import Equipment
from app.models.location import Location
from app.models.program import Program
from app.models.user import User
from app.models.validation import Validation
__all__ = [
"Contact",
"Customer",
"Device",
"Document",
"Equipment",
"Location",
"Program",
"User",
"Validation",
]

View file

@ -0,0 +1,19 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base, TimestampMixin, UUIDMixin
class Contact(Base, UUIDMixin, TimestampMixin):
__tablename__ = "contacts"
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id", ondelete="CASCADE"), index=True)
full_name: Mapped[str] = mapped_column(String(160))
function: Mapped[str | None] = mapped_column(String(120))
email: Mapped[str | None] = mapped_column(String(255))
phone: Mapped[str | None] = mapped_column(String(80))
customer: Mapped["Customer"] = relationship(back_populates="contacts")

View file

@ -0,0 +1,32 @@
from __future__ import annotations
import enum
from sqlalchemy import Enum, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base, TimestampMixin, UUIDMixin
class CustomerType(str, enum.Enum):
practice = "practice"
clinic = "clinic"
class Customer(Base, UUIDMixin, TimestampMixin):
__tablename__ = "customers"
customer_type: Mapped[CustomerType] = mapped_column(Enum(CustomerType))
name: Mapped[str] = mapped_column(String(220), index=True)
street: Mapped[str | None] = mapped_column(String(220))
postal_code: Mapped[str | None] = mapped_column(String(20))
city: Mapped[str | None] = mapped_column(String(120))
phone: Mapped[str | None] = mapped_column(String(80))
email: Mapped[str | None] = mapped_column(String(255))
hygiene_officer: Mapped[str | None] = mapped_column(String(160))
quality_manager: Mapped[str | None] = mapped_column(String(160))
notes: Mapped[str | None] = mapped_column(Text)
contacts: Mapped[list["Contact"]] = relationship(back_populates="customer", cascade="all, delete-orphan")
locations: Mapped[list["Location"]] = relationship(back_populates="customer", cascade="all, delete-orphan")

View file

@ -0,0 +1,29 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base, TimestampMixin, UUIDMixin
class Device(Base, UUIDMixin, TimestampMixin):
__tablename__ = "devices"
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id"), index=True)
location_id: Mapped[str | None] = mapped_column(ForeignKey("locations.id"), index=True)
manufacturer: Mapped[str] = mapped_column(String(140))
model: Mapped[str] = mapped_column(String(140))
device_type: Mapped[str | None] = mapped_column(String(120))
serial_number: Mapped[str] = mapped_column(String(140), unique=True, index=True)
year_built: Mapped[int | None] = mapped_column(Integer)
commissioned_on: Mapped[date | None] = mapped_column(Date)
chamber_volume_liters: Mapped[int | None] = mapped_column(Integer)
steam_generation: Mapped[str | None] = mapped_column(String(220))
water_treatment: Mapped[str | None] = mapped_column(String(220))
documentation: Mapped[str | None] = mapped_column(Text)
supplier: Mapped[str | None] = mapped_column(String(180))
location: Mapped["Location | None"] = relationship(back_populates="devices")

View file

@ -0,0 +1,26 @@
from __future__ import annotations
import enum
from sqlalchemy import Enum, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDMixin
class DocumentOwnerType(str, enum.Enum):
customer = "customer"
device = "device"
validation = "validation"
equipment = "equipment"
class Document(Base, UUIDMixin, TimestampMixin):
__tablename__ = "documents"
owner_type: Mapped[DocumentOwnerType] = mapped_column(Enum(DocumentOwnerType), index=True)
owner_id: Mapped[str] = mapped_column(String(80), index=True)
filename: Mapped[str] = mapped_column(String(255))
content_type: Mapped[str] = mapped_column(String(120))
storage_path: Mapped[str] = mapped_column(String(500))

View file

@ -0,0 +1,35 @@
from __future__ import annotations
import enum
from datetime import date
from sqlalchemy import Date, Enum, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDMixin
class EquipmentKind(str, enum.Enum):
temperature_logger = "temperature_logger"
pressure_logger = "pressure_logger"
sensor = "sensor"
class EquipmentStatus(str, enum.Enum):
green = "green"
yellow = "yellow"
red = "red"
class Equipment(Base, UUIDMixin, TimestampMixin):
__tablename__ = "equipment"
kind: Mapped[EquipmentKind] = mapped_column(Enum(EquipmentKind))
manufacturer: Mapped[str | None] = mapped_column(String(140))
model: Mapped[str | None] = mapped_column(String(140))
serial_number: Mapped[str] = mapped_column(String(140), unique=True, index=True)
calibrated_on: Mapped[date | None] = mapped_column(Date)
calibration_due_on: Mapped[date | None] = mapped_column(Date)
certificate_document_id: Mapped[str | None] = mapped_column(String(80))
status: Mapped[EquipmentStatus] = mapped_column(Enum(EquipmentStatus), default=EquipmentStatus.green)

View file

@ -0,0 +1,21 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base, TimestampMixin, UUIDMixin
class Location(Base, UUIDMixin, TimestampMixin):
__tablename__ = "locations"
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(160))
street: Mapped[str | None] = mapped_column(String(220))
postal_code: Mapped[str | None] = mapped_column(String(20))
city: Mapped[str | None] = mapped_column(String(120))
room: Mapped[str | None] = mapped_column(String(120))
customer: Mapped["Customer"] = relationship(back_populates="locations")
devices: Mapped[list["Device"]] = relationship(back_populates="location")

View file

@ -0,0 +1,17 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDMixin
class Program(Base, UUIDMixin, TimestampMixin):
__tablename__ = "programs"
device_id: Mapped[str] = mapped_column(ForeignKey("devices.id"), index=True)
name: Mapped[str] = mapped_column(String(160))
temperature_celsius: Mapped[int | None] = mapped_column(Integer)
holding_time_minutes: Mapped[int | None] = mapped_column(Integer)
drying_time_minutes: Mapped[int | None] = mapped_column(Integer)

View file

@ -0,0 +1,25 @@
from __future__ import annotations
import enum
from sqlalchemy import Boolean, Enum, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDMixin
class UserRole(str, enum.Enum):
admin = "admin"
employee = "employee"
auditor = "auditor"
class User(Base, UUIDMixin, TimestampMixin):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
full_name: Mapped[str] = mapped_column(String(160))
role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.employee)
password_hash: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)

View file

@ -0,0 +1,49 @@
from __future__ import annotations
import enum
from datetime import date
from sqlalchemy import Date, Enum, ForeignKey, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDMixin
class ValidationStatus(str, enum.Enum):
draft = "draft"
in_progress = "in_progress"
ready_for_report = "ready_for_report"
completed = "completed"
class Validation(Base, UUIDMixin, TimestampMixin):
__tablename__ = "validations"
report_number: Mapped[str] = mapped_column(String(80), unique=True, index=True)
customer_id: Mapped[str] = mapped_column(ForeignKey("customers.id"), index=True)
location_id: Mapped[str | None] = mapped_column(ForeignKey("locations.id"), index=True)
contact_id: Mapped[str | None] = mapped_column(ForeignKey("contacts.id"), index=True)
device_id: Mapped[str | None] = mapped_column(ForeignKey("devices.id"), index=True)
validation_type: Mapped[str] = mapped_column(String(120))
project: Mapped[str | None] = mapped_column(String(180))
test_location: Mapped[str | None] = mapped_column(String(180))
examiner_name: Mapped[str | None] = mapped_column(String(180))
participants: Mapped[str | None] = mapped_column(Text)
operator_name: Mapped[str | None] = mapped_column(String(180))
scheduled_on: Mapped[date | None] = mapped_column(Date)
performed_on: Mapped[date | None] = mapped_column(Date)
next_validation_on: Mapped[date | None] = mapped_column(Date)
examiner_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"))
status: Mapped[ValidationStatus] = mapped_column(Enum(ValidationStatus), default=ValidationStatus.draft)
result: Mapped[str | None] = mapped_column(String(120))
notes: Mapped[str | None] = mapped_column(Text)
equipment_ids: Mapped[list[str]] = mapped_column(JSON, default=list)
environment_conditions: Mapped[dict] = mapped_column(JSON, default=dict)
documentation_checklist: Mapped[list[dict]] = mapped_column(JSON, default=list)
performance_checklist: Mapped[list[dict]] = mapped_column(JSON, default=list)
programs: Mapped[list[dict]] = mapped_column(JSON, default=list)
loading_patterns: Mapped[list[dict]] = mapped_column(JSON, default=list)
measurement_data: Mapped[list[dict]] = mapped_column(JSON, default=list)
drying: Mapped[dict] = mapped_column(JSON, default=dict)
recommendations: Mapped[list[dict]] = mapped_column(JSON, default=list)
attachments: Mapped[list[dict]] = mapped_column(JSON, default=list)

View file

@ -0,0 +1,19 @@
from __future__ import annotations
import csv
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class MeasurementSeries:
headers: list[str]
rows: list[dict[str, str]]
class HeliosImportService:
def import_csv(self, path: Path) -> MeasurementSeries:
with path.open(newline="", encoding="utf-8-sig") as handle:
reader = csv.DictReader(handle)
return MeasurementSeries(headers=reader.fieldnames or [], rows=list(reader))

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from pathlib import Path
from weasyprint import HTML
class OrionReportService:
chapters = [
"Deckblatt",
"Inhaltsverzeichnis",
"Zusammenfassung",
"Gerät",
"Kunde",
"Normen",
"Prüfmittel",
"Programme",
"Beladung",
"Messungen",
"Diagramme",
"Empfehlungen",
"Anlagen",
]
def render_pdf(self, title: str, output_path: Path) -> Path:
chapter_markup = "".join(f"<section><h2>{chapter}</h2></section>" for chapter in self.chapters)
html = f"<html><body><h1>{title}</h1>{chapter_markup}</body></html>"
HTML(string=html).write_pdf(output_path)
return output_path

View file

@ -0,0 +1,45 @@
from __future__ import annotations
from typing import Generic, TypeVar
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
ModelT = TypeVar("ModelT")
class Repository(Generic[ModelT]):
model: type[ModelT]
def __init__(self, session: Session) -> None:
self.session = session
def get(self, item_id: str) -> ModelT | None:
return self.session.get(self.model, item_id)
search_columns: tuple[str, ...] = ()
def _search_statement(self, search: str | None = None) -> Select[tuple[ModelT]]:
statement: Select[tuple[ModelT]] = select(self.model)
if search and self.search_columns:
term = f"%{search.strip()}%"
filters = [getattr(self.model, column).ilike(term) for column in self.search_columns]
statement = statement.where(or_(*filters))
return statement
def list(self, limit: int = 20, offset: int = 0, search: str | None = None) -> list[ModelT]:
statement = self._search_statement(search).offset(offset).limit(limit)
return list(self.session.scalars(statement))
def count(self, search: str | None = None) -> int:
subquery = self._search_statement(search).subquery()
return self.session.scalar(select(func.count()).select_from(subquery)) or 0
def add(self, item: ModelT) -> ModelT:
self.session.add(item)
self.session.flush()
return item
def delete(self, item: ModelT) -> None:
self.session.delete(item)
self.session.flush()

View file

@ -0,0 +1,67 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.customer import Customer
from app.models.device import Device
from app.models.document import Document
from app.models.equipment import Equipment
from app.models.contact import Contact
from app.models.location import Location
from app.models.user import User
from app.models.validation import Validation
from app.repositories.base import Repository
class UserRepository(Repository[User]):
model = User
def by_email(self, email: str) -> User | None:
return self.session.scalar(select(User).where(User.email == email.lower()))
class CustomerRepository(Repository[Customer]):
model = Customer
search_columns = ("name", "city", "email", "phone")
class LocationRepository(Repository[Location]):
model = Location
search_columns = ("name", "city", "room")
class ContactRepository(Repository[Contact]):
model = Contact
search_columns = ("full_name", "function", "email", "phone")
class DeviceRepository(Repository[Device]):
model = Device
search_columns = ("manufacturer", "model", "serial_number", "device_type")
class EquipmentRepository(Repository[Equipment]):
model = Equipment
search_columns = ("manufacturer", "model", "serial_number")
class ValidationRepository(Repository[Validation]):
model = Validation
class DocumentRepository(Repository[Document]):
model = Document
def repositories(session: Session) -> dict[str, Repository]:
return {
"users": UserRepository(session),
"customers": CustomerRepository(session),
"locations": LocationRepository(session),
"contacts": ContactRepository(session),
"devices": DeviceRepository(session),
"equipment": EquipmentRepository(session),
"validations": ValidationRepository(session),
"documents": DocumentRepository(session),
}

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from pydantic import BaseModel, EmailStr
from app.models.user import UserRole
from app.schemas.common import EntityRead
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserRead(EntityRead):
email: EmailStr
full_name: str
role: UserRole
is_active: bool

View file

@ -0,0 +1,25 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from typing import Generic, TypeVar
T = TypeVar("T")
class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
class EntityRead(ORMModel):
id: str
created_at: datetime
updated_at: datetime
class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int

View file

@ -0,0 +1,146 @@
from __future__ import annotations
from datetime import date
from pydantic import EmailStr, Field
from app.models.customer import CustomerType
from app.models.equipment import EquipmentKind, EquipmentStatus
from app.models.validation import ValidationStatus
from app.schemas.common import EntityRead, ORMModel
class CustomerCreate(ORMModel):
customer_type: CustomerType
name: str
street: str | None = None
postal_code: str | None = None
city: str | None = None
phone: str | None = None
email: EmailStr | None = None
hygiene_officer: str | None = None
quality_manager: str | None = None
notes: str | None = None
class CustomerRead(CustomerCreate, EntityRead):
pass
class CustomerUpdate(CustomerCreate):
pass
class LocationCreate(ORMModel):
customer_id: str
name: str
street: str | None = None
postal_code: str | None = None
city: str | None = None
room: str | None = None
class LocationRead(LocationCreate, EntityRead):
pass
class LocationUpdate(LocationCreate):
pass
class ContactCreate(ORMModel):
customer_id: str
full_name: str
function: str | None = None
email: EmailStr | None = None
phone: str | None = None
class ContactRead(ContactCreate, EntityRead):
pass
class ContactUpdate(ContactCreate):
pass
class DeviceCreate(ORMModel):
customer_id: str
location_id: str | None = None
manufacturer: str
model: str
device_type: str | None = None
serial_number: str
year_built: int | None = None
commissioned_on: date | None = None
chamber_volume_liters: int | None = None
steam_generation: str | None = None
water_treatment: str | None = None
documentation: str | None = None
supplier: str | None = None
class DeviceRead(DeviceCreate, EntityRead):
pass
class DeviceUpdate(DeviceCreate):
pass
class EquipmentCreate(ORMModel):
kind: EquipmentKind
manufacturer: str | None = None
model: str | None = None
serial_number: str
calibrated_on: date | None = None
calibration_due_on: date | None = None
certificate_document_id: str | None = None
status: EquipmentStatus = EquipmentStatus.green
class EquipmentRead(EquipmentCreate, EntityRead):
pass
class EquipmentUpdate(EquipmentCreate):
pass
class ValidationCreate(ORMModel):
report_number: str
customer_id: str
location_id: str | None = None
contact_id: str | None = None
device_id: str | None = None
validation_type: str
project: str | None = None
test_location: str | None = None
examiner_name: str | None = None
participants: str | None = None
operator_name: str | None = None
scheduled_on: date | None = None
performed_on: date | None = None
next_validation_on: date | None = None
examiner_id: str | None = None
status: ValidationStatus = ValidationStatus.draft
result: str | None = None
notes: str | None = None
equipment_ids: list[str] = Field(default_factory=list)
environment_conditions: dict = Field(default_factory=dict)
documentation_checklist: list[dict] = Field(default_factory=list)
performance_checklist: list[dict] = Field(default_factory=list)
programs: list[dict] = Field(default_factory=list)
loading_patterns: list[dict] = Field(default_factory=list)
measurement_data: list[dict] = Field(default_factory=list)
drying: dict = Field(default_factory=dict)
recommendations: list[dict] = Field(default_factory=list)
attachments: list[dict] = Field(default_factory=list)
class ValidationRead(ValidationCreate, EntityRead):
pass
class ValidationUpdate(ValidationCreate):
pass

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.core.security import create_access_token, verify_password
from app.repositories.domain import UserRepository
class AuthService:
def __init__(self, session: Session) -> None:
self.users = UserRepository(session)
def login(self, email: str, password: str) -> str:
user = self.users.by_email(email)
if user is None or not user.is_active or not verify_password(password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return create_access_token(user.id, user.role.value)

View file

@ -0,0 +1,68 @@
from __future__ import annotations
from typing import TypeVar
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.models.contact import Contact
from app.models.customer import Customer
from app.models.device import Device
from app.models.equipment import Equipment
from app.models.location import Location
from app.models.validation import Validation
from app.repositories.base import Repository
from app.repositories.domain import (
ContactRepository,
CustomerRepository,
DeviceRepository,
EquipmentRepository,
LocationRepository,
ValidationRepository,
)
ModelT = TypeVar("ModelT")
class CrudService:
def __init__(self, repository: Repository[ModelT]) -> None:
self.repository = repository
def list(self, page: int = 1, page_size: int = 20, search: str | None = None) -> dict:
safe_page = max(page, 1)
safe_page_size = min(max(page_size, 1), 100)
offset = (safe_page - 1) * safe_page_size
return {
"items": self.repository.list(safe_page_size, offset, search),
"total": self.repository.count(search),
"page": safe_page,
"page_size": safe_page_size,
}
def create(self, data: dict) -> ModelT:
return self.repository.add(self.repository.model(**data))
def update(self, item_id: str, data: dict) -> ModelT:
item = self.repository.get(item_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource not found")
for key, value in data.items():
setattr(item, key, value)
return item
def delete(self, item_id: str) -> None:
item = self.repository.get(item_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource not found")
self.repository.delete(item)
class DomainServices:
def __init__(self, session: Session) -> None:
self.customers: CrudService[Customer] = CrudService(CustomerRepository(session))
self.locations: CrudService[Location] = CrudService(LocationRepository(session))
self.contacts: CrudService[Contact] = CrudService(ContactRepository(session))
self.devices: CrudService[Device] = CrudService(DeviceRepository(session))
self.equipment: CrudService[Equipment] = CrudService(EquipmentRepository(session))
self.validations: CrudService[Validation] = CrudService(ValidationRepository(session))

View file

@ -0,0 +1,7 @@
#!/usr/bin/env sh
set -eu
alembic upgrade head
python -m app.db.seed
exec "$@"

View file

@ -0,0 +1,33 @@
[project]
name = "mercury"
version = "0.1.0"
description = "Validation Suite backend"
requires-python = ">=3.12"
dependencies = [
"alembic==1.16.4",
"bcrypt==4.3.0",
"fastapi==0.116.1",
"gunicorn==23.0.0",
"psycopg[binary]==3.2.9",
"pydantic-settings==2.10.1",
"pydantic[email]==2.11.7",
"python-jose[cryptography]==3.5.0",
"python-multipart==0.0.20",
"sqlalchemy==2.0.41",
"uvicorn[standard]==0.35.0",
"weasyprint==62.3"
]
[project.optional-dependencies]
dev = ["black==25.1.0", "isort==6.0.1", "ruff==0.12.4"]
[tool.black]
line-length = 100
[tool.isort]
profile = "black"
[tool.ruff]
line-length = 100
target-version = "py312"