"""Campaign endpoints — CRUD, test management, activation, and auto-generation. Provides comprehensive campaign lifecycle management including test ordering, progress tracking, and threat actor integration. """ # Import logging import logging # Import uuid import uuid from datetime import datetime from typing import Optional # Import APIRouter, Depends, Query from fastapi from fastapi import APIRouter, Depends, Query # Import BaseModel, Field from pydantic from pydantic import BaseModel, Field # Import Session from sqlalchemy.orm from sqlalchemy.orm import Session # Import get_db from app.database from app.database import get_db # Import get_current_user, require_any_role from app.dependencies.auth from app.dependencies.auth import get_current_user, require_any_role # Import UnitOfWork from app.domain.unit_of_work from app.domain.unit_of_work import UnitOfWork # Import User from app.models.user from app.models.user import User from app.models.campaign import Campaign, CampaignTest from app.models.test import Test from app.services.campaign_service import generate_campaign_from_threat_actor from app.services.campaign_crud_service import ( add_test_to_campaign as crud_add_test, ) # Import from app.services.campaign_crud_service from app.services.campaign_crud_service import ( complete_campaign as crud_complete, ) # Import from app.services.campaign_crud_service from app.services.campaign_crud_service import ( create_campaign as crud_create, delete_campaign as crud_delete, get_campaign_detail as crud_get_detail, ) # Import from app.services.campaign_crud_service from app.services.campaign_crud_service import ( get_campaign_history as crud_get_history, ) # Import from app.services.campaign_crud_service from app.services.campaign_crud_service import ( get_campaign_progress_data as crud_get_progress, ) # Import from app.services.campaign_crud_service from app.services.campaign_crud_service import ( list_campaigns as crud_list, ) # Import from app.services.campaign_crud_service from app.services.campaign_crud_service import ( remove_test_from_campaign as crud_remove_test, ) # Import from app.services.campaign_crud_service from app.services.campaign_crud_service import ( schedule_campaign as crud_schedule, ) # Import from app.services.campaign_crud_service from app.services.campaign_crud_service import ( serialize_campaign, ) # Import from app.services.campaign_crud_service from app.services.campaign_crud_service import ( update_campaign as crud_update, ) # Import generate_campaign_from_threat_actor from app.services.campaign_service from app.services.campaign_service import generate_campaign_from_threat_actor # Import notify_role from app.services.notification_service from app.services.notification_service import notify_role from app.services.webhook_service import dispatch_webhook # Assign logger = logging.getLogger(__name__) logger = logging.getLogger(__name__) # Assign router = APIRouter(prefix="/campaigns", tags=["campaigns"]) router = APIRouter(prefix="/campaigns", tags=["campaigns"]) # ── Pydantic schemas ───────────────────────────────────────────────── class CampaignCreate(BaseModel): """Payload for creating a new campaign.""" # name: str name: str # Assign description = None description: Optional[str] = None # Assign type = "custom" type: str = "custom" # Assign threat_actor_id = None threat_actor_id: Optional[str] = None # Assign target_platform = None target_platform: Optional[str] = None # Assign tags = Field(default_factory=list) tags: Optional[list[str]] = Field(default_factory=list) # Assign scheduled_at = None scheduled_at: Optional[str] = None start_date: Optional[str] = None # ISO date — campaign won't activate before this # Define class CampaignUpdate class CampaignUpdate(BaseModel): """Payload for updating an existing campaign's metadata.""" # Assign name = None name: Optional[str] = None # Assign description = None description: Optional[str] = None # Assign type = None type: Optional[str] = None # Assign target_platform = None target_platform: Optional[str] = None # Assign tags = None tags: Optional[list[str]] = None # Assign scheduled_at = None scheduled_at: Optional[str] = None start_date: Optional[str] = None # ISO date — can be updated while still in draft # Define class AddTestPayload class AddTestPayload(BaseModel): """Payload for adding a test to a campaign.""" # test_id: str test_id: str # Assign order_index = None order_index: Optional[int] = None # Assign depends_on = None depends_on: Optional[str] = None # Assign phase = None phase: Optional[str] = None # Define class SchedulePayload class SchedulePayload(BaseModel): """Payload for scheduling or rescheduling a campaign run.""" # is_recurring: bool is_recurring: bool # Assign recurrence_pattern = None # weekly, monthly, quarterly recurrence_pattern: Optional[str] = None # weekly, monthly, quarterly # Assign next_run_at = None next_run_at: Optional[str] = None # --------------------------------------------------------------------------- # GET /campaigns — List campaigns with filters # --------------------------------------------------------------------------- @router.get("") # Define function list_campaigns def list_campaigns( # Entry: type type: Optional[str] = Query(None), # Entry: status status: Optional[str] = Query(None), # Entry: threat_actor_id threat_actor_id: Optional[str] = Query(None), # Entry: search search: Optional[str] = Query(None), # Entry: offset offset: int = Query(0, ge=0), # Entry: limit limit: int = Query(50, ge=1, le=200), # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(get_current_user), ) -> list: """List campaigns with optional filters and pagination. Args: type (Optional[str]): Filter by campaign type (e.g. ``custom``, ``threat_actor``). status (Optional[str]): Filter by campaign status (e.g. ``draft``, ``active``). threat_actor_id (Optional[str]): Filter campaigns linked to a specific threat actor. search (Optional[str]): Free-text search against campaign name. offset (int): Number of records to skip for pagination. limit (int): Maximum number of records to return. db (Session): SQLAlchemy database session. current_user (User): Authenticated user making the request. Returns: list: Serialised list of campaign summary dicts. """ # Return crud_list( return crud_list( db, # Keyword argument: type type=type, # Keyword argument: status status=status, # Keyword argument: threat_actor_id threat_actor_id=threat_actor_id, # Keyword argument: search search=search, # Keyword argument: offset offset=offset, # Keyword argument: limit limit=limit, ) # --------------------------------------------------------------------------- # POST /campaigns — Create campaign # --------------------------------------------------------------------------- @router.post("", status_code=201) # Define function create_campaign def create_campaign( # Entry: payload payload: CampaignCreate, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ) -> dict: """Create a new campaign. Args: payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.). db (Session): SQLAlchemy database session. current_user (User): Authenticated red_lead or blue_lead creating the campaign. Returns: dict: Serialised representation of the newly created campaign. """ # Open context manager with UnitOfWork(db) as uow: # Assign result = crud_create( result = crud_create( db, # Keyword argument: creator_id creator_id=current_user.id, # Keyword argument: name name=payload.name, # Keyword argument: description description=payload.description, # Keyword argument: type type=payload.type, # Keyword argument: threat_actor_id threat_actor_id=payload.threat_actor_id, # Keyword argument: target_platform target_platform=payload.target_platform, # Keyword argument: tags tags=payload.tags, # Keyword argument: scheduled_at scheduled_at=payload.scheduled_at, start_date=payload.start_date, ) campaign_id = result["id"] log_action( db, # Keyword argument: user_id user_id=current_user.id, # Keyword argument: action action="create_campaign", # Keyword argument: entity_type entity_type="campaign", entity_id=campaign_id, details={"name": payload.name, "type": payload.type}, ) # Call uow.commit() uow.commit() # Return result return result # --------------------------------------------------------------------------- # GET /campaigns/{id} — Detail with tests and progress # --------------------------------------------------------------------------- @router.get("/{campaign_id}") # Define function get_campaign def get_campaign( # Entry: campaign_id campaign_id: str, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(get_current_user), ) -> dict: """Get detailed campaign info including tests and progress. Args: campaign_id (str): UUID string of the campaign to retrieve. db (Session): SQLAlchemy database session. current_user (User): Authenticated user making the request. Returns: dict: Campaign detail including associated tests and progress metrics. """ # Return crud_get_detail(db, campaign_id) return crud_get_detail(db, campaign_id) # --------------------------------------------------------------------------- # PATCH /campaigns/{id} — Update campaign # --------------------------------------------------------------------------- @router.patch("/{campaign_id}") # Define function update_campaign def update_campaign( # Entry: campaign_id campaign_id: str, # Entry: payload payload: CampaignUpdate, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ) -> dict: """Update a campaign. Only allowed in draft or active state. Args: campaign_id (str): UUID string of the campaign to update. payload (CampaignUpdate): Partial update payload; only set fields are applied. db (Session): SQLAlchemy database session. current_user (User): Authenticated red_lead or blue_lead performing the update. Returns: dict: Serialised representation of the updated campaign. """ # Assign update_data = payload.model_dump(exclude_unset=True) update_data = payload.model_dump(exclude_unset=True) # Open context manager with UnitOfWork(db) as uow: # Assign result = crud_update( result = crud_update( db, campaign_id, # Keyword argument: updater_id updater_id=current_user.id, # Keyword argument: updater_role updater_role=current_user.role, **update_data, ) # Call log_action() log_action( db, # Keyword argument: user_id user_id=current_user.id, # Keyword argument: action action="update_campaign", # Keyword argument: entity_type entity_type="campaign", # Keyword argument: entity_id entity_id=campaign_id, # Keyword argument: details details={"updated_fields": list(update_data.keys())}, ) # Call uow.commit() uow.commit() # Return result return result # --------------------------------------------------------------------------- # DELETE /campaigns/{id} — Delete campaign # --------------------------------------------------------------------------- @router.delete("/{campaign_id}", status_code=204) def delete_campaign( campaign_id: str, delete_tests: bool = Query(False, description="Also delete associated tests"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Delete a campaign. Only draft campaigns can be deleted (admins can delete any).""" with UnitOfWork(db) as uow: crud_delete( db, campaign_id, deleter_id=current_user.id, deleter_role=current_user.role, delete_tests=delete_tests, ) log_action( db, user_id=current_user.id, action="delete_campaign", entity_type="campaign", entity_id=campaign_id, details={"delete_tests": delete_tests}, ) uow.commit() # --------------------------------------------------------------------------- # POST /campaigns/{id}/tests — Add test to campaign # --------------------------------------------------------------------------- @router.post("/{campaign_id}/tests") # Define function add_test_to_campaign def add_test_to_campaign( # Entry: campaign_id campaign_id: str, # Entry: payload payload: AddTestPayload, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ) -> dict: """Add a test to a campaign with optional ordering and dependency. Args: campaign_id (str): UUID string of the target campaign. payload (AddTestPayload): Test ID plus optional order index, dependency, and phase. db (Session): SQLAlchemy database session. current_user (User): Authenticated red_lead or blue_lead adding the test. Returns: dict: The created campaign-test association record. """ # Open context manager with UnitOfWork(db) as uow: # Assign result = crud_add_test( result = crud_add_test( db, campaign_id, # Keyword argument: test_id test_id=payload.test_id, # Keyword argument: order_index order_index=payload.order_index, # Keyword argument: depends_on depends_on=payload.depends_on, # Keyword argument: phase phase=payload.phase, ) # Call uow.commit() uow.commit() return result # --------------------------------------------------------------------------- # DELETE /campaigns/{id}/tests/{campaign_test_id} — Remove test from campaign # --------------------------------------------------------------------------- @router.delete("/{campaign_id}/tests/{campaign_test_id}") # Define function remove_test_from_campaign def remove_test_from_campaign( # Entry: campaign_id campaign_id: str, # Entry: campaign_test_id campaign_test_id: str, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ) -> dict: """Remove a test from a campaign. Args: campaign_id (str): UUID string of the campaign. campaign_test_id (str): UUID string of the campaign-test association to remove. db (Session): SQLAlchemy database session. current_user (User): Authenticated red_lead or blue_lead removing the test. Returns: dict: Confirmation message with key ``detail``. """ # Open context manager with UnitOfWork(db) as uow: # Call crud_remove_test() crud_remove_test(db, campaign_id, campaign_test_id) # Call uow.commit() uow.commit() # Return {"detail": "Test removed from campaign"} return {"detail": "Test removed from campaign"} # --------------------------------------------------------------------------- # POST /campaigns/{id}/activate — Activate campaign # --------------------------------------------------------------------------- @router.post("/{campaign_id}/activate") # Define function activate_campaign def activate_campaign( # Entry: campaign_id campaign_id: str, force: bool = Query(False, description="Activate even if start_date is in the future"), db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): """Activate a campaign, moving it from draft to active. If the campaign has a start_date in the future and force=False, returns a 409 with a warning so the frontend can show a confirmation modal. If force=True, activates immediately regardless of start_date. """ from fastapi import HTTPException campaign_obj = db.query(Campaign).filter(Campaign.id == campaign_id).first() if campaign_obj and campaign_obj.start_date and not force: now = datetime.utcnow() if campaign_obj.start_date > now: raise HTTPException( status_code=409, detail={ "code": "start_date_in_future", "start_date": campaign_obj.start_date.strftime("%Y-%m-%d"), "message": ( f"This campaign is scheduled to start on " f"{campaign_obj.start_date.strftime('%d %b %Y')}. " f"It will activate automatically on that date. " f"Do you want to activate it now anyway?" ), }, ) with UnitOfWork(db) as uow: # Assign campaign = crud_activate(db, campaign_id) campaign = crud_activate(db, campaign_id) # Call notify_role() notify_role( db, # Keyword argument: role role="red_tech", # Keyword argument: type type="campaign_activated", # Keyword argument: title title="Campaign activated", # Keyword argument: message message=f'Campaign "{campaign.name}" has been activated.', # Keyword argument: entity_type entity_type="campaign", # Keyword argument: entity_id entity_id=campaign.id, ) # Call log_action() log_action( db, # Keyword argument: user_id user_id=current_user.id, # Keyword argument: action action="activate_campaign", # Keyword argument: entity_type entity_type="campaign", # Keyword argument: entity_id entity_id=campaign.id, # Keyword argument: details details={"name": campaign.name}, ) # Call uow.commit() uow.commit() # Reload ORM object attributes from the database db.refresh(campaign) # Create Jira tickets for campaign and tests at activation time (non-fatal). # Campaign ticket is created here if it doesn't already exist (deferred from creation). try: from app.services.jira_service import ( auto_create_campaign_issue, auto_create_test_issue, get_campaign_jira_key, get_test_jira_key, ) campaign_jira_key = get_campaign_jira_key(db, campaign_id) if not campaign_jira_key: campaign_jira_key = auto_create_campaign_issue(db, campaign, current_user) if campaign_jira_key: for ct in campaign.campaign_tests: if ct.test and not get_test_jira_key(db, ct.test.id): auto_create_test_issue( db, ct.test, current_user, parent_ticket_override=campaign_jira_key, campaign_start_date=campaign.start_date, ) db.commit() except Exception: logger.exception( "Jira ticket creation failed during activation of campaign %s", campaign_id, ) return serialize_campaign(db, campaign) # --------------------------------------------------------------------------- # POST /campaigns/{id}/complete — Mark campaign as completed # --------------------------------------------------------------------------- @router.post("/{campaign_id}/complete") # Define function complete_campaign def complete_campaign( # Entry: campaign_id campaign_id: str, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(require_any_role("red_lead", "admin")), ) -> dict: """Mark a campaign as completed. Args: campaign_id (str): UUID string of the campaign to complete. db (Session): SQLAlchemy database session. current_user (User): Authenticated red_lead or admin completing the campaign. Returns: dict: Serialised representation of the completed campaign. """ # Open context manager with UnitOfWork(db) as uow: # Assign campaign = crud_complete(db, campaign_id) campaign = crud_complete(db, campaign_id) # Call log_action() log_action( db, # Keyword argument: user_id user_id=current_user.id, # Keyword argument: action action="complete_campaign", # Keyword argument: entity_type entity_type="campaign", # Keyword argument: entity_id entity_id=campaign.id, # Keyword argument: details details={"name": campaign.name}, ) # Call uow.commit() uow.commit() # Reload ORM object attributes from the database db.refresh(campaign) dispatch_webhook("campaign.completed", {"campaign_id": str(campaign.id), "name": campaign.name}) # Return serialize_campaign(db, campaign) return serialize_campaign(db, campaign) # --------------------------------------------------------------------------- # GET /campaigns/{id}/progress — Campaign progress # --------------------------------------------------------------------------- @router.get("/{campaign_id}/progress") # Define function get_campaign_progress_endpoint def get_campaign_progress_endpoint( # Entry: campaign_id campaign_id: str, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(get_current_user), ) -> dict: """Get progress statistics for a campaign. Args: campaign_id (str): UUID string of the campaign. db (Session): SQLAlchemy database session. current_user (User): Authenticated user making the request. Returns: dict: Progress breakdown including counts by test state and overall percentage. """ # Return crud_get_progress(db, campaign_id) return crud_get_progress(db, campaign_id) # --------------------------------------------------------------------------- # POST /campaigns/from-threat-actor/{actor_id} — Auto-generate campaign # --------------------------------------------------------------------------- class GenerateFromActorPayload(BaseModel): start_date: Optional[str] = None # ISO date YYYY-MM-DD @router.post("/from-threat-actor/{actor_id}", status_code=201) # Define function generate_campaign_from_actor def generate_campaign_from_actor( # Entry: actor_id actor_id: str, payload: GenerateFromActorPayload = GenerateFromActorPayload(), db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ) -> dict: """Auto-generate a campaign from a threat actor's uncovered techniques. Creates tests from the best available templates and orders them by kill chain phase. Args: actor_id (str): UUID string of the threat actor to generate a campaign for. db (Session): SQLAlchemy database session. current_user (User): Authenticated red_lead or blue_lead requesting the generation. Returns: dict: Serialised representation of the newly generated campaign. """ start_date_parsed = ( datetime.fromisoformat(payload.start_date) if payload.start_date else None ) campaign = generate_campaign_from_threat_actor( db, uuid.UUID(actor_id), current_user, start_date=start_date_parsed, ) # Open context manager with UnitOfWork(db) as uow: # Call log_action() log_action( db, # Keyword argument: user_id user_id=current_user.id, # Keyword argument: action action="generate_campaign", # Keyword argument: entity_type entity_type="campaign", # Keyword argument: entity_id entity_id=campaign.id, # Keyword argument: details details={"actor_id": actor_id, "campaign_name": campaign.name}, ) # Call uow.commit() uow.commit() # Return serialize_campaign(db, campaign) return serialize_campaign(db, campaign) # --------------------------------------------------------------------------- # PATCH /campaigns/{id}/schedule — Configure recurrence # --------------------------------------------------------------------------- @router.patch("/{campaign_id}/schedule") # Define function schedule_campaign def schedule_campaign( # Entry: campaign_id campaign_id: str, # Entry: payload payload: SchedulePayload, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ) -> dict: """Configure or update the recurrence schedule for a campaign. Only the campaign creator or admin can change scheduling. Args: campaign_id (str): UUID string of the campaign to schedule. payload (SchedulePayload): Recurrence flag, pattern, and next run timestamp. db (Session): SQLAlchemy database session. current_user (User): Authenticated red_lead or blue_lead (must be owner or admin). Returns: dict: Serialised representation of the campaign with updated schedule fields. """ # Open context manager with UnitOfWork(db) as uow: # Assign campaign = crud_schedule( campaign = crud_schedule( db, campaign_id, # Keyword argument: owner_id owner_id=current_user.id, # Keyword argument: owner_role owner_role=current_user.role, # Keyword argument: is_recurring is_recurring=payload.is_recurring, # Keyword argument: recurrence_pattern recurrence_pattern=payload.recurrence_pattern, # Keyword argument: next_run_at next_run_at=payload.next_run_at, ) # Call log_action() log_action( db, # Keyword argument: user_id user_id=current_user.id, # Keyword argument: action action="schedule_campaign", # Keyword argument: entity_type entity_type="campaign", # Keyword argument: entity_id entity_id=campaign.id, # Keyword argument: details details={ # Literal argument value "is_recurring": campaign.is_recurring, # Literal argument value "recurrence_pattern": campaign.recurrence_pattern, # Literal argument value "next_run_at": campaign.next_run_at.isoformat() if campaign.next_run_at else None, }, ) # Call uow.commit() uow.commit() # Reload ORM object attributes from the database db.refresh(campaign) # Return serialize_campaign(db, campaign) return serialize_campaign(db, campaign) # --------------------------------------------------------------------------- # GET /campaigns/{id}/history — Execution history (child campaigns) # --------------------------------------------------------------------------- @router.get("/{campaign_id}/history") # Define function get_campaign_history def get_campaign_history( # Entry: campaign_id campaign_id: str, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(get_current_user), ) -> list: """List all child campaigns (execution history) of a recurring campaign. Args: campaign_id (str): UUID string of the parent recurring campaign. db (Session): SQLAlchemy database session. current_user (User): Authenticated user making the request. Returns: list: Serialised list of child campaign dicts ordered by creation date. """ # Return crud_get_history(db, campaign_id) return crud_get_history(db, campaign_id) # --------------------------------------------------------------------------- # GET /campaigns/{id}/timing-summary — Aggregated timing across campaign tests # --------------------------------------------------------------------------- def _seconds_between(start: datetime | None, end: datetime | None) -> int: """Return elapsed seconds between two datetimes; 0 if either is None.""" if not start or not end: return 0 diff = (end - start).total_seconds() return max(0, int(diff)) @router.get("/{campaign_id}/timing-summary") def get_campaign_timing_summary( campaign_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Return aggregated Red/Blue timing metrics for all tests in a campaign. For each test we calculate: - red_execution_secs : red_started_at → blue_started_at (minus red_paused_seconds) - blue_queue_secs : blue_started_at → blue_work_started_at (waiting for Blue pick-up) - blue_evaluation_secs: blue_work_started_at → first validation timestamp (minus blue_paused_seconds) - total_secs : sum of the three phases Returns totals + per-test breakdown. """ # Load campaign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() if not campaign: from fastapi import HTTPException raise HTTPException(status_code=404, detail="Campaign not found") # Load all tests for this campaign test_ids = [ ct.test_id for ct in db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).all() ] tests = db.query(Test).filter(Test.id.in_(test_ids)).all() if test_ids else [] breakdown = [] total_red = 0 total_queue = 0 total_blue = 0 for t in tests: # Red execution: from start-execution to submit-to-blue, minus paused time red_secs = max( 0, _seconds_between(t.red_started_at, t.blue_started_at) - (t.red_paused_seconds or 0), ) # Blue queue: from receiving the test to actually starting evaluation queue_secs = _seconds_between(t.blue_started_at, t.blue_work_started_at) # Blue evaluation: from starting evaluation to first validation, minus paused time eval_end = t.red_validated_at or t.blue_validated_at blue_secs = max( 0, _seconds_between(t.blue_work_started_at, eval_end) - (t.blue_paused_seconds or 0), ) total_red += red_secs total_queue += queue_secs total_blue += blue_secs breakdown.append({ "test_id": str(t.id), "test_name": t.name, "state": t.state.value if t.state else None, "red_execution_secs": red_secs, "blue_queue_secs": queue_secs, "blue_evaluation_secs": blue_secs, "total_secs": red_secs + queue_secs + blue_secs, "has_timing": bool(t.red_started_at), }) total_secs = total_red + total_queue + total_blue return { "campaign_id": campaign_id, "campaign_name": campaign.name, "tests_total": len(tests), "tests_with_timing": sum(1 for b in breakdown if b["has_timing"]), "red_execution_secs": total_red, "blue_queue_secs": total_queue, "blue_evaluation_secs": total_blue, "total_secs": total_secs, "breakdown": sorted(breakdown, key=lambda x: -(x["total_secs"])), }