"""Campaign CRUD service — list, create, update, and business logic. Framework-agnostic; uses domain exceptions from app.domain.errors. The router is responsible for HTTP concerns, auth, audit logging, and commit. """ # Import uuid import uuid # Import datetime from datetime from datetime import datetime # Import Optional from typing from typing import Optional # Import Session from sqlalchemy.orm from sqlalchemy.orm import Session # Import from app.domain.errors from app.domain.errors import ( BusinessRuleViolation, EntityNotFoundError, PermissionViolation, ) # Import Campaign, CampaignModificationRequest, CampaignTest from app.models.campaign from app.models.campaign import Campaign, CampaignModificationRequest, CampaignTest # Import AuditLog from app.models.audit from app.models.audit import AuditLog # Import Technique from app.models.technique from app.models.technique import Technique # Import Test from app.models.test from app.models.test import Test # Import calculate_next_run from app.services.campaign_scheduler_service from app.services.campaign_scheduler_service import calculate_next_run from app.services.status_service import recalculate_technique_status # Import from app.services.campaign_service from app.services.campaign_service import ( TACTIC_TO_PHASE, get_campaign_progress, validate_no_circular_dependency, ) # Import escape_like from app.utils from app.utils import escape_like # ── Serialization helpers ──────────────────────────────────────────────── def serialize_campaign(db: Session, campaign: Campaign) -> dict: """Serialize a campaign with its tests and progress.""" # Assign progress = get_campaign_progress(db, campaign.id) progress = get_campaign_progress(db, campaign.id) # Assign campaign_tests = ( campaign_tests = ( db.query(CampaignTest) # Chain .filter() call .filter(CampaignTest.campaign_id == campaign.id) # Chain .order_by() call .order_by(CampaignTest.order_index) # Chain .all() call .all() ) # Assign tests = [] tests = [] # Iterate over campaign_tests for ct in campaign_tests: # Assign test = ct.test test = ct.test # Assign technique = db.query(Technique).filter(Technique.id == test.technique_id).first... technique = db.query(Technique).filter(Technique.id == test.technique_id).first() if test else None # Call tests.append() tests.append({ # Literal argument value "id": str(ct.id), # Literal argument value "test_id": str(ct.test_id), # Literal argument value "order_index": ct.order_index, # Literal argument value "depends_on": str(ct.depends_on) if ct.depends_on else None, # Literal argument value "phase": ct.phase, # Literal argument value "test_name": test.name if test else None, # Literal argument value "test_state": test.state.value if test and test.state else None, # Literal argument value "test_result": test.result.value if test and test.result else None, # Literal argument value "technique_mitre_id": technique.mitre_id if technique else None, # Literal argument value "technique_name": technique.name if technique else None, # Literal argument value "platform": test.platform if test else None, }) # Assign actor = campaign.threat_actor actor = campaign.threat_actor # Return { return { # Literal argument value "id": str(campaign.id), # Literal argument value "name": campaign.name, # Literal argument value "description": campaign.description, # Literal argument value "type": campaign.type, # Literal argument value "status": campaign.status, # Literal argument value "threat_actor_id": str(campaign.threat_actor_id) if campaign.threat_actor_id else None, # Literal argument value "threat_actor_name": actor.name if actor else None, # Literal argument value "created_by": str(campaign.created_by) if campaign.created_by else None, "start_date": campaign.start_date.isoformat() if campaign.start_date else None, "scheduled_at": campaign.scheduled_at.isoformat() if campaign.scheduled_at else None, # Literal argument value "completed_at": campaign.completed_at.isoformat() if campaign.completed_at else None, "approved_by": str(campaign.approved_by) if campaign.approved_by else None, "approved_at": campaign.approved_at.isoformat() if campaign.approved_at else None, "rejection_reason": campaign.rejection_reason, # Literal argument value "target_platform": campaign.target_platform, # Literal argument value "tags": campaign.tags or [], # Literal argument value "created_at": campaign.created_at.isoformat() if campaign.created_at else None, # Literal argument value "is_recurring": campaign.is_recurring or False, # 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, # Literal argument value "last_run_at": campaign.last_run_at.isoformat() if campaign.last_run_at else None, # Literal argument value "parent_campaign_id": str(campaign.parent_campaign_id) if campaign.parent_campaign_id else None, # Literal argument value "tests": tests, # Literal argument value "progress": progress, } # Define function serialize_campaign_summary def serialize_campaign_summary(db: Session, campaign: Campaign) -> dict: """Lightweight campaign serialization for list views.""" # Assign progress = get_campaign_progress(db, campaign.id) progress = get_campaign_progress(db, campaign.id) # Assign actor = campaign.threat_actor actor = campaign.threat_actor # Return { return { # Literal argument value "id": str(campaign.id), # Literal argument value "name": campaign.name, # Literal argument value "description": campaign.description, # Literal argument value "type": campaign.type, # Literal argument value "status": campaign.status, # Literal argument value "threat_actor_id": str(campaign.threat_actor_id) if campaign.threat_actor_id else None, # Literal argument value "threat_actor_name": actor.name if actor else None, "start_date": campaign.start_date.isoformat() if campaign.start_date else None, "target_platform": campaign.target_platform, # Literal argument value "tags": campaign.tags or [], # Literal argument value "created_at": campaign.created_at.isoformat() if campaign.created_at else None, # Literal argument value "is_recurring": campaign.is_recurring or False, # 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, # Literal argument value "last_run_at": campaign.last_run_at.isoformat() if campaign.last_run_at else None, # Literal argument value "test_count": progress["total"], # Literal argument value "completion_pct": progress["completion_pct"], } # ── CRUD operations ─────────────────────────────────────────────────────── def list_campaigns( # Entry: db db: Session, *, # Entry: type type: Optional[str] = None, # Entry: status status: Optional[str] = None, # Entry: threat_actor_id threat_actor_id: Optional[str] = None, # Entry: search search: Optional[str] = None, # Entry: offset offset: int = 0, # Entry: limit limit: int = 50, ) -> dict: """Return a paginated list of campaigns with optional filters.""" # Assign query = db.query(Campaign) query = db.query(Campaign) # Check: type if type: # Assign query = query.filter(Campaign.type == type) query = query.filter(Campaign.type == type) # Check: status if status: # Assign query = query.filter(Campaign.status == status) query = query.filter(Campaign.status == status) # Check: threat_actor_id if threat_actor_id: # Assign query = query.filter(Campaign.threat_actor_id == threat_actor_id) query = query.filter(Campaign.threat_actor_id == threat_actor_id) # Check: search if search: # Assign pattern = f"%{escape_like(search)}%" pattern = f"%{escape_like(search)}%" # Assign query = query.filter(Campaign.name.ilike(pattern) | Campaign.description.il... query = query.filter(Campaign.name.ilike(pattern) | Campaign.description.ilike(pattern)) # Assign total = query.count() total = query.count() # Assign campaigns = query.order_by(Campaign.created_at.desc()).offset(offset).limit(lim... campaigns = query.order_by(Campaign.created_at.desc()).offset(offset).limit(limit).all() # Return { return { # Literal argument value "total": total, # Literal argument value "offset": offset, # Literal argument value "limit": limit, # Literal argument value "items": [serialize_campaign_summary(db, c) for c in campaigns], } # Define function create_campaign def create_campaign( # Entry: db db: Session, *, # Entry: creator_id creator_id: uuid.UUID, # Entry: name name: str, # Entry: description description: Optional[str] = None, # Entry: type type: str = "custom", # Entry: threat_actor_id threat_actor_id: Optional[str] = None, # Entry: target_platform target_platform: Optional[str] = None, # Entry: tags tags: Optional[list[str]] = None, # Entry: scheduled_at scheduled_at: Optional[str] = None, # A manager's own campaign is auto-approved on creation instead of # going through the draft -> submit -> pending_approval queue (a # manager is the same role that would otherwise approve it). auto_approve: bool = False, start_date: Optional[str] = None, approver_id: Optional[uuid.UUID] = None, ) -> dict: """Create a new campaign. Does not commit; caller commits. Raises BusinessRuleViolation if auto_approve is set without a start_date. """ if auto_approve and not start_date: raise BusinessRuleViolation("start_date is required to auto-approve a campaign") # Assign campaign = Campaign( campaign = Campaign( # Keyword argument: name name=name, # Keyword argument: description description=description, # Keyword argument: type type=type, # Keyword argument: threat_actor_id threat_actor_id=uuid.UUID(threat_actor_id) if threat_actor_id else None, # Keyword argument: target_platform target_platform=target_platform, # Keyword argument: tags tags=tags or [], # Keyword argument: created_by created_by=creator_id, # Keyword argument: scheduled_at scheduled_at=datetime.fromisoformat(scheduled_at) if scheduled_at else None, ) if auto_approve: campaign.start_date = datetime.fromisoformat(start_date) campaign.status = "active" campaign.approved_by = approver_id campaign.approved_at = datetime.utcnow() # Stage new record(s) for database insertion db.add(campaign) # Flush changes to DB without committing the transaction db.flush() # Return serialize_campaign(db, campaign) return serialize_campaign(db, campaign) def submit_campaign_for_approval( db: Session, campaign_id: str, *, submitter_id: uuid.UUID, submitter_role: str, ) -> Campaign: """Submit a draft campaign into the manager's approval queue. Raises EntityNotFoundError, PermissionViolation, BusinessRuleViolation. Does not commit; caller commits. """ campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first() if not campaign: raise EntityNotFoundError("Campaign", campaign_id) if str(campaign.created_by) != str(submitter_id) and submitter_role != "admin": raise PermissionViolation("Only the creator or admin can submit this campaign for approval") if campaign.status != "draft": raise BusinessRuleViolation("Only draft campaigns can be submitted for approval") test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).count() if test_count == 0: raise BusinessRuleViolation("Campaign must have at least one test to submit for approval") campaign.status = "pending_approval" db.flush() return campaign def approve_campaign( db: Session, campaign_id: str, *, approver_id: uuid.UUID, start_date: str, ) -> Campaign: """Approve a pending campaign: fix its start date and activate it. Also allows approving a previously-rejected campaign directly (status ``draft`` with a ``rejection_reason`` set) — a manager can edit it via ``update_campaign()`` and then approve it themselves, without needing the original lead to resubmit it through the normal queue. Raises EntityNotFoundError, BusinessRuleViolation. Does not commit; caller commits. """ campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first() if not campaign: raise EntityNotFoundError("Campaign", campaign_id) is_previously_rejected_draft = campaign.status == "draft" and campaign.rejection_reason if campaign.status != "pending_approval" and not is_previously_rejected_draft: raise BusinessRuleViolation("Only campaigns pending approval (or previously rejected) can be approved") if not start_date: raise BusinessRuleViolation("start_date is required to approve a campaign") campaign.start_date = datetime.fromisoformat(start_date) campaign.status = "active" campaign.approved_by = approver_id campaign.approved_at = datetime.utcnow() campaign.rejection_reason = None db.flush() return campaign def reject_campaign( db: Session, campaign_id: str, *, rejecter_id: uuid.UUID, reason: str, ) -> Campaign: """Reject a pending campaign, returning it to draft with a mandatory reason. Raises EntityNotFoundError, BusinessRuleViolation. Does not commit; caller commits. """ campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first() if not campaign: raise EntityNotFoundError("Campaign", campaign_id) if campaign.status != "pending_approval": raise BusinessRuleViolation("Only campaigns pending approval can be rejected") if not reason or not reason.strip(): raise BusinessRuleViolation("A rejection reason is required") campaign.status = "draft" campaign.rejection_reason = reason.strip() campaign.approved_by = None campaign.approved_at = None db.flush() return campaign # Define function get_campaign_detail def get_campaign_detail(db: Session, campaign_id: str) -> dict: """Get detailed campaign info including tests and progress. Raises EntityNotFoundError if campaign not found. """ # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() # Check: not campaign if not campaign: # Raise EntityNotFoundError raise EntityNotFoundError("Campaign", campaign_id) # Return serialize_campaign(db, campaign) return serialize_campaign(db, campaign) # Define function update_campaign def update_campaign( # Entry: db db: Session, # Entry: campaign_id campaign_id: str, *, # Entry: updater_id updater_id: uuid.UUID, # Entry: updater_role updater_role: str, **fields: object, ) -> dict: """Update a campaign. Only allowed in draft or active state. Raises EntityNotFoundError, BusinessRuleViolation, or PermissionViolation. Does not commit; caller commits. """ # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first() # Check: not campaign if not campaign: # Raise EntityNotFoundError raise EntityNotFoundError("Campaign", campaign_id) # Check: campaign.status not in ("draft", "active") if campaign.status not in ("draft", "active"): # Raise BusinessRuleViolation raise BusinessRuleViolation("Can only update draft or active campaigns") # A manager may edit a campaign they (or another manager) previously # rejected — this is what lets them fix it up and self-approve it, # instead of bouncing it back to the original lead to resubmit. is_manager_editing_own_rejection = ( updater_role == "manager" and campaign.status == "draft" and campaign.rejection_reason ) is_owner_or_admin = str(campaign.created_by) == str(updater_id) or updater_role == "admin" if not is_owner_or_admin and not is_manager_editing_own_rejection: # Raise PermissionViolation raise PermissionViolation("Only the creator, admin, or a manager reviewing a rejected campaign can update this campaign") # Check: "scheduled_at" in fields and fields["scheduled_at"] if "scheduled_at" in fields and fields["scheduled_at"]: # Assign fields["scheduled_at"] = datetime.fromisoformat(fields["scheduled_at"]) fields["scheduled_at"] = datetime.fromisoformat(fields["scheduled_at"]) # Iterate over fields.items() for field, value in fields.items(): # Call setattr() setattr(campaign, field, value) # Flush changes to DB without committing the transaction db.flush() # Return serialize_campaign(db, campaign) return serialize_campaign(db, campaign) # Define function add_test_to_campaign def add_test_to_campaign( # Entry: db db: Session, # Entry: campaign_id campaign_id: str, *, # Entry: test_id test_id: str, # Entry: order_index order_index: Optional[int] = None, # Entry: depends_on depends_on: Optional[str] = None, # Entry: phase phase: Optional[str] = None, # Entry: allowed_statuses allowed_statuses: tuple[str, ...] = ("draft",), ) -> dict: """Add a test to a campaign with optional ordering and dependency. Raises EntityNotFoundError for missing campaign or test. Raises BusinessRuleViolation for invalid state or circular dependency. Does not commit; caller commits. """ # Assign campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first() campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first() # Check: not campaign if not campaign: # Raise EntityNotFoundError raise EntityNotFoundError("Campaign", campaign_id) # Check: campaign.status not in allowed_statuses if campaign.status not in allowed_statuses: # Raise BusinessRuleViolation raise BusinessRuleViolation( f"Can only add tests to a campaign in one of: {', '.join(allowed_statuses)}" ) # Assign test = db.query(Test).filter(Test.id == uuid.UUID(test_id)).first() test = db.query(Test).filter(Test.id == uuid.UUID(test_id)).first() # Check: not test if not test: # Raise EntityNotFoundError raise EntityNotFoundError("Test", test_id) # Check: order_index is not None if order_index is not None: # Assign final_order_index = order_index final_order_index = order_index # Fallback: handle remaining cases else: # Assign max_order = ( max_order = ( db.query(CampaignTest.order_index) # Chain .filter() call .filter(CampaignTest.campaign_id == uuid.UUID(campaign_id)) # Chain .order_by() call .order_by(CampaignTest.order_index.desc()) # Chain .first() call .first() ) # Assign final_order_index = (max_order[0] + 1) if max_order else 0 final_order_index = (max_order[0] + 1) if max_order else 0 # Assign depends_on_uuid = uuid.UUID(depends_on) if depends_on else None depends_on_uuid = uuid.UUID(depends_on) if depends_on else None # Assign ct_id = uuid.uuid4() ct_id = uuid.uuid4() # Check: depends_on_uuid if depends_on_uuid: # Call validate_no_circular_dependency() validate_no_circular_dependency(db, uuid.UUID(campaign_id), ct_id, depends_on_uuid) # Check: not phase and test.technique_id if not phase and test.technique_id: # Assign technique = db.query(Technique).filter(Technique.id == test.technique_id).first() technique = db.query(Technique).filter(Technique.id == test.technique_id).first() # Check: technique and technique.tactic if technique and technique.tactic: # Assign phase = TACTIC_TO_PHASE.get(technique.tactic, None) phase = TACTIC_TO_PHASE.get(technique.tactic, None) # Assign campaign_test = CampaignTest( campaign_test = CampaignTest( # Keyword argument: id id=ct_id, # Keyword argument: campaign_id campaign_id=uuid.UUID(campaign_id), # Keyword argument: test_id test_id=uuid.UUID(test_id), # Keyword argument: order_index order_index=final_order_index, # Keyword argument: depends_on depends_on=depends_on_uuid, # Keyword argument: phase phase=phase, ) # Stage new record(s) for database insertion db.add(campaign_test) # Flush changes to DB without committing the transaction db.flush() # Return { return { # Literal argument value "id": str(campaign_test.id), # Literal argument value "campaign_id": str(campaign_test.campaign_id), # Literal argument value "test_id": str(campaign_test.test_id), # Literal argument value "order_index": campaign_test.order_index, # Literal argument value "depends_on": str(campaign_test.depends_on) if campaign_test.depends_on else None, # Literal argument value "phase": campaign_test.phase, } # Define function remove_test_from_campaign def remove_test_from_campaign( db: Session, campaign_id: str, campaign_test_id: str, *, # Entry: allowed_statuses allowed_statuses: tuple[str, ...] = ("draft",), ) -> None: """Remove a test from a campaign. Raises EntityNotFoundError for missing campaign or campaign test. Raises BusinessRuleViolation for invalid state. Does not commit; caller commits. """ # Assign campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first() campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first() # Check: not campaign if not campaign: # Raise EntityNotFoundError raise EntityNotFoundError("Campaign", campaign_id) # Check: campaign.status not in allowed_statuses if campaign.status not in allowed_statuses: # Raise BusinessRuleViolation raise BusinessRuleViolation( f"Can only modify tests on a campaign in one of: {', '.join(allowed_statuses)}" ) # Assign ct = ( ct = ( db.query(CampaignTest) # Chain .filter() call .filter( CampaignTest.id == uuid.UUID(campaign_test_id), CampaignTest.campaign_id == uuid.UUID(campaign_id), ) # Chain .first() call .first() ) # Check: not ct if not ct: # Raise EntityNotFoundError raise EntityNotFoundError("CampaignTest", campaign_test_id) # Assign dep_id = uuid.UUID(campaign_test_id) dep_id = uuid.UUID(campaign_test_id) # Assign dependents = db.query(CampaignTest).filter(CampaignTest.depends_on == dep_id).all() dependents = db.query(CampaignTest).filter(CampaignTest.depends_on == dep_id).all() # Iterate over dependents for dep in dependents: # Assign dep.depends_on = None dep.depends_on = None # Keep a reference to the underlying test before deleting the join record test_id = ct.test_id technique_id = None test_obj = db.query(Test).filter(Test.id == test_id).first() if test_obj: technique_id = test_obj.technique_id db.delete(ct) # Flush changes to DB without committing the transaction db.flush() # Also delete the actual test record (it was created for this campaign) if test_obj: db.delete(test_obj) db.flush() # Recalculate technique status_global so coverage metrics stay consistent if technique_id: technique = db.query(Technique).filter(Technique.id == technique_id).first() if technique: recalculate_technique_status(db, technique) db.flush() # Define function activate_campaign def activate_campaign(db: Session, campaign_id: str) -> Campaign: """Activate a campaign, moving it from draft to active. Raises EntityNotFoundError, BusinessRuleViolation. Does not commit; caller commits. """ # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first() # Check: not campaign if not campaign: # Raise EntityNotFoundError raise EntityNotFoundError("Campaign", campaign_id) # Check: campaign.status != "draft" if campaign.status != "draft": # Raise BusinessRuleViolation raise BusinessRuleViolation("Only draft campaigns can be activated") # Assign test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign_... test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).count() # Check: test_count == 0 if test_count == 0: # Raise BusinessRuleViolation raise BusinessRuleViolation("Campaign must have at least one test to activate") # Assign campaign.status = "active" campaign.status = "active" # Flush changes to DB without committing the transaction db.flush() # Return campaign return campaign # Define function complete_campaign def complete_campaign(db: Session, campaign_id: str) -> Campaign: """Mark a campaign as completed. Raises EntityNotFoundError, BusinessRuleViolation. Does not commit; caller commits. """ # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() # Check: not campaign if not campaign: # Raise EntityNotFoundError raise EntityNotFoundError("Campaign", campaign_id) # Check: campaign.status != "active" if campaign.status != "active": # Raise BusinessRuleViolation raise BusinessRuleViolation("Only active campaigns can be completed") # Assign campaign.status = "completed" campaign.status = "completed" # Assign campaign.completed_at = datetime.utcnow() campaign.completed_at = datetime.utcnow() # Flush changes to DB without committing the transaction db.flush() # Return campaign return campaign # Define function get_campaign_progress_data def get_campaign_progress_data(db: Session, campaign_id: str) -> dict: """Get progress statistics for a campaign. Raises EntityNotFoundError if campaign not found. """ # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() # Check: not campaign if not campaign: # Raise EntityNotFoundError raise EntityNotFoundError("Campaign", campaign_id) # Assign progress = get_campaign_progress(db, uuid.UUID(campaign_id)) progress = get_campaign_progress(db, uuid.UUID(campaign_id)) # Return { return { # Literal argument value "campaign_id": str(campaign.id), # Literal argument value "campaign_name": campaign.name, **progress, } # Define function schedule_campaign def schedule_campaign( # Entry: db db: Session, # Entry: campaign_id campaign_id: str, *, # Entry: owner_id owner_id: uuid.UUID, # Entry: owner_role owner_role: str, # Entry: is_recurring is_recurring: bool, # Entry: recurrence_pattern recurrence_pattern: Optional[str] = None, # Entry: next_run_at next_run_at: Optional[str] = None, ) -> Campaign: """Configure or update the recurrence schedule for a campaign. Raises EntityNotFoundError, PermissionViolation, BusinessRuleViolation. Does not commit; caller commits. """ # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() # Check: not campaign if not campaign: # Raise EntityNotFoundError raise EntityNotFoundError("Campaign", campaign_id) # Check: str(campaign.created_by) != str(owner_id) and owner_role != "admin" if str(campaign.created_by) != str(owner_id) and owner_role != "admin": # Raise PermissionViolation raise PermissionViolation("Only the creator or admin can configure scheduling") # Assign campaign.is_recurring = is_recurring campaign.is_recurring = is_recurring # Check: is_recurring if is_recurring: # Check: recurrence_pattern not in ("weekly", "monthly", "quarterly") if recurrence_pattern not in ("weekly", "monthly", "quarterly"): # Raise BusinessRuleViolation raise BusinessRuleViolation( # Literal argument value "recurrence_pattern must be 'weekly', 'monthly', or 'quarterly'" ) # Assign campaign.recurrence_pattern = recurrence_pattern campaign.recurrence_pattern = recurrence_pattern # Check: next_run_at if next_run_at: # Assign campaign.next_run_at = datetime.fromisoformat( campaign.next_run_at = datetime.fromisoformat( next_run_at.replace("Z", "+00:00").replace("+00:00", "") ) # Alternative: not campaign.next_run_at elif not campaign.next_run_at: # Assign campaign.next_run_at = calculate_next_run(datetime.utcnow(), recurrence_pattern) campaign.next_run_at = calculate_next_run(datetime.utcnow(), recurrence_pattern) # Fallback: handle remaining cases else: # Assign campaign.recurrence_pattern = None campaign.recurrence_pattern = None # Assign campaign.next_run_at = None campaign.next_run_at = None # Flush changes to DB without committing the transaction db.flush() # Return campaign return campaign def delete_campaign( db: Session, campaign_id: str, *, deleter_id: uuid.UUID, deleter_role: str, delete_tests: bool = False, ) -> None: """Delete a campaign. Only draft campaigns can be deleted unless the caller is admin. If delete_tests=True, the associated Test objects are also deleted. Does not commit; caller commits. """ campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() if not campaign: raise EntityNotFoundError("Campaign", campaign_id) if campaign.status != "draft" and deleter_role != "admin": raise BusinessRuleViolation("Only draft campaigns can be deleted") if str(campaign.created_by) != str(deleter_id) and deleter_role != "admin": raise PermissionViolation("Only the creator or admin can delete this campaign") # Collect test IDs before removing associations campaign_tests = ( db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign_id).all() ) test_ids = [ct.test_id for ct in campaign_tests] # Remove CampaignTest join rows (clear depends_on refs first to avoid FK cycles) for ct in campaign_tests: ct.depends_on = None db.flush() for ct in campaign_tests: db.delete(ct) db.flush() # Optionally delete the associated tests if delete_tests: affected_technique_ids: set = set() for test_id in test_ids: test = db.query(Test).filter(Test.id == test_id).first() if test: if test.technique_id: affected_technique_ids.add(test.technique_id) db.delete(test) db.flush() # Recalculate status_global for every affected technique so the # coverage metrics stay consistent after test deletion. for tech_id in affected_technique_ids: technique = db.query(Technique).filter(Technique.id == tech_id).first() if technique: recalculate_technique_status(db, technique) db.flush() # Null-out parent_campaign_id on child campaigns to avoid FK violation db.query(Campaign).filter(Campaign.parent_campaign_id == campaign.id).update( {"parent_campaign_id": None} ) db.flush() db.delete(campaign) db.flush() def get_campaign_history(db: Session, campaign_id: str) -> dict: """List all child campaigns (execution history) of a recurring campaign. Raises EntityNotFoundError if campaign not found. """ # Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() # Check: not campaign if not campaign: # Raise EntityNotFoundError raise EntityNotFoundError("Campaign", campaign_id) # Assign campaign_uuid = uuid.UUID(campaign_id) campaign_uuid = uuid.UUID(campaign_id) # Assign children = ( children = ( db.query(Campaign) # Chain .filter() call .filter(Campaign.parent_campaign_id == campaign_uuid) # Chain .order_by() call .order_by(Campaign.created_at.desc()) # Chain .all() call .all() ) # Return { return { # Literal argument value "campaign_id": str(campaign.id), # Literal argument value "campaign_name": campaign.name, # Literal argument value "items": [ { # Literal argument value "id": str(child.id), # Literal argument value "name": child.name, # Literal argument value "status": child.status, # Literal argument value "test_count": db.query(CampaignTest).filter(CampaignTest.campaign_id == child.id).count(), # Literal argument value "completion_pct": get_campaign_progress(db, child.id)["completion_pct"], # Literal argument value "created_at": child.created_at.isoformat() if child.created_at else None, # Literal argument value "completed_at": child.completed_at.isoformat() if child.completed_at else None, } for child in children ], } # ── Campaign Modification Requests ────────────────────────────────────── def serialize_modification_request(db: Session, request: CampaignModificationRequest) -> dict: """Serialize a modification request, including the test's display name.""" test = request.test return { "id": str(request.id), "campaign_id": str(request.campaign_id), "campaign_name": request.campaign.name if request.campaign else None, "requested_by": str(request.requested_by) if request.requested_by else None, "action": request.action, "test_id": str(request.test_id) if request.test_id else None, "test_name": test.name if test else None, "order_index": request.order_index, "phase": request.phase, "justification": request.justification, "status": request.status, "reviewed_by": str(request.reviewed_by) if request.reviewed_by else None, "reviewed_at": request.reviewed_at.isoformat() if request.reviewed_at else None, "review_notes": request.review_notes, "created_at": request.created_at.isoformat() if request.created_at else None, } def create_modification_request( db: Session, campaign_id: str, *, requester_id: uuid.UUID, action: str, test_id: str, justification: str, order_index: Optional[int] = None, phase: Optional[str] = None, ) -> CampaignModificationRequest: """File a request to add/remove a test on an active campaign. Raises EntityNotFoundError, BusinessRuleViolation. Does not commit; caller commits. """ campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first() if not campaign: raise EntityNotFoundError("Campaign", campaign_id) if campaign.status != "active": raise BusinessRuleViolation("Modification requests can only be created for active campaigns") if action not in ("add_test", "remove_test"): raise BusinessRuleViolation("action must be 'add_test' or 'remove_test'") if not justification or not justification.strip(): raise BusinessRuleViolation("A justification is required") test = db.query(Test).filter(Test.id == uuid.UUID(test_id)).first() if not test: raise EntityNotFoundError("Test", test_id) if action == "remove_test": ct = ( db.query(CampaignTest) .filter(CampaignTest.campaign_id == campaign.id, CampaignTest.test_id == test.id) .first() ) if not ct: raise EntityNotFoundError("CampaignTest", test_id) request = CampaignModificationRequest( campaign_id=campaign.id, requested_by=requester_id, action=action, test_id=test.id, order_index=order_index, phase=phase, justification=justification.strip(), status="pending", ) db.add(request) db.flush() return request def approve_modification_request( db: Session, request_id: str, *, reviewer_id: uuid.UUID, ) -> CampaignModificationRequest: """Approve a pending modification request and apply the underlying test change. Raises EntityNotFoundError, BusinessRuleViolation. Does not commit; caller commits. """ request = ( db.query(CampaignModificationRequest) .filter(CampaignModificationRequest.id == uuid.UUID(request_id)) .first() ) if not request: raise EntityNotFoundError("CampaignModificationRequest", request_id) if request.status != "pending": raise BusinessRuleViolation("Only pending modification requests can be approved") # A "remove_test" approval deletes the underlying Test row below, which # nulls this row's test_id (test_id uses ON DELETE SET NULL specifically # so this audit record survives that delete instead of being cascaded # away with it — see CampaignModificationRequest.test_id). request.status = "approved" request.reviewed_by = reviewer_id request.reviewed_at = datetime.utcnow() db.flush() if request.action == "add_test": add_test_to_campaign( db, str(request.campaign_id), test_id=str(request.test_id), order_index=request.order_index, phase=request.phase, allowed_statuses=("active",), ) else: ct = ( db.query(CampaignTest) .filter( CampaignTest.campaign_id == request.campaign_id, CampaignTest.test_id == request.test_id, ) .first() ) if not ct: raise EntityNotFoundError("CampaignTest", str(request.test_id)) remove_test_from_campaign( db, str(request.campaign_id), str(ct.id), allowed_statuses=("active",), ) db.flush() return request def reject_modification_request( db: Session, request_id: str, *, reviewer_id: uuid.UUID, review_notes: str, ) -> CampaignModificationRequest: """Reject a pending modification request. No change is applied to the campaign. Raises EntityNotFoundError, BusinessRuleViolation. Does not commit; caller commits. """ request = ( db.query(CampaignModificationRequest) .filter(CampaignModificationRequest.id == uuid.UUID(request_id)) .first() ) if not request: raise EntityNotFoundError("CampaignModificationRequest", request_id) if request.status != "pending": raise BusinessRuleViolation("Only pending modification requests can be rejected") if not review_notes or not review_notes.strip(): raise BusinessRuleViolation("Review notes are required to reject a modification request") request.status = "rejected" request.reviewed_by = reviewer_id request.reviewed_at = datetime.utcnow() request.review_notes = review_notes.strip() db.flush() return request def list_modification_requests( db: Session, *, campaign_id: Optional[str] = None, status: Optional[str] = None, ) -> list[dict]: """List modification requests, optionally filtered by campaign and/or status.""" query = db.query(CampaignModificationRequest) if campaign_id: query = query.filter(CampaignModificationRequest.campaign_id == uuid.UUID(campaign_id)) if status: query = query.filter(CampaignModificationRequest.status == status) requests = query.order_by(CampaignModificationRequest.created_at.desc()).all() return [serialize_modification_request(db, r) for r in requests] def get_campaign_timeline(db: Session, campaign_id: str) -> list[dict]: """Return the chronological audit-log history for a campaign. Raises EntityNotFoundError if the campaign does not exist. """ campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first() if not campaign: raise EntityNotFoundError("Campaign", campaign_id) logs = ( db.query(AuditLog) .filter(AuditLog.entity_type == "campaign", AuditLog.entity_id == str(campaign_id)) .order_by(AuditLog.timestamp.asc()) .all() ) return [ { "id": str(log.id), "action": log.action, "user_id": str(log.user_id) if log.user_id else None, "timestamp": log.timestamp.isoformat() if log.timestamp else None, "details": log.details or {}, } for log in logs ]