From 42eb625ef6a82ed8caf8f46ec2f6c42f136eee80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Fri, 13 Feb 2026 08:01:26 +0100 Subject: [PATCH 1/4] fix select round after closing --- src/warchron/controller/round_controller.py | 4 +++- src/warchron/view/view.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index fcca17e..d5a09e3 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -141,7 +141,9 @@ class RoundController: return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) - self.app.navigation.refresh(RefreshScope.WARS_TREE) + self.app.navigation.refresh_and_select( + RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=round_id + ) # Choice methods diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index caf73ae..d4aed89 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -238,6 +238,10 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): def display_wars_tree(self, wars: List[WarDTO]) -> None: tree = self.warsTree + try: + tree.currentItemChanged.disconnect() + except TypeError: + pass tree.clear() tree.setColumnCount(1) tree.setHeaderLabels(["Wars"]) From 88bd28e9497b34361eaeed669d943ef24c8cc251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Fri, 13 Feb 2026 11:38:59 +0100 Subject: [PATCH 2/4] add forbidden exceptions on closed elements --- src/warchron/controller/app_controller.py | 22 +++---- src/warchron/model/campaign.py | 74 ++++++++++++++--------- src/warchron/model/exception.py | 44 ++++++-------- src/warchron/model/model.py | 19 +++++- src/warchron/model/round.py | 19 ++++++ src/warchron/model/war.py | 44 +++++++++++++- 6 files changed, 151 insertions(+), 71 deletions(-) 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 From a2b6c7c6847f67917f2da62eea251cf763196a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Fri, 13 Feb 2026 15:44:28 +0100 Subject: [PATCH 3/4] exceptions adding in closed elements --- src/warchron/controller/app_controller.py | 92 +++++++++++++++++-- .../controller/campaign_controller.py | 47 +++++----- src/warchron/controller/player_controller.py | 18 ++-- src/warchron/controller/round_controller.py | 14 ++- src/warchron/controller/war_controller.py | 50 +++++----- src/warchron/model/closure_workflow.py | 19 ++++ src/warchron/model/model.py | 2 + src/warchron/model/score_service.py | 6 ++ src/warchron/model/war.py | 2 + src/warchron/view/view.py | 11 +-- 10 files changed, 179 insertions(+), 82 deletions(-) create mode 100644 src/warchron/model/closure_workflow.py diff --git a/src/warchron/controller/app_controller.py b/src/warchron/controller/app_controller.py index 57c1958..7ac6d7c 100644 --- a/src/warchron/controller/app_controller.py +++ b/src/warchron/controller/app_controller.py @@ -31,8 +31,7 @@ class AppController: self.navigation.refresh_wars_view() self.update_window_title() self.view.on_tree_selection_changed = self.navigation.on_tree_selection_changed - self.view.on_add_campaign = self.campaigns.add_campaign - self.view.on_add_round = self.rounds.add_round + self.view.on_add_item = self.add_item def __connect(self) -> None: self.view.actionExit.triggered.connect(self.view.close) @@ -41,20 +40,25 @@ class AppController: self.view.actionSave.triggered.connect(self.save) self.view.actionSave_as.triggered.connect(self.save_as) self.view.actionAbout.triggered.connect(self.show_about) - self.view.addPlayerBtn.clicked.connect(self.players.add_player) - self.view.addWarBtn.clicked.connect(self.wars.add_war) + self.view.addPlayerBtn.clicked.connect(lambda: self.add_item(ItemType.PLAYER)) + self.view.addWarBtn.clicked.connect(lambda: self.add_item(ItemType.WAR)) self.view.majorValue.valueChanged.connect(self.wars.set_major_value) self.view.minorValue.valueChanged.connect(self.wars.set_minor_value) self.view.influenceToken.toggled.connect(self.wars.set_influence_token) - self.view.addObjectiveBtn.clicked.connect(self.wars.add_objective) - self.view.addWarParticipantBtn.clicked.connect(self.wars.add_war_participant) + self.view.addObjectiveBtn.clicked.connect( + lambda: self.add_item(ItemType.OBJECTIVE) + ) + self.view.addWarParticipantBtn.clicked.connect( + lambda: self.add_item(ItemType.WAR_PARTICIPANT) + ) self.view.endWarBtn.clicked.connect(self.wars.close_war) - self.view.addSectorBtn.clicked.connect(self.campaigns.add_sector) + self.view.addSectorBtn.clicked.connect(lambda: self.add_item(ItemType.SECTOR)) self.view.addCampaignParticipantBtn.clicked.connect( - self.campaigns.add_campaign_participant + lambda: self.add_item(ItemType.CAMPAIGN_PARTICIPANT) ) self.view.endCampaignBtn.clicked.connect(self.campaigns.close_campaign) self.view.endRoundBtn.clicked.connect(self.rounds.close_round) + self.view.on_add_item = self.add_item self.view.on_edit_item = self.edit_item self.view.on_delete_item = self.delete_item @@ -161,6 +165,72 @@ class AppController: # Command methods + def add_item(self, item_type: str) -> None: + try: + if item_type == ItemType.PLAYER: + play = self.players.create_player() + if not play: + return + self.navigation.refresh(RefreshScope.PLAYERS_LIST) + elif item_type == ItemType.WAR: + war = self.wars.create_war() + if not war: + return + self.navigation.refresh_and_select( + RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=war.id + ) + elif item_type == ItemType.CAMPAIGN: + camp = self.campaigns.create_campaign() + if not camp: + return + self.navigation.refresh_and_select( + RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=camp.id + ) + elif item_type == ItemType.OBJECTIVE: + obj = self.wars.create_objective() + if not obj: + return + self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + elif item_type == ItemType.WAR_PARTICIPANT: + war_part = self.wars.create_war_participant() + if not war_part: + return + self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + elif item_type == ItemType.SECTOR: + sect = self.campaigns.create_sector() + if not sect: + return + self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + elif item_type == ItemType.CAMPAIGN_PARTICIPANT: + camp_part = self.campaigns.create_campaign_participant() + if not camp_part: + return + self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + elif item_type == ItemType.ROUND: + rnd = self.rounds.create_round() + if not rnd: + return + self.navigation.refresh_and_select( + RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=rnd.id + ) + self.is_dirty = True + except DomainError as e: + QMessageBox.warning( + self.view, + "Deletion forbidden", + str(e), + ) + except RequiresConfirmation as e: + reply = QMessageBox.question( + self.view, + "Confirm update", + str(e), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + e.action() + self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + def edit_item(self, item_type: str, item_id: str) -> None: try: if item_type == ItemType.PLAYER: @@ -195,6 +265,12 @@ class AppController: self.rounds.edit_round_battle(item_id) self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.is_dirty = True + except DomainError as e: + QMessageBox.warning( + self.view, + "Deletion forbidden", + str(e), + ) except RequiresConfirmation as e: reply = QMessageBox.question( self.view, diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index f5ff268..022fd54 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -2,7 +2,7 @@ from typing import List, TYPE_CHECKING from PyQt6.QtWidgets import QMessageBox, QDialog -from warchron.constants import ItemType, RefreshScope +from warchron.constants import RefreshScope if TYPE_CHECKING: from warchron.controller.app_controller import AppController @@ -13,6 +13,9 @@ from warchron.controller.dtos import ( SectorDTO, RoundDTO, ) +from warchron.model.campaign import Campaign +from warchron.model.campaign_participant import CampaignParticipant +from warchron.model.sector import Sector from warchron.model.closure_service import ClosureService from warchron.view.campaign_dialog import CampaignDialog from warchron.view.campaign_participant_dialog import CampaignParticipantDialog @@ -68,9 +71,9 @@ class CampaignController: return False return True - def add_campaign(self) -> None: + def create_campaign(self) -> Campaign | None: if not self.app.navigation.selected_war_id: - return + return None dialog = CampaignDialog( self.app.view, default_month=self.app.model.get_default_campaign_values( @@ -78,18 +81,18 @@ class CampaignController: )["month"], ) if dialog.exec() != QDialog.DialogCode.Accepted: - return + return None name = dialog.get_campaign_name() month = dialog.get_campaign_month() if not self._validate_campaign_inputs(name, month): - return - camp = self.app.model.add_campaign( + return None + return self.app.model.add_campaign( self.app.navigation.selected_war_id, name, month ) - self.app.is_dirty = True - self.app.navigation.refresh_and_select( - RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=camp.id - ) + # self.app.is_dirty = True + # self.app.navigation.refresh_and_select( + # RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=camp.id + # ) def edit_campaign(self, campaign_id: str) -> None: camp = self.app.model.get_campaign(campaign_id) @@ -128,9 +131,9 @@ class CampaignController: # Campaign participant methods - def add_campaign_participant(self) -> None: + def create_campaign_participant(self) -> CampaignParticipant | None: if not self.app.navigation.selected_campaign_id: - return + return None participants = self.app.model.get_available_war_participants( self.app.navigation.selected_campaign_id ) @@ -140,17 +143,15 @@ class CampaignController: ] dialog = CampaignParticipantDialog(self.app.view, participants=part_opts) if dialog.exec() != QDialog.DialogCode.Accepted: - return + return None player_id = dialog.get_player_id() leader = dialog.get_participant_leader() theme = dialog.get_participant_theme() if not player_id: - return - self.app.model.add_campaign_participant( + return None + return self.app.model.add_campaign_participant( self.app.navigation.selected_campaign_id, player_id, leader, theme ) - self.app.is_dirty = True - self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) def edit_campaign_participant(self, participant_id: str) -> None: camp_part = self.app.model.get_campaign_participant(participant_id) @@ -191,9 +192,9 @@ class CampaignController: # allow same objectives in different fields? return True - def add_sector(self) -> None: + def create_sector(self) -> Sector | None: if not self.app.navigation.selected_campaign_id: - return + return None war = self.app.model.get_war_by_campaign( self.app.navigation.selected_campaign_id ) @@ -212,7 +213,7 @@ class CampaignController: self.app.view, default_name="", rounds=rnd_objs, objectives=obj_dtos ) if dialog.exec() != QDialog.DialogCode.Accepted: - return + return None name = dialog.get_sector_name() round_id = dialog.get_round_id() major_id = dialog.get_major_id() @@ -223,8 +224,8 @@ class CampaignController: if not self._validate_sector_inputs( name, round_id, major_id, minor_id, influence_id ): - return - self.app.model.add_sector( + return None + return self.app.model.add_sector( self.app.navigation.selected_campaign_id, name, round_id, @@ -234,8 +235,6 @@ class CampaignController: mission, description, ) - self.app.is_dirty = True - self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) def edit_sector(self, sector_id: str) -> None: sect = self.app.model.get_sector(sector_id) diff --git a/src/warchron/controller/player_controller.py b/src/warchron/controller/player_controller.py index 4767576..ecd27b7 100644 --- a/src/warchron/controller/player_controller.py +++ b/src/warchron/controller/player_controller.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from PyQt6.QtWidgets import QMessageBox, QDialog from warchron.constants import RefreshScope +from warchron.model.player import Player if TYPE_CHECKING: from warchron.controller.app_controller import AppController @@ -21,16 +22,15 @@ class PlayerController: return False return True - def add_player(self) -> None: + def create_player(self) -> Player | None: dialog = PlayerDialog(self.app.view) - result = dialog.exec() # modal blocking dialog - if result == QDialog.DialogCode.Accepted: - name = dialog.get_player_name() - if not self._validate_player_inputs(name): - return - self.app.model.add_player(name) - self.app.is_dirty = True - self.app.navigation.refresh(RefreshScope.PLAYERS_LIST) + result = dialog.exec() + if result != QDialog.DialogCode.Accepted: + return None + name = dialog.get_player_name() + if not self._validate_player_inputs(name): + return None + return self.app.model.add_player(name) def edit_player(self, player_id: str) -> None: play = self.app.model.get_player(player_id) diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index d5a09e3..93e39ae 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -3,6 +3,7 @@ from typing import List, TYPE_CHECKING from PyQt6.QtWidgets import QDialog, QMessageBox from warchron.constants import ItemType, RefreshScope, Icons, IconName +from warchron.model.round import Round if TYPE_CHECKING: from warchron.controller.app_controller import AppController @@ -111,14 +112,11 @@ class RoundController: self.app.view.display_round_battles(battles_for_display) self.app.view.endRoundBtn.setEnabled(not rnd.is_over) - def add_round(self) -> None: - if not self.app.navigation.selected_campaign_id: - return - rnd = self.app.model.add_round(self.app.navigation.selected_campaign_id) - self.app.is_dirty = True - self.app.navigation.refresh_and_select( - RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=rnd.id - ) + def create_round(self) -> Round | None: + campaign_id = self.app.navigation.selected_campaign_id + if not campaign_id: + return None + return self.app.model.add_round(campaign_id) def close_round(self) -> None: round_id = self.app.navigation.selected_round_id diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index f360b8b..8702bf8 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -2,7 +2,7 @@ from typing import List, TYPE_CHECKING from PyQt6.QtWidgets import QMessageBox, QDialog -from warchron.constants import ItemType, RefreshScope +from warchron.constants import RefreshScope if TYPE_CHECKING: from warchron.controller.app_controller import AppController @@ -11,6 +11,9 @@ from warchron.controller.dtos import ( WarParticipantDTO, ObjectiveDTO, ) +from warchron.model.war import War +from warchron.model.war_participant import WarParticipant +from warchron.model.objective import Objective from warchron.model.closure_service import ClosureService from warchron.view.war_dialog import WarDialog from warchron.view.objective_dialog import ObjectiveDialog @@ -60,21 +63,18 @@ class WarController: return False return True - def add_war(self) -> None: + def create_war(self) -> War | None: dialog = WarDialog( self.app.view, default_year=self.app.model.get_default_war_values()["year"] ) - result = dialog.exec() # modal blocking dialog - if result == QDialog.DialogCode.Accepted: - name = dialog.get_war_name() - year = dialog.get_war_year() - if not self._validate_war_inputs(name, year): - return - war = self.app.model.add_war(name, year) - self.app.is_dirty = True - self.app.navigation.refresh_and_select( - RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=war.id - ) + result = dialog.exec() + if result != QDialog.DialogCode.Accepted: + return None + name = dialog.get_war_name() + year = dialog.get_war_year() + if not self._validate_war_inputs(name, year): + return None + return self.app.model.add_war(name, year) def edit_war(self, war_id: str) -> None: war = self.app.model.get_war(war_id) @@ -142,21 +142,19 @@ class WarController: return False return True - def add_objective(self) -> None: + def create_objective(self) -> Objective | None: if not self.app.navigation.selected_war_id: - return + return None dialog = ObjectiveDialog(self.app.view) if dialog.exec() != QDialog.DialogCode.Accepted: - return + return None name = dialog.get_objective_name() description = dialog.get_objective_description() if not self._validate_objective_inputs(name, description): - return - self.app.model.add_objective( + return None + return self.app.model.add_objective( self.app.navigation.selected_war_id, name, description ) - self.app.is_dirty = True - self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) def edit_objective(self, objective_id: str) -> None: obj = self.app.model.get_objective(objective_id) @@ -174,9 +172,9 @@ class WarController: # War participant methods - def add_war_participant(self) -> None: + def create_war_participant(self) -> WarParticipant | None: if not self.app.navigation.selected_war_id: - return + return None players = self.app.model.get_available_players( self.app.navigation.selected_war_id ) @@ -185,16 +183,14 @@ class WarController: ] dialog = WarParticipantDialog(self.app.view, players=play_opts) if dialog.exec() != QDialog.DialogCode.Accepted: - return + return None player_id = dialog.get_player_id() faction = dialog.get_participant_faction() if not player_id: - return - self.app.model.add_war_participant( + return None + return self.app.model.add_war_participant( self.app.navigation.selected_war_id, player_id, faction ) - self.app.is_dirty = True - self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) def edit_war_participant(self, participant_id: str) -> None: war_part = self.app.model.get_war_participant(participant_id) diff --git a/src/warchron/model/closure_workflow.py b/src/warchron/model/closure_workflow.py new file mode 100644 index 0000000..db7ee66 --- /dev/null +++ b/src/warchron/model/closure_workflow.py @@ -0,0 +1,19 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from warchron.model.war import War + from warchron.model.war import War + from warchron.model.war import War + from warchron.model.closure_service import ClosureService + + +class RoundClosureWorkflow: + + def close_round(self, round_id): + rnd = repo.get_round(round_id) + + ties = ClosureService.close_round(rnd) + + repo.save() + + return ties diff --git a/src/warchron/model/model.py b/src/warchron/model/model.py index b5eeaf3..4519f07 100644 --- a/src/warchron/model/model.py +++ b/src/warchron/model/model.py @@ -159,6 +159,8 @@ class Model: def update_war(self, war_id: str, *, name: str, year: int) -> None: war = self.get_war(war_id) + if war.is_over: + raise ForbiddenOperation("Can't update a closed war.") war.set_name(name) war.set_year(year) diff --git a/src/warchron/model/score_service.py b/src/warchron/model/score_service.py index 30bb48e..2bfda9b 100644 --- a/src/warchron/model/score_service.py +++ b/src/warchron/model/score_service.py @@ -37,3 +37,9 @@ class ScoreService: if sector.minor_objective_id: totals[sector.minor_objective_id] += war.minor_value return totals + + # def compute_round_results(round) + + # def compute_campaign_winner(campaign) + + # def compute_war_winner(war) diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index cacf5a8..3ff19b5 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -238,6 +238,8 @@ class War: if self.is_over: raise ForbiddenOperation("Can't update campaign in a closed war.") camp = self.get_campaign(campaign_id) + if camp.is_over: + raise ForbiddenOperation("Can't update a closed campaign.") camp.set_name(name) camp.set_month(month) diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index d4aed89..5950dae 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -41,8 +41,7 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): self.majorValue.setMinimum(0) self.minorValue.setMinimum(0) self.on_influence_token_changed: Callable[[int], None] | None = None - self.on_add_campaign: Callable[[], None] | None = None - self.on_add_round: Callable[[], None] | None = None + self.on_add_item: Callable[[str], None] | None = None self.on_edit_item: Callable[[str, str], None] | None = None self.on_delete_item: Callable[[str, str], None] | None = None self.splitter.setSizes([200, 800]) @@ -202,12 +201,12 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): # Wars view def _on_add_campaign_clicked(self) -> None: - if self.on_add_campaign: - self.on_add_campaign() + if self.on_add_item: + self.on_add_item(ItemType.CAMPAIGN) def _on_add_round_clicked(self) -> None: - if self.on_add_round: - self.on_add_round() + if self.on_add_item: + self.on_add_item(ItemType.ROUND) def set_add_campaign_enabled(self, enabled: bool) -> None: self.addCampaignBtn.setEnabled(enabled) From 60fc88af75f84c4b9dd610658ed0f18c3884bd58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Fri, 13 Feb 2026 16:12:43 +0100 Subject: [PATCH 4/4] catch settings exception --- src/warchron/controller/war_controller.py | 31 ++++++++++++++++++++--- src/warchron/model/model.py | 6 ++--- src/warchron/model/war.py | 16 ++++++++---- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index 8702bf8..9851333 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -3,6 +3,7 @@ from typing import List, TYPE_CHECKING from PyQt6.QtWidgets import QMessageBox, QDialog from warchron.constants import RefreshScope +from warchron.model.exception import DomainError if TYPE_CHECKING: from warchron.controller.app_controller import AppController @@ -115,22 +116,46 @@ class WarController: war_id = self.app.navigation.selected_war_id if not war_id: return - self.app.model.set_major_value(war_id, value) + try: + self.app.model.set_major_value(war_id, value) + except DomainError as e: + QMessageBox.warning( + self.app.view, + "Setting forbidden", + str(e), + ) self.app.is_dirty = True + self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) def set_minor_value(self, value: int) -> None: war_id = self.app.navigation.selected_war_id if not war_id: return - self.app.model.set_minor_value(war_id, value) + try: + self.app.model.set_minor_value(war_id, value) + except DomainError as e: + QMessageBox.warning( + self.app.view, + "Setting forbidden", + str(e), + ) self.app.is_dirty = True + self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) def set_influence_token(self, checked: bool) -> None: war_id = self.app.navigation.selected_war_id if not war_id: return - self.app.model.set_influence_token(war_id, checked) + try: + self.app.model.set_influence_token(war_id, checked) + except DomainError as e: + QMessageBox.warning( + self.app.view, + "Setting forbidden", + str(e), + ) self.app.is_dirty = True + self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) # Objective methods diff --git a/src/warchron/model/model.py b/src/warchron/model/model.py index 4519f07..65a4a7b 100644 --- a/src/warchron/model/model.py +++ b/src/warchron/model/model.py @@ -166,15 +166,15 @@ class Model: def set_major_value(self, war_id: str, value: int) -> None: war = self.get_war(war_id) - war.set_major(value) + war.set_major_value(value) def set_minor_value(self, war_id: str, value: int) -> None: war = self.get_war(war_id) - war.set_minor(value) + war.set_minor_value(value) def set_influence_token(self, war_id: str, value: bool) -> None: war = self.get_war(war_id) - war.set_influence(value) + war.set_influence_token(value) def get_all_wars(self) -> List[War]: return list(self.wars.values()) diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index 3ff19b5..ca7c6ff 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -36,17 +36,23 @@ class War: def set_year(self, new_year: int) -> None: self.year = new_year - def set_major(self, new_value: int) -> None: + def set_major_value(self, new_value: int) -> None: + if self.is_over: + raise ForbiddenOperation("Can't set major value of a closed war.") if new_value < self.minor_value: - raise ValueError("major_value cannot be < minor_value") + raise ValueError("Can' set major value < minor value") self.major_value = new_value - def set_minor(self, new_value: int) -> None: + def set_minor_value(self, new_value: int) -> None: + if self.is_over: + raise ForbiddenOperation("Can't set minor value of a closed war.") if new_value > self.major_value: - raise ValueError("minor_value cannot be > major_value") + raise ValueError("Can't set minor value > major value") self.minor_value = new_value - def set_influence(self, new_state: bool) -> None: + def set_influence_token(self, new_state: bool) -> None: + if self.is_over: + raise ForbiddenOperation("Can't set influence token of a closed war.") self.influence_token = new_state def set_state(self, new_state: bool) -> None: