diff --git a/src/warchron/controller/app_controller.py b/src/warchron/controller/app_controller.py index e4356e8..57c1958 100644 --- a/src/warchron/controller/app_controller.py +++ b/src/warchron/controller/app_controller.py @@ -3,11 +3,7 @@ from pathlib import Path from PyQt6.QtWidgets import QMessageBox from warchron.model.model import Model -from warchron.model.exception import ( - DeletionForbidden, - DeletionRequiresConfirmation, - UpdateRequiresConfirmation, -) +from warchron.model.exception import DomainError, RequiresConfirmation from warchron.view.view import View from warchron.constants import ItemType, RefreshScope from warchron.controller.navigation_controller import NavigationController @@ -199,15 +195,15 @@ class AppController: self.rounds.edit_round_battle(item_id) self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.is_dirty = True - except UpdateRequiresConfirmation as e: + except RequiresConfirmation as e: reply = QMessageBox.question( self.view, "Confirm update", - e.message, + str(e), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: - e.apply_update() + e.action() self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) def delete_item(self, item_type: str, item_id: str) -> None: @@ -253,19 +249,19 @@ class AppController: RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=camp_id ) self.is_dirty = True - except DeletionForbidden as e: + except DomainError as e: QMessageBox.warning( self.view, "Deletion forbidden", - e.reason, + str(e), ) - except DeletionRequiresConfirmation as e: + except RequiresConfirmation as e: reply = QMessageBox.question( self.view, "Confirm deletion", - e.message, + str(e), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: - e.cleanup_action() + e.action() self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) diff --git a/src/warchron/model/campaign.py b/src/warchron/model/campaign.py index fb16c97..2b8148a 100644 --- a/src/warchron/model/campaign.py +++ b/src/warchron/model/campaign.py @@ -2,10 +2,7 @@ from __future__ import annotations from uuid import uuid4 from typing import Any, Dict, List -from warchron.model.exception import ( - DeletionRequiresConfirmation, - UpdateRequiresConfirmation, -) +from warchron.model.exception import ForbiddenOperation, RequiresConfirmation from warchron.model.campaign_participant import CampaignParticipant from warchron.model.sector import Sector from warchron.model.round import Round @@ -78,6 +75,8 @@ class Campaign: def add_campaign_participant( self, war_participant_id: str, leader: str, theme: str ) -> CampaignParticipant: + if self.is_over: + raise ForbiddenOperation("Can't add participant in a closed campaign.") if self.has_war_participant(war_participant_id): raise ValueError("Player already registered in this campaign") participant = CampaignParticipant( @@ -98,12 +97,16 @@ class Campaign: def update_campaign_participant( self, participant_id: str, *, leader: str, theme: str ) -> None: + if self.is_over: + raise ForbiddenOperation("Can't update participant in a closed campaign.") part = self.get_campaign_participant(participant_id) # Can't change referred War.participant part.set_leader(leader) part.set_theme(theme) def remove_campaign_participant(self, participant_id: str) -> None: + if self.is_over: + raise ForbiddenOperation("Can't remove participant in a closed campaign.") rounds_blocking: list[Round] = [] for rnd in self.rounds: if rnd.has_choice_with_participant( @@ -123,13 +126,11 @@ class Campaign: rounds_str = ", ".join( str(self.get_round_index(rnd.id)) for rnd in rounds_blocking ) - raise DeletionRequiresConfirmation( - message=( - f"This participant is used in round(s): {rounds_str}.\n" - "Related choices and battles will be cleared.\n" - "Do you want to continue?" - ), - cleanup_action=cleanup, + raise RequiresConfirmation( + f"This participant is used in round(s): {rounds_str}.\n" + "Related choices and battles will be cleared.\n" + "Do you want to continue?", + action=cleanup, ) # Sector methods @@ -155,6 +156,8 @@ class Campaign: mission: str | None, description: str | None, ) -> Sector: + if self.is_over: + raise ForbiddenOperation("Can't add sector in a closed campaign.") sect = Sector( name, round_id, major_id, minor_id, influence_id, mission, description ) @@ -184,6 +187,8 @@ class Campaign: mission: str | None, description: str | None, ) -> None: + if self.is_over: + raise ForbiddenOperation("Can't update sector in a closed campaign.") sect = self.get_sector(sector_id) old_round_id = sect.round_id @@ -219,16 +224,16 @@ class Campaign: rounds_str = ", ".join( str(self.get_round_index(rnd.id)) for rnd in affected_rounds ) - raise UpdateRequiresConfirmation( - message=( - f"Changing the round of this sector will affect round(s): {rounds_str}." - "\nRelated battles and choices will be cleared.\n" - "Do you want to continue?" - ), - apply_update=cleanup_and_update, + raise RequiresConfirmation( + f"Changing the round of this sector will affect round(s): {rounds_str}." + "\nRelated battles and choices will be cleared.\n" + "Do you want to continue?", + action=cleanup_and_update, ) def remove_sector(self, sector_id: str) -> None: + if self.is_over: + raise ForbiddenOperation("Can't remove sector in a closed campaign.") rounds_blocking: list[Round] = [] for rnd in self.rounds: if rnd.has_battle_with_sector(sector_id) or rnd.has_choice_with_sector( @@ -248,13 +253,11 @@ class Campaign: rounds_str = ", ".join( str(self.get_round_index(rnd.id)) for rnd in rounds_blocking ) - raise DeletionRequiresConfirmation( - message=( - f"This sector is used in round(s): {rounds_str}.\n" - "Battles and choices using this sector will be cleared.\n" - "Do you want to continue?" - ), - cleanup_action=cleanup, + raise RequiresConfirmation( + f"This sector is used in round(s): {rounds_str}.\n" + "Battles and choices using this sector will be cleared.\n" + "Do you want to continue?", + action=cleanup, ) def get_sectors_in_round(self, round_id: str) -> List[Sector]: @@ -266,6 +269,15 @@ class Campaign: def has_round(self, round_id: str) -> bool: return any(r.id == round_id for r in self.rounds) + def has_finished_round(self) -> bool: + return any(r.is_over for r in self.rounds) + + def has_finished_battle(self) -> bool: + return any(r.has_finished_battle() for r in self.rounds) + + def all_rounds_finished(self) -> bool: + return all(r.is_over for r in self.rounds) + def get_round(self, round_id: str) -> Round: for rnd in self.rounds: if rnd.id == round_id: @@ -276,12 +288,21 @@ class Campaign: return list(self.rounds) def add_round(self) -> Round: + if self.is_over: + raise ForbiddenOperation("Can't add round in a closed campaign.") round = Round() self.rounds.append(round) return round def remove_round(self, round_id: str) -> None: + if self.is_over: + raise ForbiddenOperation("Can't remove round in a closed campaign.") rnd = next((r for r in self.rounds if r.id == round_id), None) + if rnd: + if rnd.is_over: + raise ForbiddenOperation("Can't remove closed round.") + if rnd.has_finished_battle(): + raise ForbiddenOperation("Can't remove round with finished battle(s).") for sect in self.sectors.values(): if sect.round_id == round_id: sect.round_id = None @@ -321,9 +342,6 @@ class Campaign: # Battle methods - def all_rounds_finished(self) -> bool: - return all(r.is_over for r in self.rounds) - def create_battle(self, round_id: str, sector_id: str) -> Battle: rnd = self.get_round(round_id) return rnd.create_battle(sector_id) diff --git a/src/warchron/model/exception.py b/src/warchron/model/exception.py index be14e13..0c6c38e 100644 --- a/src/warchron/model/exception.py +++ b/src/warchron/model/exception.py @@ -1,31 +1,25 @@ from typing import Callable -class DeletionForbidden(Exception): - def __init__(self, reason: str): - self.reason = reason - super().__init__(reason) +class DomainError(Exception): + """Base class for all domain rule violations.""" + + pass -class DeletionRequiresConfirmation(Exception): - def __init__( - self, - message: str, - *, - cleanup_action: Callable[[], None], - ): - self.message = message - self.cleanup_action = cleanup_action - super().__init__(message) - - -class UpdateRequiresConfirmation(Exception): - def __init__( - self, - message: str, - *, - apply_update: Callable[[], None], - ): - self.message = message - self.apply_update = apply_update +class ForbiddenOperation(DomainError): + """Generic 'you can't do this' rule.""" + + pass + + +class DomainDecision(Exception): + """Base class for domain actions requiring user decision.""" + + pass + + +class RequiresConfirmation(DomainDecision): + def __init__(self, message: str, action: Callable[[], None]): super().__init__(message) + self.action = action diff --git a/src/warchron/model/model.py b/src/warchron/model/model.py index ca7cd10..b5eeaf3 100644 --- a/src/warchron/model/model.py +++ b/src/warchron/model/model.py @@ -4,7 +4,7 @@ import json import shutil from datetime import datetime -from warchron.model.exception import DeletionForbidden +from warchron.model.exception import ForbiddenOperation from warchron.model.player import Player from warchron.model.war import War from warchron.model.war_participant import WarParticipant @@ -91,7 +91,7 @@ class Model: wars_using_player.append(war.name) if wars_using_player: wars_str = ", ".join(wars_using_player) - raise DeletionForbidden( + raise ForbiddenOperation( f"This player is participating in war(s): {wars_str}.\n" "Remove it from participants first." ) @@ -178,6 +178,21 @@ class Model: return list(self.wars.values()) def remove_war(self, war_id: str) -> None: + war = self.wars[war_id] + if war: + if war.is_over: + raise ForbiddenOperation("Can't remove closed war.") + if war.has_finished_campaign(): + raise ForbiddenOperation("Can't remove war with finished campaign(s).") + if war.has_finished_round(): + raise ForbiddenOperation( + "Can't remove war with finished round(s) in campaign(s)." + ) + if war.has_finished_battle(): + raise ForbiddenOperation( + "Can't remove war with finished battle(s) in round(s) " + "in campaign(s)." + ) del self.wars[war_id] # Objective methods diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index cb15f20..08d77d6 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -2,6 +2,7 @@ from __future__ import annotations from uuid import uuid4 from typing import Any, Dict +from warchron.model.exception import ForbiddenOperation from warchron.model.choice import Choice from warchron.model.battle import Battle @@ -58,6 +59,8 @@ class Round: ) def create_choice(self, participant_id: str) -> Choice: + if self.is_over: + raise ForbiddenOperation("Can't create choice in a closed round.") if participant_id not in self.choices: choice = Choice( participant_id=participant_id, @@ -74,6 +77,8 @@ class Round: secondary_sector_id: str | None, comment: str | None, ) -> None: + if self.is_over: + raise ForbiddenOperation("Can't update choice in a closed round.") choice = self.get_choice(participant_id) if choice: choice.set_priority(priority_sector_id) @@ -88,6 +93,8 @@ class Round: choice.secondary_sector_id = None def remove_choice(self, participant_id: str) -> None: + if self.is_over: + raise ForbiddenOperation("Can't remove choice in a closed round.") del self.choices[participant_id] # Battle methods @@ -104,12 +111,17 @@ class Round: for bat in self.battles.values() ) + def has_finished_battle(self) -> bool: + return any(b.is_finished() for b in self.battles.values()) + def all_battles_finished(self) -> bool: return all( b.winner_id is not None or b.is_draw() for b in self.battles.values() ) def create_battle(self, sector_id: str) -> Battle: + if self.is_over: + raise ForbiddenOperation("Can't create battle in a closed round.") if sector_id not in self.battles: battle = Battle(sector_id=sector_id, player_1_id=None, player_2_id=None) self.battles[sector_id] = battle @@ -125,6 +137,8 @@ class Round: victory_condition: str | None, comment: str | None, ) -> None: + if self.is_over: + raise ForbiddenOperation("Can't update battle in a closed round.") bat = self.get_battle(sector_id) if bat: bat.set_player_1(player_1_id) @@ -144,4 +158,9 @@ class Round: battle.winner_id = None def remove_battle(self, sector_id: str) -> None: + if self.is_over: + raise ForbiddenOperation("Can't remove battle in a closed round.") + bat = self.battles[sector_id] + if bat and bat.is_finished(): + raise ForbiddenOperation("Can't remove finished battle.") del self.battles[sector_id] diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index a2fa125..cacf5a8 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -3,7 +3,7 @@ from uuid import uuid4 from datetime import datetime from typing import Any, Dict, List -from warchron.model.exception import DeletionForbidden +from warchron.model.exception import ForbiddenOperation from warchron.model.war_participant import WarParticipant from warchron.model.objective import Objective from warchron.model.campaign_participant import CampaignParticipant @@ -87,6 +87,8 @@ class War: # Objective methods def add_objective(self, name: str, description: str | None) -> Objective: + if self.is_over: + raise ForbiddenOperation("Can't add objective in a closed war.") obj = Objective(name, description) self.objectives[obj.id] = obj return obj @@ -106,18 +108,22 @@ class War: def update_objective( self, objective_id: str, *, name: str, description: str | None ) -> None: + if self.is_over: + raise ForbiddenOperation("Can't update objective in a closed war.") obj = self.get_objective(objective_id) obj.set_name(name) obj.set_description(description) def remove_objective(self, objective_id: str) -> None: + if self.is_over: + raise ForbiddenOperation("Can't remove objective in a closed war.") camp_using_obj: List[str] = [] for camp in self.campaigns: if camp.has_sector_with_objective(objective_id): camp_using_obj.append(camp.name) if camp_using_obj: camps_str = ", ".join(camp_using_obj) - raise DeletionForbidden( + raise ForbiddenOperation( f"This objective is used in campaign(s) sector(s): {camps_str}.\n" "Remove it from campaign(s) first." ) @@ -135,6 +141,8 @@ class War: return any(part.player_id == player_id for part in self.participants.values()) def add_war_participant(self, player_id: str, faction: str) -> WarParticipant: + if self.is_over: + raise ForbiddenOperation("Can't add participant in a closed war.") if self.has_player(player_id): raise ValueError("Player already registered in this war") participant = WarParticipant(player_id=player_id, faction=faction) @@ -148,18 +156,22 @@ class War: return list(self.participants.values()) def update_war_participant(self, participant_id: str, *, faction: str) -> None: + if self.is_over: + raise ForbiddenOperation("Can't update participant in a closed war.") part = self.get_war_participant(participant_id) # Can't change referred Model.players part.set_faction(faction) def remove_war_participant(self, participant_id: str) -> None: + if self.is_over: + raise ForbiddenOperation("Can't remove participant in a closed war.") camp_using_part: List[str] = [] for camp in self.campaigns: if camp.has_war_participant(participant_id): camp_using_part.append(camp.name) if camp_using_part: camps_str = ", ".join(camp_using_part) - raise DeletionForbidden( + raise ForbiddenOperation( f"This war participant is used in campaign(s): {camps_str}.\n" "Remove it from campaign(s) first." ) @@ -173,10 +185,21 @@ class War: def get_default_campaign_values(self) -> Dict[str, Any]: return {"month": datetime.now().month} + def has_finished_campaign(self) -> bool: + return any(c.is_over for c in self.campaigns) + + def has_finished_round(self) -> bool: + return any(c.has_finished_round() for c in self.campaigns) + + def has_finished_battle(self) -> bool: + return any(c.has_finished_battle() for c in self.campaigns) + def all_campaigns_finished(self) -> bool: return all(c.is_over for c in self.campaigns) def add_campaign(self, name: str, month: int | None = None) -> Campaign: + if self.is_over: + raise ForbiddenOperation("Can't add campaign in a closed war.") if month is None: month = self.get_default_campaign_values()["month"] campaign = Campaign(name, month) @@ -212,6 +235,8 @@ class War: raise KeyError(f"Participant {participant_id} not found in any Campaign") def update_campaign(self, campaign_id: str, *, name: str, month: int) -> None: + if self.is_over: + raise ForbiddenOperation("Can't update campaign in a closed war.") camp = self.get_campaign(campaign_id) camp.set_name(name) camp.set_month(month) @@ -220,7 +245,20 @@ class War: return list(self.campaigns) def remove_campaign(self, campaign_id: str) -> None: + if self.is_over: + raise ForbiddenOperation("Can't remove campaign in a closed war.") camp = self.get_campaign(campaign_id) + if camp: + if camp.is_over: + raise ForbiddenOperation("Can't remove closed campaign.") + if camp.has_finished_round(): + raise ForbiddenOperation( + "Can't remove campaign with finished round(s)." + ) + if camp.has_finished_battle(): + raise ForbiddenOperation( + "Can't remove campaign with finished battle(s) in round(s)." + ) self.campaigns.remove(camp) # Sector methods