UX: implement visual product roadmap
This commit is contained in:
@@ -2,10 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
@@ -14,7 +15,7 @@ from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import AuditEventOut, CurrentUser
|
||||
from app.schemas import AuditEventOut, AuditEventPageOut, CurrentUser
|
||||
|
||||
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
||||
|
||||
@@ -51,26 +52,51 @@ def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid
|
||||
return refs
|
||||
|
||||
|
||||
@router.get("", response_model=list[AuditEventOut])
|
||||
@router.get("", response_model=list[AuditEventOut] | AuditEventPageOut)
|
||||
def list_audit_events(
|
||||
actor_label: str | None = Query(default=None),
|
||||
action: str | None = Query(default=None),
|
||||
entity_type: str | None = Query(default=None),
|
||||
entity_ref: str | None = Query(default=None, min_length=1, max_length=100),
|
||||
correlation_id: str | None = Query(default=None),
|
||||
limit: int = Query(default=100, le=500),
|
||||
occurred_from: datetime | None = Query(default=None),
|
||||
occurred_to: datetime | None = Query(default=None),
|
||||
page: int | None = Query(default=None, ge=1),
|
||||
page_size: int = Query(default=25, ge=1, le=25),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> list[AuditEventOut]:
|
||||
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit)
|
||||
) -> list[AuditEventOut] | AuditEventPageOut:
|
||||
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc())
|
||||
if actor_label:
|
||||
stmt = stmt.where(AuditEvent.actor_label == actor_label)
|
||||
if action:
|
||||
stmt = stmt.where(AuditEvent.action == action)
|
||||
if entity_type:
|
||||
stmt = stmt.where(AuditEvent.entity_type == entity_type)
|
||||
if entity_ref:
|
||||
matched_ids: set[uuid.UUID] = set()
|
||||
for model in _ENTITY_MODELS.values():
|
||||
matched_ids.update(
|
||||
db.scalars(select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))).all()
|
||||
)
|
||||
if not matched_ids:
|
||||
if page is None:
|
||||
return []
|
||||
return AuditEventPageOut(
|
||||
items=[], page=1, page_size=page_size, total=0, total_pages=1
|
||||
)
|
||||
stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids))
|
||||
if correlation_id:
|
||||
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
|
||||
events = db.scalars(stmt).all()
|
||||
if occurred_from:
|
||||
stmt = stmt.where(AuditEvent.occurred_at >= occurred_from)
|
||||
if occurred_to:
|
||||
stmt = stmt.where(AuditEvent.occurred_at <= occurred_to)
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
page_number = page or 1
|
||||
events = db.scalars(
|
||||
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||
).all()
|
||||
entity_refs = _resolve_entity_refs(db, events)
|
||||
|
||||
out = []
|
||||
@@ -94,4 +120,13 @@ def list_audit_events(
|
||||
metadata=e.metadata_json,
|
||||
)
|
||||
)
|
||||
return out
|
||||
if page is None:
|
||||
return out
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
return AuditEventPageOut(
|
||||
items=out,
|
||||
page=min(page_number, total_pages),
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
@@ -16,6 +16,7 @@ from app.schemas import (
|
||||
CurrentUser,
|
||||
DataQualityIssueDetailOut,
|
||||
DataQualityIssueOut,
|
||||
DataQualityIssuePageOut,
|
||||
MergeCustomersRequest,
|
||||
MergeCustomersResult,
|
||||
ProvideFieldsRequest,
|
||||
@@ -54,14 +55,16 @@ def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/issues", response_model=list[DataQualityIssueOut])
|
||||
@router.get("/issues", response_model=list[DataQualityIssueOut] | DataQualityIssuePageOut)
|
||||
def list_issues(
|
||||
status: str | None = Query(default=None),
|
||||
rule_type: str | None = Query(default=None),
|
||||
severity: str | None = Query(default=None),
|
||||
page: int | None = Query(default=None, ge=1),
|
||||
page_size: int = Query(default=25, ge=1, le=25),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> list[DataQualityIssueOut]:
|
||||
) -> list[DataQualityIssueOut] | DataQualityIssuePageOut:
|
||||
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
|
||||
if status:
|
||||
stmt = stmt.where(DataQualityIssue.status == status)
|
||||
@@ -69,8 +72,22 @@ def list_issues(
|
||||
stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
|
||||
if severity:
|
||||
stmt = stmt.where(DataQualityIssue.severity == severity)
|
||||
issues = db.scalars(stmt).all()
|
||||
return [_to_out(i) for i in issues]
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
page_number = page or 1
|
||||
issues = db.scalars(
|
||||
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||
).all()
|
||||
items = [_to_out(i) for i in issues]
|
||||
if page is None:
|
||||
return items
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
return DataQualityIssuePageOut(
|
||||
items=items,
|
||||
page=min(page_number, total_pages),
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
|
||||
# Every public reference in this system carries its entity type in its own prefix
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
@@ -19,6 +19,7 @@ from app.schemas import (
|
||||
MaintenanceOut,
|
||||
VehicleDetailOut,
|
||||
VehicleOut,
|
||||
VehiclePageOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
|
||||
@@ -34,19 +35,41 @@ def _attention_vehicle_ids(db: Session) -> set:
|
||||
return set(rows)
|
||||
|
||||
|
||||
@router.get("", response_model=list[VehicleOut])
|
||||
@router.get("", response_model=list[VehicleOut] | VehiclePageOut)
|
||||
def list_vehicles(
|
||||
status: str | None = Query(default=None),
|
||||
attention_only: bool = Query(default=False),
|
||||
query: str | None = Query(default=None, min_length=1, max_length=100),
|
||||
page: int | None = Query(default=None, ge=1),
|
||||
page_size: int = Query(default=25, ge=1, le=25),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[VehicleOut]:
|
||||
) -> list[VehicleOut] | VehiclePageOut:
|
||||
stmt = select(Vehicle).order_by(Vehicle.public_ref)
|
||||
if status:
|
||||
stmt = stmt.where(Vehicle.operational_status == status)
|
||||
vehicles = db.scalars(stmt).all()
|
||||
if query:
|
||||
term = f"%{query.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Vehicle.public_ref.ilike(term),
|
||||
Vehicle.make.ilike(term),
|
||||
Vehicle.model.ilike(term),
|
||||
Vehicle.location.ilike(term),
|
||||
Vehicle.registration_number.ilike(term),
|
||||
)
|
||||
)
|
||||
attention_ids = _attention_vehicle_ids(db)
|
||||
out = [
|
||||
if attention_only:
|
||||
stmt = stmt.where(
|
||||
or_(Vehicle.id.in_(attention_ids), Vehicle.operational_status == "blocked")
|
||||
)
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
page_number = page or 1
|
||||
vehicles = db.scalars(
|
||||
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||
).all()
|
||||
items = [
|
||||
VehicleOut(
|
||||
public_ref=v.public_ref,
|
||||
make=v.make,
|
||||
@@ -62,9 +85,16 @@ def list_vehicles(
|
||||
)
|
||||
for v in vehicles
|
||||
]
|
||||
if attention_only:
|
||||
out = [v for v in out if v.attention]
|
||||
return out
|
||||
if page is None:
|
||||
return items
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
return VehiclePageOut(
|
||||
items=items,
|
||||
page=min(page_number, total_pages),
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{public_ref}", response_model=VehicleDetailOut)
|
||||
|
||||
Reference in New Issue
Block a user