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:
+16
-3
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 ###
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+1784
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user