"""Campaign domain entity with lifecycle validation. Pure domain logic — no framework imports. """ # Enable future language features for compatibility from __future__ import annotations # Import enum import enum # Import uuid import uuid # Import dataclass, field from dataclasses from dataclasses import dataclass, field # Import TYPE_CHECKING from typing from typing import TYPE_CHECKING # Import BusinessRuleViolation, InvalidStateTransition from app.domain.errors from app.domain.errors import BusinessRuleViolation, InvalidStateTransition # Check: TYPE_CHECKING if TYPE_CHECKING: # Import Campaign as CampaignORM from app.models.campaign from app.models.campaign import Campaign as CampaignORM # Define class CampaignStatus class CampaignStatus(str, enum.Enum): """Lifecycle states for a campaign.""" # Assign draft = "draft" draft = "draft" # Assign active = "active" active = "active" # Assign completed = "completed" completed = "completed" # Assign archived = "archived" archived = "archived" # Define class CampaignType class CampaignType(str, enum.Enum): """Classification of the campaign's testing methodology.""" # Assign custom = "custom" custom = "custom" # Assign apt_emulation = "apt_emulation" apt_emulation = "apt_emulation" # Assign kill_chain = "kill_chain" kill_chain = "kill_chain" # Assign compliance = "compliance" compliance = "compliance" # Assign VALID_TRANSITIONS = { VALID_TRANSITIONS: dict[CampaignStatus, list[CampaignStatus]] = { CampaignStatus.draft: [CampaignStatus.active], CampaignStatus.active: [CampaignStatus.completed], CampaignStatus.completed: [CampaignStatus.archived], CampaignStatus.archived: [], } # Apply the @dataclass decorator @dataclass # Define class CampaignEntity class CampaignEntity: """Pure domain representation of a security testing campaign. Owns all lifecycle state-machine logic for campaign activation, completion, and archival. """ # name: str name: str # Assign type = CampaignType.custom type: CampaignType = CampaignType.custom # Assign status = CampaignStatus.draft status: CampaignStatus = CampaignStatus.draft # Assign id = None id: uuid.UUID | None = None # Assign description = None description: str | None = None # Assign threat_actor_id = None threat_actor_id: uuid.UUID | None = None # Assign created_by = None created_by: uuid.UUID | None = None # Assign target_platform = None target_platform: str | None = None # Assign tags = field(default_factory=list) tags: list[str] = field(default_factory=list) # Assign test_count = 0 test_count: int = 0 # Define function can_transition_to def can_transition_to(self, target: CampaignStatus) -> bool: """Check whether transitioning from the current status to *target* is valid. Args: target (CampaignStatus): The desired next status. Returns: bool: True if the transition is allowed, False otherwise. """ # Return target in VALID_TRANSITIONS.get(self.status, []) return target in VALID_TRANSITIONS.get(self.status, []) # Define function activate def activate(self) -> None: """Transition the campaign from ``draft`` to ``active``. Returns: None """ # Check: not self.can_transition_to(CampaignStatus.active) if not self.can_transition_to(CampaignStatus.active): # Raise InvalidStateTransition raise InvalidStateTransition( self.status.value, CampaignStatus.active.value, [s.value for s in VALID_TRANSITIONS[self.status]], ) # Check: self.test_count == 0 if self.test_count == 0: # Raise BusinessRuleViolation raise BusinessRuleViolation( # Literal argument value "Campaign must have at least one test to activate" ) # Assign self.status = CampaignStatus.active self.status = CampaignStatus.active # Define function complete def complete(self) -> None: """Transition the campaign from ``active`` to ``completed``. Returns: None """ # Check: not self.can_transition_to(CampaignStatus.completed) if not self.can_transition_to(CampaignStatus.completed): # Raise InvalidStateTransition raise InvalidStateTransition( self.status.value, CampaignStatus.completed.value, [s.value for s in VALID_TRANSITIONS[self.status]], ) # Assign self.status = CampaignStatus.completed self.status = CampaignStatus.completed # Define function archive def archive(self) -> None: """Transition the campaign from ``completed`` to ``archived``. Returns: None """ # Check: not self.can_transition_to(CampaignStatus.archived) if not self.can_transition_to(CampaignStatus.archived): # Raise InvalidStateTransition raise InvalidStateTransition( self.status.value, CampaignStatus.archived.value, [s.value for s in VALID_TRANSITIONS[self.status]], ) # Assign self.status = CampaignStatus.archived self.status = CampaignStatus.archived # Define function ensure_modifiable def ensure_modifiable(self) -> None: """Raise BusinessRuleViolation if the campaign is not in a modifiable state. Returns: None """ # Check: self.status not in (CampaignStatus.draft, CampaignStatus.active) if self.status not in (CampaignStatus.draft, CampaignStatus.active): # Raise BusinessRuleViolation raise BusinessRuleViolation( f"Cannot modify campaign in '{self.status.value}' state" ) # Apply the @classmethod decorator @classmethod # Define function from_orm def from_orm(cls, orm: CampaignORM) -> CampaignEntity: """Build a CampaignEntity from a SQLAlchemy Campaign model. Args: orm (CampaignORM): The SQLAlchemy Campaign ORM model instance. Returns: CampaignEntity: A fully populated domain entity reflecting the ORM state. """ # Assign test_count = len(getattr(orm, "campaign_tests", None) or []) test_count = len(getattr(orm, "campaign_tests", None) or []) # Return cls( return cls( # Keyword argument: id id=orm.id, # Keyword argument: name name=orm.name, # Keyword argument: type type=CampaignType(orm.type) if orm.type else CampaignType.custom, # Keyword argument: status status=CampaignStatus(orm.status) if orm.status else CampaignStatus.draft, # Keyword argument: description description=orm.description, # Keyword argument: threat_actor_id threat_actor_id=orm.threat_actor_id, # Keyword argument: created_by created_by=orm.created_by, # Keyword argument: target_platform target_platform=orm.target_platform, # Keyword argument: tags tags=orm.tags or [], # Keyword argument: test_count test_count=test_count, )