"""Campaign scheduler service — recurring campaign execution. Handles checking which recurring campaigns are due, cloning them with fresh tests, and computing the next run date. """ # Import logging import logging # Import datetime, timedelta from datetime from datetime import datetime, timedelta # Import Session from sqlalchemy.orm from sqlalchemy.orm import Session # Import Campaign, CampaignTest from app.models.campaign from app.models.campaign import Campaign, CampaignTest # Import TestState from app.models.enums from app.models.enums import TestState # Import Test from app.models.test from app.models.test import Test # Import User from app.models.user from app.models.user import User # Import log_action from app.services.audit_service from app.services.audit_service import log_action # Import create_notification from app.services.notification_service from app.services.notification_service import create_notification # Assign logger = logging.getLogger(__name__) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Next-run calculation # --------------------------------------------------------------------------- def calculate_next_run(current_date: datetime, pattern: str) -> datetime: """Compute the next run date from *current_date* and a recurrence pattern. Supported patterns: - ``weekly`` : +7 days - ``monthly`` : +30 days - ``quarterly``: +90 days """ # Assign offsets = { offsets = { # Literal argument value "weekly": timedelta(days=7), # Literal argument value "monthly": timedelta(days=30), # Literal argument value "quarterly": timedelta(days=90), } # Return current_date + offsets.get(pattern, timedelta(days=30)) return current_date + offsets.get(pattern, timedelta(days=30)) # --------------------------------------------------------------------------- # Clone a campaign # --------------------------------------------------------------------------- def _clone_campaign(db: Session, original: Campaign) -> Campaign: """Create a new child campaign from a recurring template. 1. Clone the campaign with a date-stamped name. 2. For each ``CampaignTest`` in the original, create a new ``Test`` with the same base data (in ``draft`` state) and link it. 3. Queue the new campaign for manager approval — recurrence only automates *creating* the run, not skipping the same approval gate every other campaign goes through. A manager still picks the real start date (and that's what triggers Jira ticket creation) via the normal /approve endpoint. """ now = datetime.utcnow() run_label = now.strftime("%Y-%m-%d") child = Campaign( name=f"{original.name} (Run {run_label})", description=original.description, type=original.type, threat_actor_id=original.threat_actor_id, status="pending_approval", created_by=original.created_by, target_platform=original.target_platform, tags=original.tags or [], parent_campaign_id=original.id, ) # Stage new record(s) for database insertion db.add(child) # Flush changes to DB without committing the transaction db.flush() # get child.id # Clone each campaign_test with a fresh Test original_cts = ( db.query(CampaignTest) # Chain .filter() call .filter(CampaignTest.campaign_id == original.id) # Chain .order_by() call .order_by(CampaignTest.order_index) # Chain .all() call .all() ) # Iterate over original_cts for ct in original_cts: # Assign src_test = ct.test src_test = ct.test # Check: not src_test if not src_test: # Skip to the next loop iteration continue # Assign new_test = Test( new_test = Test( # Keyword argument: technique_id technique_id=src_test.technique_id, # Keyword argument: name name=src_test.name, # Keyword argument: description description=src_test.description, # Keyword argument: platform platform=src_test.platform, # Keyword argument: procedure_text procedure_text=src_test.procedure_text, # Keyword argument: tool_used tool_used=src_test.tool_used, # Keyword argument: created_by created_by=original.created_by, # Keyword argument: state state=TestState.draft, ) # Stage new record(s) for database insertion db.add(new_test) # Flush changes to DB without committing the transaction db.flush() # get new_test.id # Assign new_ct = CampaignTest( new_ct = CampaignTest( # Keyword argument: campaign_id campaign_id=child.id, # Keyword argument: test_id test_id=new_test.id, # Keyword argument: order_index order_index=ct.order_index, # Keyword argument: phase phase=ct.phase, # depends_on is not copied — would need ID remapping ) # Stage new record(s) for database insertion db.add(new_ct) # Flush changes to DB without committing the transaction db.flush() # Return child return child # --------------------------------------------------------------------------- # Check and run recurring campaigns (daily job) # --------------------------------------------------------------------------- def check_and_run_recurring_campaigns(db: Session) -> int: """Check all recurring campaigns and clone any that are due. Returns the number of campaigns spawned. """ # Assign now = datetime.utcnow() now = datetime.utcnow() # Assign due_campaigns = ( due_campaigns = ( db.query(Campaign) # Chain .filter() call .filter( Campaign.is_recurring == True, # noqa: E712 Campaign.next_run_at <= now, ) # Chain .all() call .all() ) # Assign spawned = 0 spawned = 0 # Iterate over due_campaigns for campaign in due_campaigns: # Attempt the following; catch errors below try: # Assign child = _clone_campaign(db, campaign) child = _clone_campaign(db, campaign) # Update the original's scheduling fields campaign.last_run_at = now # Assign campaign.next_run_at = calculate_next_run(now, campaign.recurrence_pattern or "monthly") campaign.next_run_at = calculate_next_run(now, campaign.recurrence_pattern or "monthly") # Commit all pending changes to the database db.commit() # Reload ORM object attributes from the database db.refresh(child) # Audit log_action( db, # Keyword argument: user_id user_id=campaign.created_by, # Keyword argument: action action="recurring_campaign_run", # Keyword argument: entity_type entity_type="campaign", # Keyword argument: entity_id entity_id=child.id, # Keyword argument: details details={ # Literal argument value "parent_campaign_id": str(campaign.id), # Literal argument value "child_campaign_name": child.name, # Literal argument value "pattern": campaign.recurrence_pattern, }, ) # Commit all pending changes to the database db.commit() # Notify the creator — the run happened, but it still needs a # manager's approval before it goes anywhere. if campaign.created_by: create_notification( db, user_id=campaign.created_by, type="recurring_campaign_run", title="Recurring campaign created — awaiting approval", message=( f'Campaign "{child.name}" was automatically created ' f'from recurring template "{campaign.name}" and is now ' f'queued for manager approval.' ), entity_type="campaign", entity_id=child.id, ) # Notify managers — same approval gate as any other campaign, # recurrence only automates spawning the run, not skipping review. managers = db.query(User).filter(User.role == "manager", User.is_active == True).all() # noqa: E712 for user in managers: create_notification( db, user_id=user.id, type="campaign_pending_approval", title="Recurring campaign needs approval", message=f'Campaign "{child.name}" was auto-created from a recurring template and needs your approval.', entity_type="campaign", entity_id=child.id, ) # Assign spawned = 1 spawned += 1 # Log info: "Spawned child campaign '%s' from parent '%s'", ch logger.info("Spawned child campaign '%s' from parent '%s'", child.name, campaign.name) # Handle Exception except Exception: # Roll back all uncommitted changes db.rollback() # Log exception: "Failed to run recurring campaign '%s'", campaign. logger.exception("Failed to run recurring campaign '%s'", campaign.name) # Return spawned return spawned # --------------------------------------------------------------------------- # Catch up on due campaigns' Jira tickets (periodic job) # --------------------------------------------------------------------------- def sync_due_campaign_jira_tickets(db: Session) -> int: """Create Jira tickets for active campaigns whose start_date has arrived. A campaign approved with a future ``start_date`` intentionally skips Jira ticket creation at approval time (see ``app.routers.campaigns._create_jira_tickets_for_campaign``). This job is what actually creates those tickets once that date arrives — it finds active campaigns lacking a Jira link and, for any whose start_date is now due, calls the same idempotent creation helper. Returns the number of campaigns processed. """ from app.models.jira_link import JiraLink, JiraLinkEntityType from app.services.jira_service import ensure_campaign_jira_tickets now = datetime.utcnow() linked_campaign_ids = { row[0] for row in db.query(JiraLink.entity_id).filter( JiraLink.entity_type == JiraLinkEntityType.campaign ).all() } due_campaigns = ( db.query(Campaign) .filter( Campaign.status == "active", Campaign.start_date.isnot(None), Campaign.start_date <= now, ) .all() ) processed = 0 for campaign in due_campaigns: if campaign.id in linked_campaign_ids: continue actor = db.query(User).filter(User.id == campaign.approved_by).first() if not actor: actor = db.query(User).filter(User.id == campaign.created_by).first() if not actor: logger.warning( "Cannot sync Jira tickets for campaign %s: no valid actor found", campaign.id, ) continue ensure_campaign_jira_tickets(db, campaign, actor) processed += 1 return processed