M0: implement operational core

Backend: SQLAlchemy models, Alembic migrations, requirements lockfile. Frontend: pinned deps, package-lock, vite-env types fix. Verified compose build/health, pytest, ruff clean.
This commit is contained in:
NuklearRabbit
2026-08-01 21:01:04 +02:00
parent 24188d9b10
commit 04d26f1f2e
24 changed files with 2539 additions and 14 deletions
+16 -3
View File
@@ -2,7 +2,7 @@
## Current milestone
M0 — not started.
M0 — complete. Starting M1 next.
## Locked decisions
@@ -12,10 +12,23 @@ M0 — not started.
- Core stack and boundaries are defined in `CLAUDE.md` and `docs/03-architecture.md`.
- RAGcore and ITWorx MCP Hub are external central services.
- n8n receives post-commit events through an outbox dispatcher.
- SQLAlchemy 2 declarative models cover the full domain model (`backend/app/models/`); enums are plain `String` columns validated at the Pydantic/service layer, not native PG enums (simpler migrations).
- `backend/requirements.lock` is compiled inside a `python:3.12-slim` container (matches the Dockerfile base image) via `pip-compile --extra dev`; regenerate the same way if `pyproject.toml` changes.
- Frontend dependencies pinned (no more `"latest"`); `package-lock.json` committed; Docker build uses `npm ci`.
## Completed evidence
None.
### M0 — Reproducible foundation
- Added `backend/app/core/db.py` (engine/session), `backend/app/models/*` (User, Customer, Vehicle, Booking, Inspection, MaintenanceRecord, DataQualityIssue, OutboxEvent, AuditEvent), Alembic config (`backend/alembic.ini`, `backend/alembic/env.py`) and initial migration `backend/alembic/versions/c9498525abb5_initial_schema.py`.
- Commands run and verified from this checkout:
- `docker compose build api` — OK
- `docker compose run --rm api alembic upgrade head` — applied cleanly to empty DB, created 9 tables + `alembic_version`.
- `docker compose run --rm api pytest -q` — 1 passed.
- `docker compose run --rm api ruff check .` — All checks passed (added `extend-exclude = ["alembic/versions"]` to `backend/pyproject.toml` for autogenerated migration line length).
- `docker compose up -d --build` — all 4 services healthy: `curl http://localhost:8128/health``{"status":"ok",...}`; `curl -o /dev/null -w "%{http_code}" http://localhost:1228/` → 200; `curl http://localhost:5678/healthz` → 200.
- Fixed a real scaffold bug: `frontend/src/App.tsx` used `import.meta.env` without a `vite/client` types reference, which broke `npm run build` in Docker (works fine under plain `vite dev` because Vite injects the global at dev-time but `tsc -b` still type-checks it). Added `frontend/src/vite-env.d.ts`.
- `make` is not installed in this Windows/git-bash shell — validated the underlying `docker compose ...` commands directly instead (Makefile targets are thin wrappers around them and are correct as written for a Linux/CI shell or WSL).
- Known accepted gap: `npm audit` reports 1 moderate/1 high transitive `esbuild` advisory (dev-server-only, fixed only by a Vite 8 major bump); left as-is for the PoC, noted here rather than silently upgrading a major version.
## Known blockers
@@ -23,4 +36,4 @@ None. External service credentials may be absent; use the documented demo/degrad
## Exact next action
Read M0 inputs in `docs/15-build-plan.md`, verify the scaffold, generate lockfiles and establish clean-checkout validation.
Start M1 (operational core): read `docs/02-user-stories.md`, `docs/05-api-contract.md`, `docs/06-ui-ux.md`, `docs/13-seed-and-demo-scenarios.md` (already read this session). Implement: `app/cli.py` seed import/reset from `seed/*.csv`, demo auth/session + role middleware, dashboard/vehicles/bookings read APIs, audit-event writer, and the corresponding React router + pages (Dashboard, Vehicles, Bookings) with the persistent demo-disclosure banner.
+6 -1
View File
@@ -1,8 +1,13 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.lock ./
RUN pip install --no-cache-dir -r requirements.lock
COPY pyproject.toml ./
COPY app ./app
RUN pip install --no-cache-dir .
COPY alembic ./alembic
COPY alembic.ini ./
COPY tests ./tests
RUN pip install --no-cache-dir --no-deps -e .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[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
datefmt = %H:%M:%S
+46
View File
@@ -0,0 +1,46 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.core.config import get_settings
from app.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=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()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,184 @@
"""initial schema
Revision ID: c9498525abb5
Revises:
Create Date: 2026-08-01 20:50:05.997864
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'c9498525abb5'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('audit_events',
sa.Column('actor_type', sa.String(length=20), nullable=False),
sa.Column('actor_id', sa.UUID(), nullable=True),
sa.Column('actor_label', sa.String(length=120), nullable=False),
sa.Column('action', sa.String(length=80), nullable=False),
sa.Column('entity_type', sa.String(length=30), nullable=False),
sa.Column('entity_id', sa.UUID(), nullable=True),
sa.Column('correlation_id', sa.UUID(), nullable=False),
sa.Column('before_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('after_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('metadata_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('occurred_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('customers',
sa.Column('public_ref', sa.String(length=20), nullable=False),
sa.Column('first_name', sa.String(length=80), nullable=False),
sa.Column('last_name', sa.String(length=80), nullable=False),
sa.Column('email', sa.String(length=200), nullable=True),
sa.Column('phone', sa.String(length=40), nullable=True),
sa.Column('postal_code', sa.String(length=20), nullable=True),
sa.Column('city', sa.String(length=120), nullable=True),
sa.Column('date_of_birth', sa.Date(), nullable=True),
sa.Column('merged_into_customer_id', sa.UUID(), nullable=True),
sa.Column('id', sa.UUID(), 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(['merged_into_customer_id'], ['customers.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('public_ref')
)
op.create_table('data_quality_issues',
sa.Column('public_ref', sa.String(length=20), nullable=False),
sa.Column('rule_type', sa.String(length=40), nullable=False),
sa.Column('entity_type', sa.String(length=30), nullable=False),
sa.Column('entity_id', sa.UUID(), nullable=False),
sa.Column('severity', sa.String(length=10), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('evidence_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('proposed_action_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('detected_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('resolved_by', sa.String(length=120), nullable=True),
sa.Column('id', sa.UUID(), 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('public_ref')
)
op.create_table('outbox_events',
sa.Column('event_id', sa.UUID(), nullable=False),
sa.Column('event_type', sa.String(length=60), nullable=False),
sa.Column('aggregate_type', sa.String(length=30), nullable=False),
sa.Column('aggregate_id', sa.UUID(), nullable=False),
sa.Column('payload_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('occurred_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('delivery_status', sa.String(length=20), nullable=False),
sa.Column('attempts', sa.Integer(), nullable=False),
sa.Column('next_attempt_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_error', sa.Text(), nullable=True),
sa.Column('external_run_id', sa.String(length=120), nullable=True),
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('event_id')
)
op.create_table('users',
sa.Column('public_ref', sa.String(length=20), nullable=False),
sa.Column('display_name', sa.String(length=120), nullable=False),
sa.Column('role', sa.String(length=30), nullable=False),
sa.Column('active', sa.Boolean(), nullable=False),
sa.Column('id', sa.UUID(), 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('public_ref')
)
op.create_table('vehicles',
sa.Column('public_ref', sa.String(length=20), nullable=False),
sa.Column('make', sa.String(length=80), nullable=False),
sa.Column('model', sa.String(length=80), nullable=False),
sa.Column('model_year', sa.Integer(), nullable=False),
sa.Column('registration_number', sa.String(length=40), nullable=False),
sa.Column('location', sa.String(length=120), nullable=False),
sa.Column('operational_status', sa.String(length=20), nullable=False),
sa.Column('odometer_km', sa.Integer(), nullable=False),
sa.Column('next_service_km', sa.Integer(), nullable=False),
sa.Column('active', sa.Boolean(), nullable=False),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('id', sa.UUID(), 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('public_ref'),
sa.UniqueConstraint('registration_number')
)
op.create_table('bookings',
sa.Column('public_ref', sa.String(length=20), nullable=False),
sa.Column('customer_id', sa.UUID(), nullable=False),
sa.Column('vehicle_id', sa.UUID(), nullable=False),
sa.Column('starts_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('ends_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('start_odometer_km', sa.Integer(), nullable=True),
sa.Column('end_odometer_km', sa.Integer(), nullable=True),
sa.Column('requirements_complete', sa.Boolean(), nullable=False),
sa.Column('id', sa.UUID(), 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(['vehicle_id'], ['vehicles.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('public_ref')
)
op.create_table('maintenance_records',
sa.Column('public_ref', sa.String(length=20), nullable=False),
sa.Column('vehicle_id', sa.UUID(), nullable=False),
sa.Column('occurred_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('odometer_km', sa.Integer(), nullable=False),
sa.Column('category', sa.String(length=60), nullable=False),
sa.Column('summary', sa.Text(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('public_ref')
)
op.create_table('inspections',
sa.Column('public_ref', sa.String(length=20), nullable=False),
sa.Column('booking_id', sa.UUID(), nullable=False),
sa.Column('vehicle_id', sa.UUID(), nullable=False),
sa.Column('type', sa.String(length=20), nullable=False),
sa.Column('fuel_level_percent', sa.Integer(), nullable=False),
sa.Column('cleanliness_ok', sa.Boolean(), nullable=False),
sa.Column('damage_reported', sa.Boolean(), nullable=False),
sa.Column('technical_warning', sa.Boolean(), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('odometer_km', sa.Integer(), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('completed_by', sa.String(length=120), nullable=True),
sa.Column('id', sa.UUID(), 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(['booking_id'], ['bookings.id'], ),
sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('public_ref')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('inspections')
op.drop_table('maintenance_records')
op.drop_table('bookings')
op.drop_table('vehicles')
op.drop_table('users')
op.drop_table('outbox_events')
op.drop_table('data_quality_issues')
op.drop_table('customers')
op.drop_table('audit_events')
# ### end Alembic commands ###
+23
View File
@@ -0,0 +1,23 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import get_settings
settings = get_settings()
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+23
View File
@@ -0,0 +1,23 @@
from app.core.db import Base
from app.models.audit import AuditEvent
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent
from app.models.user import User
from app.models.vehicle import Vehicle
__all__ = [
"Base",
"AuditEvent",
"Booking",
"Customer",
"DataQualityIssue",
"Inspection",
"MaintenanceRecord",
"OutboxEvent",
"User",
"Vehicle",
]
+27
View File
@@ -0,0 +1,27 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import UUIDPrimaryKeyMixin
ACTOR_TYPES = ("user", "service", "system")
class AuditEvent(UUIDPrimaryKeyMixin, Base):
__tablename__ = "audit_events"
actor_type: Mapped[str] = mapped_column(String(20), nullable=False)
actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
actor_label: Mapped[str] = mapped_column(String(120), nullable=False)
action: Mapped[str] = mapped_column(String(80), nullable=False)
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
entity_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
correlation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
before_json: Mapped[dict | None] = mapped_column(JSONB)
after_json: Mapped[dict | None] = mapped_column(JSONB)
metadata_json: Mapped[dict | None] = mapped_column(JSONB)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
BOOKING_STATUSES = ("reserved", "active", "returned", "cancelled", "blocked")
class Booking(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "bookings"
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
customer_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("customers.id"), nullable=False
)
vehicle_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("vehicles.id"), nullable=False
)
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False)
start_odometer_km: Mapped[int | None] = mapped_column(Integer)
end_odometer_km: Mapped[int | None] = mapped_column(Integer)
requirements_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import date
from sqlalchemy import Date, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
class Customer(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "customers"
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
first_name: Mapped[str] = mapped_column(String(80), nullable=False)
last_name: Mapped[str] = mapped_column(String(80), nullable=False)
email: Mapped[str | None] = mapped_column(String(200))
phone: Mapped[str | None] = mapped_column(String(40))
postal_code: Mapped[str | None] = mapped_column(String(20))
city: Mapped[str | None] = mapped_column(String(120))
date_of_birth: Mapped[date | None] = mapped_column(Date)
merged_into_customer_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("customers.id")
)
+35
View File
@@ -0,0 +1,35 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
RULE_TYPES = (
"possible_duplicate_customer",
"missing_required_field",
"odometer_regression",
"booking_overlap",
"vehicle_status_conflict",
)
SEVERITIES = ("low", "medium", "high")
ISSUE_STATUSES = ("open", "deferred", "resolved", "rejected")
class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "data_quality_issues"
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
rule_type: Mapped[str] = mapped_column(String(40), nullable=False)
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
severity: Mapped[str] = mapped_column(String(10), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="open")
evidence_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
proposed_action_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_by: Mapped[str | None] = mapped_column(String(120))
+32
View File
@@ -0,0 +1,32 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
INSPECTION_TYPES = ("checkout", "return")
class Inspection(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "inspections"
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
booking_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=False
)
vehicle_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("vehicles.id"), nullable=False
)
type: Mapped[str] = mapped_column(String(20), nullable=False)
fuel_level_percent: Mapped[int] = mapped_column(Integer, nullable=False)
cleanliness_ok: Mapped[bool] = mapped_column(Boolean, nullable=False)
damage_reported: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
technical_warning: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
notes: Mapped[str | None] = mapped_column(Text)
odometer_km: Mapped[int] = mapped_column(Integer, nullable=False)
completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
completed_by: Mapped[str | None] = mapped_column(String(120))
+22
View File
@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import UUIDPrimaryKeyMixin
class MaintenanceRecord(UUIDPrimaryKeyMixin, Base):
__tablename__ = "maintenance_records"
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
vehicle_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("vehicles.id"), nullable=False
)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
odometer_km: Mapped[int] = mapped_column(Integer, nullable=False)
category: Mapped[str] = mapped_column(String(60), nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
+24
View File
@@ -0,0 +1,24 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
class UUIDPrimaryKeyMixin:
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin
DELIVERY_STATUSES = ("pending", "delivering", "succeeded", "failed")
class OutboxEvent(TimestampMixin, Base):
__tablename__ = "outbox_events"
event_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
event_type: Mapped[str] = mapped_column(String(60), nullable=False)
aggregate_type: Mapped[str] = mapped_column(String(30), nullable=False)
aggregate_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
payload_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
delivery_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str | None] = mapped_column(Text)
external_run_id: Mapped[str | None] = mapped_column(String(120))
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
ROLES = ("operations_manager", "rental_employee")
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "users"
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
role: Mapped[str] = mapped_column(String(30), nullable=False)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
OPERATIONAL_STATUSES = ("available", "rented", "cleaning", "maintenance", "blocked")
class Vehicle(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "vehicles"
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
make: Mapped[str] = mapped_column(String(80), nullable=False)
model: Mapped[str] = mapped_column(String(80), nullable=False)
model_year: Mapped[int] = mapped_column(Integer, nullable=False)
registration_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
location: Mapped[str] = mapped_column(String(120), nullable=False)
operational_status: Mapped[str] = mapped_column(String(20), nullable=False)
odometer_km: Mapped[int] = mapped_column(Integer, nullable=False)
next_service_km: Mapped[int] = mapped_column(Integer, nullable=False)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
+1
View File
@@ -34,6 +34,7 @@ asyncio_mode = "auto"
[tool.ruff]
line-length = 100
extend-exclude = ["alembic/versions"]
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
+118
View File
@@ -0,0 +1,118 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --extra=dev --output-file=requirements.lock pyproject.toml
#
alembic==1.18.5
# via mobilityops-api (pyproject.toml)
annotated-doc==0.0.5
# via fastapi
annotated-types==0.8.0
# via pydantic
anyio==4.14.2
# via
# httpx
# starlette
# watchfiles
certifi==2026.7.22
# via
# httpcore
# httpx
click==8.4.2
# via uvicorn
fastapi==0.141.1
# via mobilityops-api (pyproject.toml)
greenlet==3.5.4
# via sqlalchemy
h11==0.16.0
# via
# httpcore
# uvicorn
httpcore==1.0.9
# via httpx
httptools==0.8.0
# via uvicorn
httpx==0.28.1
# via mobilityops-api (pyproject.toml)
idna==3.18
# via
# anyio
# httpx
iniconfig==2.3.0
# via pytest
librt==0.13.0
# via mypy
mako==1.3.12
# via alembic
markupsafe==3.0.3
# via mako
mypy==1.20.2
# via mobilityops-api (pyproject.toml)
mypy-extensions==1.1.0
# via mypy
packaging==26.2
# via pytest
pathspec==1.1.1
# via mypy
pluggy==1.6.0
# via pytest
psycopg[binary]==3.3.4
# via mobilityops-api (pyproject.toml)
psycopg-binary==3.3.4
# via psycopg
pydantic==2.13.4
# via
# fastapi
# pydantic-settings
pydantic-core==2.46.4
# via pydantic
pydantic-settings==2.14.2
# via mobilityops-api (pyproject.toml)
pygments==2.20.0
# via pytest
pytest==8.4.2
# via
# mobilityops-api (pyproject.toml)
# pytest-asyncio
pytest-asyncio==0.26.0
# via mobilityops-api (pyproject.toml)
python-dotenv==1.2.2
# via
# pydantic-settings
# uvicorn
pyyaml==6.0.3
# via uvicorn
ruff==0.16.1
# via mobilityops-api (pyproject.toml)
sqlalchemy==2.0.51
# via
# alembic
# mobilityops-api (pyproject.toml)
starlette==1.3.1
# via fastapi
typing-extensions==4.16.0
# via
# alembic
# anyio
# fastapi
# mypy
# psycopg
# pydantic
# pydantic-core
# sqlalchemy
# starlette
# typing-inspection
typing-inspection==0.4.2
# via
# fastapi
# pydantic
# pydantic-settings
uvicorn[standard]==0.52.1
# via mobilityops-api (pyproject.toml)
uvloop==0.22.1
# via uvicorn
watchfiles==1.2.0
# via uvicorn
websockets==17.0.1
# via uvicorn
+2 -2
View File
@@ -1,8 +1,8 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json tsconfig.json vite.config.ts index.html ./
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
COPY src ./src
RUN npm install && npm run build
RUN npm ci && npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
+1784
View File
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -6,17 +6,18 @@
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0"
"preview": "vite preview --host 0.0.0.0",
"lint": "tsc -b --noEmit"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"vite": "latest",
"typescript": "latest",
"react": "latest",
"react-dom": "latest"
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/react": "latest",
"@types/react-dom": "latest"
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.6.3",
"vite": "5.4.21"
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />