diff --git a/src/warchron/controller/controller.py b/src/warchron/controller/controller.py index 2f54c32..2cbd73c 100644 --- a/src/warchron/controller/controller.py +++ b/src/warchron/controller/controller.py @@ -4,6 +4,11 @@ from pathlib import Path from PyQt6.QtWidgets import QMessageBox, QDialog from warchron.model.model import Model +from warchron.model.exception import ( + DeletionForbidden, + DeletionRequiresConfirmation, + UpdateRequiresConfirmation, +) from warchron.view.view import View from warchron.constants import ItemType, RefreshScope from warchron.controller.dtos import ( @@ -362,38 +367,49 @@ class Controller: self.view.select_tree_item(item_type=item_type, item_id=item_id) def edit_item(self, item_type: str, item_id: str) -> None: - if item_type == ItemType.PLAYER: - self.edit_player(item_id) - self.refresh(RefreshScope.PLAYERS_LIST) - elif item_type == ItemType.WAR: - self.edit_war(item_id) - self.refresh_and_select( - RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=item_id + try: + if item_type == ItemType.PLAYER: + self.edit_player(item_id) + self.refresh(RefreshScope.PLAYERS_LIST) + elif item_type == ItemType.WAR: + self.edit_war(item_id) + self.refresh_and_select( + RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=item_id + ) + elif item_type == ItemType.CAMPAIGN: + self.edit_campaign(item_id) + self.refresh_and_select( + RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=item_id + ) + elif item_type == ItemType.OBJECTIVE: + self.edit_objective(item_id) + self.refresh(RefreshScope.WAR_DETAILS) + elif item_type == ItemType.WAR_PARTICIPANT: + self.edit_war_participant(item_id) + self.refresh(RefreshScope.WAR_DETAILS) + elif item_type == ItemType.SECTOR: + self.edit_sector(item_id) + self.refresh(RefreshScope.CAMPAIGN_DETAILS) + elif item_type == ItemType.CAMPAIGN_PARTICIPANT: + self.edit_campaign_participant(item_id) + self.refresh(RefreshScope.CAMPAIGN_DETAILS) + elif item_type == ItemType.CHOICE: + self.edit_round_choice(item_id) + self.refresh(RefreshScope.ROUND_DETAILS) + elif item_type == ItemType.BATTLE: + self.edit_round_battle(item_id) + self.refresh(RefreshScope.ROUND_DETAILS) + self.is_dirty = True + except UpdateRequiresConfirmation as e: + reply = QMessageBox.question( + self.view, + "Confirm update", + e.message, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) - elif item_type == ItemType.CAMPAIGN: - self.edit_campaign(item_id) - self.refresh_and_select( - RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=item_id - ) - elif item_type == ItemType.OBJECTIVE: - self.edit_objective(item_id) - self.refresh(RefreshScope.WAR_DETAILS) - elif item_type == ItemType.WAR_PARTICIPANT: - self.edit_war_participant(item_id) - self.refresh(RefreshScope.WAR_DETAILS) - elif item_type == ItemType.SECTOR: - self.edit_sector(item_id) - self.refresh(RefreshScope.CAMPAIGN_DETAILS) - elif item_type == ItemType.CAMPAIGN_PARTICIPANT: - self.edit_campaign_participant(item_id) - self.refresh(RefreshScope.CAMPAIGN_DETAILS) - elif item_type == ItemType.CHOICE: - self.edit_round_choice(item_id) - self.refresh(RefreshScope.ROUND_DETAILS) - elif item_type == ItemType.BATTLE: - self.edit_round_battle(item_id) - self.refresh(RefreshScope.ROUND_DETAILS) - self.is_dirty = True + if reply == QMessageBox.StandardButton.Yes: + e.apply_update() + self.refresh(RefreshScope.CAMPAIGN_DETAILS) def delete_item(self, item_type: str, item_id: str) -> None: reply = QMessageBox.question( @@ -404,39 +420,56 @@ class Controller: ) if reply != QMessageBox.StandardButton.Yes: return - if item_type == ItemType.PLAYER: - self.model.remove_player(item_id) - self.refresh(RefreshScope.PLAYERS_LIST) - elif item_type == ItemType.WAR: - self.model.remove_war(item_id) - self.refresh(RefreshScope.WARS_TREE) - elif item_type == ItemType.CAMPAIGN: - war = self.model.get_war_by_campaign(item_id) - war_id = war.id - self.model.remove_campaign(item_id) - self.refresh_and_select( - RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=war_id + try: + if item_type == ItemType.PLAYER: + self.model.remove_player(item_id) + self.refresh(RefreshScope.PLAYERS_LIST) + elif item_type == ItemType.WAR: + self.model.remove_war(item_id) + self.refresh(RefreshScope.WARS_TREE) + elif item_type == ItemType.CAMPAIGN: + war = self.model.get_war_by_campaign(item_id) + war_id = war.id + self.model.remove_campaign(item_id) + self.refresh_and_select( + RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=war_id + ) + elif item_type == ItemType.OBJECTIVE: + self.model.remove_objective(item_id) + self.refresh(RefreshScope.WAR_DETAILS) + elif item_type == ItemType.WAR_PARTICIPANT: + self.model.remove_war_participant(item_id) + self.refresh(RefreshScope.WAR_DETAILS) + elif item_type == ItemType.SECTOR: + self.model.remove_sector(item_id) + self.refresh(RefreshScope.CAMPAIGN_DETAILS) + elif item_type == ItemType.CAMPAIGN_PARTICIPANT: + self.model.remove_campaign_participant(item_id) + self.refresh(RefreshScope.CAMPAIGN_DETAILS) + elif item_type == ItemType.ROUND: + camp = self.model.get_campaign_by_round(item_id) + camp_id = camp.id + self.model.remove_round(item_id) + self.refresh_and_select( + RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=camp_id + ) + self.is_dirty = True + except DeletionForbidden as e: + QMessageBox.warning( + self.view, + "Deletion forbidden", + e.reason, ) - elif item_type == ItemType.OBJECTIVE: - self.model.remove_objective(item_id) - self.refresh(RefreshScope.WAR_DETAILS) - elif item_type == ItemType.WAR_PARTICIPANT: - self.model.remove_war_participant(item_id) - self.refresh(RefreshScope.WAR_DETAILS) - elif item_type == ItemType.SECTOR: - self.model.remove_sector(item_id) - self.refresh(RefreshScope.CAMPAIGN_DETAILS) - elif item_type == ItemType.CAMPAIGN_PARTICIPANT: - self.model.remove_campaign_participant(item_id) - self.refresh(RefreshScope.CAMPAIGN_DETAILS) - elif item_type == ItemType.ROUND: - camp = self.model.get_campaign_by_round(item_id) - camp_id = camp.id - self.model.remove_round(item_id) - self.refresh_and_select( - RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=camp_id + except DeletionRequiresConfirmation as e: + reply = QMessageBox.question( + self.view, + "Confirm deletion", + e.message, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) - self.is_dirty = True + if reply == QMessageBox.StandardButton.Yes: + e.cleanup_action() + self.refresh(RefreshScope.CAMPAIGN_DETAILS) # Player methods diff --git a/src/warchron/model/campaign.py b/src/warchron/model/campaign.py index 7b9d022..25ef22f 100644 --- a/src/warchron/model/campaign.py +++ b/src/warchron/model/campaign.py @@ -2,6 +2,10 @@ from __future__ import annotations from uuid import uuid4 from typing import Any, Dict, List +from warchron.model.exception import ( + DeletionRequiresConfirmation, + UpdateRequiresConfirmation, +) from warchron.model.campaign_participant import CampaignParticipant from warchron.model.sector import Sector from warchron.model.round import Round @@ -94,12 +98,47 @@ class Campaign: part.set_theme(theme) def remove_campaign_participant(self, participant_id: str) -> None: - # TODO manage choices referring to it - # TODO manage battles referring to it - del self.participants[participant_id] + rounds_blocking: list[Round] = [] + for rnd in self.rounds: + if rnd.has_choice_with_participant( + participant_id + ) or rnd.has_battle_with_participant(participant_id): + rounds_blocking.append(rnd) + if not rounds_blocking: + del self.participants[participant_id] + return + + def cleanup() -> None: + for rnd in rounds_blocking: + rnd.clear_participant_references(participant_id) + rnd.remove_choice(participant_id) + del self.participants[participant_id] + + 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, + ) # Sector methods + def has_sector(self, sector_id: str) -> bool: + return sector_id in self.sectors + + def has_sector_with_objective(self, objective_id: str) -> bool: + return any( + sect.major_objective_id == objective_id + or sect.minor_objective_id == objective_id + or sect.influence_objective_id == objective_id + for sect in self.sectors.values() + ) + def add_sector( self, name: str, round_id: str, major_id: str, minor_id: str, influence_id: str ) -> Sector: @@ -118,7 +157,6 @@ class Campaign: def get_all_sectors(self) -> List[Sector]: return list(self.sectors.values()) - # TODO manage choices referring to it (round order!) def update_sector( self, sector_id: str, @@ -130,16 +168,75 @@ class Campaign: influence_id: str, ) -> None: sect = self.get_sector(sector_id) - sect.set_name(name) - sect.set_round(round_id) - sect.set_major(major_id) - sect.set_minor(minor_id) - sect.set_influence(influence_id) + old_round_id = sect.round_id + + def apply_update() -> None: + sect.set_name(name) + sect.set_round(round_id) + sect.set_major(major_id) + sect.set_minor(minor_id) + sect.set_influence(influence_id) + + if old_round_id == round_id: + apply_update() + return + affected_rounds: list[Round] = [] + for rnd in self.rounds: + if rnd.id == old_round_id and ( + rnd.has_choice_with_sector(sector_id) + or rnd.has_battle_with_sector(sector_id) + ): + affected_rounds.append(rnd) + if not affected_rounds: + apply_update() + return + + def cleanup_and_update() -> None: + for rnd in affected_rounds: + rnd.clear_sector_references(sector_id) + rnd.remove_battle(sector_id) + apply_update() + + 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, + ) def remove_sector(self, sector_id: str) -> None: - # TODO manage choices referring to it - # TODO manage battles referring to it - del self.sectors[sector_id] + rounds_blocking: list[Round] = [] + for rnd in self.rounds: + if rnd.has_battle_with_sector(sector_id) or rnd.has_choice_with_sector( + sector_id + ): + rounds_blocking.append(rnd) + if not rounds_blocking: + del self.sectors[sector_id] + return + + def cleanup() -> None: + for rnd in rounds_blocking: + rnd.clear_sector_references(sector_id) + rnd.remove_battle(sector_id) + del self.sectors[sector_id] + + 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, + ) def get_sectors_in_round(self, round_id: str) -> List[Sector]: sectors = [s for s in self.sectors.values() if s.round_id == round_id] diff --git a/src/warchron/model/exception.py b/src/warchron/model/exception.py new file mode 100644 index 0000000..be14e13 --- /dev/null +++ b/src/warchron/model/exception.py @@ -0,0 +1,31 @@ +from typing import Callable + + +class DeletionForbidden(Exception): + def __init__(self, reason: str): + self.reason = reason + super().__init__(reason) + + +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 + super().__init__(message) diff --git a/src/warchron/model/model.py b/src/warchron/model/model.py index 195d76e..1f448b3 100644 --- a/src/warchron/model/model.py +++ b/src/warchron/model/model.py @@ -4,6 +4,7 @@ import json import shutil from datetime import datetime +from warchron.model.exception import DeletionForbidden from warchron.model.player import Player from warchron.model.war import War from warchron.model.war_participant import WarParticipant @@ -84,7 +85,16 @@ class Model: return list(self.players.values()) def remove_player(self, player_id: str) -> None: - # TODO manage war_participants referring to it + wars_using_player: List[str] = [] + for war in self.wars.values(): + if war.has_player(player_id): + wars_using_player.append(war.name) + if wars_using_player: + wars_str = ", ".join(wars_using_player) + raise DeletionForbidden( + f"This player is participating in war(s): {wars_str}.\n" + "Remove it from participants first." + ) del self.players[player_id] # War methods @@ -100,6 +110,7 @@ class Model: def get_war(self, id: str) -> War: return self.wars[id] + # TODO replace multiloops by internal has_* method def get_war_by_campaign(self, campaign_id: str) -> War: for war in self.wars.values(): for camp in war.campaigns: @@ -107,6 +118,7 @@ class Model: return war raise KeyError(f"Campaign {campaign_id} not found in any War") + # TODO replace multiloops by internal has_* method def get_war_by_sector(self, sector_id: str) -> War: for war in self.wars.values(): for camp in war.campaigns: @@ -115,6 +127,7 @@ class Model: return war raise KeyError(f"Sector {sector_id} not found in any War") + # TODO replace multiloops by internal has_* method def get_war_by_round(self, round_id: str) -> War: for war in self.wars.values(): for camp in war.campaigns: @@ -123,6 +136,7 @@ class Model: return war raise KeyError(f"Round {round_id} not found in any War") + # TODO replace multiloops by internal has_* method def get_war_by_objective(self, objective_id: str) -> War: for war in self.wars.values(): for obj in war.objectives.values(): @@ -160,6 +174,7 @@ class Model: war = self.get_war(war_id) return war.add_objective(name, description) + # TODO replace multiloops by internal has_* method def get_objective(self, objective_id: str) -> Objective: for war in self.wars.values(): for obj in war.objectives.values(): @@ -191,6 +206,7 @@ class Model: war = self.get_war(war_id) return war.add_war_participant(player_id, faction) + # TODO replace multiloops by internal has_* method def get_war_participant(self, participant_id: str) -> WarParticipant: for war in self.wars.values(): for part in war.participants.values(): @@ -219,6 +235,7 @@ class Model: war = self.get_war(war_id) return war.add_campaign(name, month) + # TODO replace multiloops by internal has_* method def get_campaign(self, campaign_id: str) -> Campaign: for war in self.wars.values(): for campaign in war.campaigns: @@ -269,6 +286,7 @@ class Model: camp = self.get_campaign(campaign_id) return camp.add_sector(name, round_id, major_id, minor_id, influence_id) + # TODO replace multiloops by internal has_* method def get_sector(self, sector_id: str) -> Sector: for war in self.wars.values(): for camp in war.campaigns: @@ -318,6 +336,7 @@ class Model: war_part = war.get_war_participant(participant_id) return self.players[war_part.player_id].name + # TODO replace multiloops by internal has_* method def get_campaign_participant(self, participant_id: str) -> CampaignParticipant: for war in self.wars.values(): for camp in war.campaigns: @@ -352,6 +371,7 @@ class Model: war = self.get_war_by_campaign(campaign_id) return war.add_round(campaign_id) + # TODO replace multiloops by internal has_* method def get_round(self, round_id: str) -> Round: for war in self.wars.values(): for camp in war.campaigns: diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index 62cd5c7..2460d4c 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -43,6 +43,18 @@ class Round: def get_choice(self, participant_id: str) -> Choice | None: return self.choices.get(participant_id) + def has_choice_with_sector(self, sector_id: str) -> bool: + return any( + choice.priority_sector_id == sector_id + or choice.secondary_sector_id == sector_id + for choice in self.choices.values() + ) + + def has_choice_with_participant(self, participant_id: str) -> bool: + return any( + choice.participant_id == participant_id for choice in self.choices.values() + ) + def create_choice(self, participant_id: str) -> Choice: if participant_id not in self.choices: choice = Choice( @@ -66,6 +78,13 @@ class Round: choice.set_secondary(secondary_sector_id) choice.set_comment(comment) + def clear_sector_references(self, sector_id: str) -> None: + for choice in self.choices.values(): + if choice.priority_sector_id == sector_id: + choice.priority_sector_id = None + if choice.secondary_sector_id == sector_id: + choice.secondary_sector_id = None + def remove_choice(self, participant_id: str) -> None: del self.choices[participant_id] @@ -74,6 +93,15 @@ class Round: def get_battle(self, sector_id: str) -> Battle | None: return self.battles.get(sector_id) + def has_battle_with_sector(self, sector_id: str) -> bool: + return any(bat.sector_id == sector_id for bat in self.battles.values()) + + def has_battle_with_participant(self, participant_id: str) -> bool: + return any( + bat.player_1_id == participant_id or bat.player_2_id == participant_id + for bat in self.battles.values() + ) + def create_battle(self, sector_id: str) -> Battle: if sector_id not in self.battles: battle = Battle(sector_id=sector_id, player_1_id=None, player_2_id=None) @@ -99,5 +127,14 @@ class Round: bat.set_victory_condition(victory_condition) bat.set_comment(comment) + def clear_participant_references(self, participant_id: str) -> None: + for battle in self.battles.values(): + if battle.player_1_id == participant_id: + battle.player_1_id = None + if battle.player_2_id == participant_id: + battle.player_2_id = None + if battle.winner_id == participant_id: + battle.winner_id = None + def remove_battle(self, sector_id: str) -> None: del self.battles[sector_id] diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index 390be66..892edeb 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -3,6 +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.war_participant import WarParticipant from warchron.model.objective import Objective from warchron.model.campaign_participant import CampaignParticipant @@ -82,7 +83,16 @@ class War: obj.set_description(description) def remove_objective(self, objective_id: str) -> None: - # TODO manage sectors referring to it + 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( + f"This objective is used in campaign(s) sector(s): {camps_str}.\n" + "Remove it from campaign(s) first." + ) del self.objectives[objective_id] # War participant methods @@ -109,14 +119,23 @@ class War: def get_all_war_participants(self) -> List[WarParticipant]: return list(self.participants.values()) - def update_war_participant(self, player_id: str, *, faction: str) -> None: - part = self.get_war_participant(player_id) + def update_war_participant(self, participant_id: str, *, faction: str) -> None: + part = self.get_war_participant(participant_id) # Can't change referred Model.players part.set_faction(faction) - def remove_war_participant(self, player_id: str) -> None: - # TODO manage campaign_participants referring to it - del self.participants[player_id] + def remove_war_participant(self, participant_id: str) -> None: + 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( + f"This war participant is used in campaign(s): {camps_str}.\n" + "Remove it from campaign(s) first." + ) + del self.participants[participant_id] # Campaign methods @@ -139,6 +158,7 @@ class War: return camp raise KeyError(f"Campaign {campaign_id} not found in War {self.id}") + # TODO replace multiloops by internal has_* method def get_campaign_by_round(self, round_id: str) -> Campaign | None: for camp in self.campaigns: for rnd in camp.rounds: @@ -146,6 +166,7 @@ class War: return camp return None + # TODO replace multiloops by internal has_* method def get_campaign_by_sector(self, sector_id: str) -> Campaign: for camp in self.campaigns: for sect in camp.sectors.values(): @@ -185,6 +206,7 @@ class War: camp = self.get_campaign(campaign_id) return camp.add_sector(name, round_id, major_id, minor_id, influence_id) + # TODO replace multiloops by internal has_* method def get_sector(self, sector_id: str) -> Sector: for camp in self.campaigns: for sect in camp.sectors.values(): @@ -232,6 +254,7 @@ class War: camp = self.get_campaign(campaign_id) return camp.add_campaign_participant(participant_id, leader, theme) + # TODO replace multiloops by internal has_* method def get_campaign_participant(self, participant_id: str) -> CampaignParticipant: for camp in self.campaigns: for part in camp.participants.values():