M18: implement operational workspaces
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
@@ -9,10 +11,13 @@ 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.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import (
|
||||
ApplyRecommendedStatusRequest,
|
||||
ApplyRecommendedStatusResult,
|
||||
BulkDataQualityWorkRequest,
|
||||
BulkDataQualityWorkResult,
|
||||
CurrentUser,
|
||||
DataQualityIssueDetailOut,
|
||||
DataQualityIssueOut,
|
||||
@@ -26,6 +31,7 @@ from app.schemas import (
|
||||
StatusRecommendationOut,
|
||||
VehicleStatusFactsOut,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality import (
|
||||
apply_recommended_status,
|
||||
defer_issue,
|
||||
@@ -42,6 +48,7 @@ router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"])
|
||||
|
||||
|
||||
def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
|
||||
assignee = issue.assigned_to_user
|
||||
return DataQualityIssueOut(
|
||||
public_ref=issue.public_ref,
|
||||
rule_type=issue.rule_type,
|
||||
@@ -51,6 +58,12 @@ def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
|
||||
status=issue.status,
|
||||
evidence=issue.evidence_json,
|
||||
detected_at=issue.detected_at,
|
||||
due_at=issue.due_at,
|
||||
assigned_to_ref=assignee.public_ref if assignee else None,
|
||||
assigned_to_name=assignee.display_name if assignee else None,
|
||||
overdue=(
|
||||
issue.status == "open" and issue.due_at is not None and issue.due_at < datetime.now(UTC)
|
||||
),
|
||||
resolved_at=issue.resolved_at,
|
||||
)
|
||||
|
||||
@@ -60,18 +73,40 @@ def list_issues(
|
||||
status: str | None = Query(default=None),
|
||||
rule_type: str | None = Query(default=None),
|
||||
severity: str | None = Query(default=None),
|
||||
assigned_to_ref: str | None = Query(default=None),
|
||||
overdue: bool | 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] | DataQualityIssuePageOut:
|
||||
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
|
||||
severity_order = case(
|
||||
(DataQualityIssue.severity == "high", 0),
|
||||
(DataQualityIssue.severity == "medium", 1),
|
||||
else_=2,
|
||||
)
|
||||
stmt = select(DataQualityIssue).order_by(
|
||||
DataQualityIssue.due_at.asc().nulls_last(),
|
||||
severity_order,
|
||||
DataQualityIssue.detected_at.desc(),
|
||||
)
|
||||
if status:
|
||||
stmt = stmt.where(DataQualityIssue.status == status)
|
||||
if rule_type:
|
||||
stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
|
||||
if severity:
|
||||
stmt = stmt.where(DataQualityIssue.severity == severity)
|
||||
if assigned_to_ref == "unassigned":
|
||||
stmt = stmt.where(DataQualityIssue.assigned_to_user_id.is_(None))
|
||||
elif assigned_to_ref:
|
||||
stmt = stmt.join(DataQualityIssue.assigned_to_user).where(
|
||||
User.public_ref == assigned_to_ref
|
||||
)
|
||||
if overdue is True:
|
||||
stmt = stmt.where(
|
||||
DataQualityIssue.status == "open",
|
||||
DataQualityIssue.due_at < datetime.now(UTC),
|
||||
)
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
page_number = page or 1
|
||||
issues = db.scalars(
|
||||
@@ -90,6 +125,90 @@ def list_issues(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/issues/bulk-work", response_model=BulkDataQualityWorkResult)
|
||||
def update_issue_work_queue(
|
||||
body: BulkDataQualityWorkRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> BulkDataQualityWorkResult:
|
||||
refs = list(dict.fromkeys(body.issue_refs))
|
||||
if (
|
||||
body.assigned_to_ref is None
|
||||
and not body.clear_assignment
|
||||
and body.due_at is None
|
||||
and not body.clear_due_at
|
||||
):
|
||||
raise HTTPException(status_code=422, detail="No work queue change was requested")
|
||||
if body.assigned_to_ref is not None and body.clear_assignment:
|
||||
raise HTTPException(status_code=422, detail="Choose an assignee or clear assignment")
|
||||
if body.due_at is not None and body.clear_due_at:
|
||||
raise HTTPException(status_code=422, detail="Choose a due date or clear the due date")
|
||||
if body.due_at is not None and body.due_at.tzinfo is None:
|
||||
raise HTTPException(status_code=422, detail="Due date must include a timezone")
|
||||
|
||||
assignee = None
|
||||
if body.assigned_to_ref is not None:
|
||||
assignee = db.scalar(
|
||||
select(User).where(
|
||||
User.public_ref == body.assigned_to_ref,
|
||||
User.active.is_(True),
|
||||
)
|
||||
)
|
||||
if assignee is None:
|
||||
raise HTTPException(status_code=422, detail="Active assignee not found")
|
||||
|
||||
issues = list(
|
||||
db.scalars(
|
||||
select(DataQualityIssue).where(DataQualityIssue.public_ref.in_(refs)).with_for_update()
|
||||
).all()
|
||||
)
|
||||
if len(issues) != len(refs):
|
||||
found = {issue.public_ref for issue in issues}
|
||||
missing = next(ref for ref in refs if ref not in found)
|
||||
raise HTTPException(status_code=404, detail=f"Data quality issue {missing} not found")
|
||||
|
||||
for issue in issues:
|
||||
if issue.status != "open":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Data quality issue {issue.public_ref} is not open",
|
||||
)
|
||||
before = {
|
||||
"assigned_to_ref": issue.assigned_to_user.public_ref
|
||||
if issue.assigned_to_user
|
||||
else None,
|
||||
"due_at": issue.due_at.isoformat() if issue.due_at else None,
|
||||
}
|
||||
if body.assigned_to_ref is not None:
|
||||
issue.assigned_to_user = assignee
|
||||
elif body.clear_assignment:
|
||||
issue.assigned_to_user = None
|
||||
if body.due_at is not None:
|
||||
issue.due_at = body.due_at
|
||||
elif body.clear_due_at:
|
||||
issue.due_at = None
|
||||
after = {
|
||||
"assigned_to_ref": assignee.public_ref
|
||||
if body.assigned_to_ref is not None and assignee
|
||||
else (None if body.clear_assignment else before["assigned_to_ref"]),
|
||||
"due_at": issue.due_at.isoformat() if issue.due_at else None,
|
||||
}
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="data_quality_work_updated",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
before=before,
|
||||
after=after,
|
||||
)
|
||||
db.commit()
|
||||
for issue in issues:
|
||||
db.refresh(issue)
|
||||
return BulkDataQualityWorkResult(updated=[_to_out(issue) for issue in issues])
|
||||
|
||||
|
||||
# Every public reference in this system carries its entity type in its own prefix
|
||||
# (CUS-/MO-/BK-/INSP-/DQ-). Related-entity typing is resolved from the reference itself,
|
||||
# not guessed from the issue's rule_type -- a booking_overlap issue's related refs are
|
||||
@@ -239,9 +358,7 @@ def provide_fields(
|
||||
return _to_out(issue)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut
|
||||
)
|
||||
@router.post("/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut)
|
||||
def resolve_odometer(
|
||||
public_ref: str,
|
||||
body: ResolveOdometerRegressionRequest,
|
||||
@@ -263,9 +380,7 @@ def resolve_overlap(
|
||||
return _to_out(issue)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut
|
||||
)
|
||||
@router.post("/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut)
|
||||
def status_recommendation(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
Reference in New Issue
Block a user