add forbidden exceptions on closed elements

This commit is contained in:
Maxime Réaux 2026-02-13 11:38:59 +01:00
parent 42eb625ef6
commit 88bd28e949
6 changed files with 151 additions and 71 deletions

View file

@ -3,11 +3,7 @@ from pathlib import Path
from PyQt6.QtWidgets import QMessageBox from PyQt6.QtWidgets import QMessageBox
from warchron.model.model import Model from warchron.model.model import Model
from warchron.model.exception import ( from warchron.model.exception import DomainError, RequiresConfirmation
DeletionForbidden,
DeletionRequiresConfirmation,
UpdateRequiresConfirmation,
)
from warchron.view.view import View from warchron.view.view import View
from warchron.constants import ItemType, RefreshScope from warchron.constants import ItemType, RefreshScope
from warchron.controller.navigation_controller import NavigationController from warchron.controller.navigation_controller import NavigationController
@ -199,15 +195,15 @@ class AppController:
self.rounds.edit_round_battle(item_id) self.rounds.edit_round_battle(item_id)
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
self.is_dirty = True self.is_dirty = True
except UpdateRequiresConfirmation as e: except RequiresConfirmation as e:
reply = QMessageBox.question( reply = QMessageBox.question(
self.view, self.view,
"Confirm update", "Confirm update",
e.message, str(e),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply == QMessageBox.StandardButton.Yes: if reply == QMessageBox.StandardButton.Yes:
e.apply_update() e.action()
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
def delete_item(self, item_type: str, item_id: str) -> None: 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 RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=camp_id
) )
self.is_dirty = True self.is_dirty = True
except DeletionForbidden as e: except DomainError as e:
QMessageBox.warning( QMessageBox.warning(
self.view, self.view,
"Deletion forbidden", "Deletion forbidden",
e.reason, str(e),
) )
except DeletionRequiresConfirmation as e: except RequiresConfirmation as e:
reply = QMessageBox.question( reply = QMessageBox.question(
self.view, self.view,
"Confirm deletion", "Confirm deletion",
e.message, str(e),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply == QMessageBox.StandardButton.Yes: if reply == QMessageBox.StandardButton.Yes:
e.cleanup_action() e.action()
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)

View file

@ -2,10 +2,7 @@ from __future__ import annotations
from uuid import uuid4 from uuid import uuid4
from typing import Any, Dict, List from typing import Any, Dict, List
from warchron.model.exception import ( from warchron.model.exception import ForbiddenOperation, RequiresConfirmation
DeletionRequiresConfirmation,
UpdateRequiresConfirmation,
)
from warchron.model.campaign_participant import CampaignParticipant from warchron.model.campaign_participant import CampaignParticipant
from warchron.model.sector import Sector from warchron.model.sector import Sector
from warchron.model.round import Round from warchron.model.round import Round
@ -78,6 +75,8 @@ class Campaign:
def add_campaign_participant( def add_campaign_participant(
self, war_participant_id: str, leader: str, theme: str self, war_participant_id: str, leader: str, theme: str
) -> CampaignParticipant: ) -> CampaignParticipant:
if self.is_over:
raise ForbiddenOperation("Can't add participant in a closed campaign.")
if self.has_war_participant(war_participant_id): if self.has_war_participant(war_participant_id):
raise ValueError("Player already registered in this campaign") raise ValueError("Player already registered in this campaign")
participant = CampaignParticipant( participant = CampaignParticipant(
@ -98,12 +97,16 @@ class Campaign:
def update_campaign_participant( def update_campaign_participant(
self, participant_id: str, *, leader: str, theme: str self, participant_id: str, *, leader: str, theme: str
) -> None: ) -> None:
if self.is_over:
raise ForbiddenOperation("Can't update participant in a closed campaign.")
part = self.get_campaign_participant(participant_id) part = self.get_campaign_participant(participant_id)
# Can't change referred War.participant # Can't change referred War.participant
part.set_leader(leader) part.set_leader(leader)
part.set_theme(theme) part.set_theme(theme)
def remove_campaign_participant(self, participant_id: str) -> None: 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] = [] rounds_blocking: list[Round] = []
for rnd in self.rounds: for rnd in self.rounds:
if rnd.has_choice_with_participant( if rnd.has_choice_with_participant(
@ -123,13 +126,11 @@ class Campaign:
rounds_str = ", ".join( rounds_str = ", ".join(
str(self.get_round_index(rnd.id)) for rnd in rounds_blocking str(self.get_round_index(rnd.id)) for rnd in rounds_blocking
) )
raise DeletionRequiresConfirmation( raise RequiresConfirmation(
message=(
f"This participant is used in round(s): {rounds_str}.\n" f"This participant is used in round(s): {rounds_str}.\n"
"Related choices and battles will be cleared.\n" "Related choices and battles will be cleared.\n"
"Do you want to continue?" "Do you want to continue?",
), action=cleanup,
cleanup_action=cleanup,
) )
# Sector methods # Sector methods
@ -155,6 +156,8 @@ class Campaign:
mission: str | None, mission: str | None,
description: str | None, description: str | None,
) -> Sector: ) -> Sector:
if self.is_over:
raise ForbiddenOperation("Can't add sector in a closed campaign.")
sect = Sector( sect = Sector(
name, round_id, major_id, minor_id, influence_id, mission, description name, round_id, major_id, minor_id, influence_id, mission, description
) )
@ -184,6 +187,8 @@ class Campaign:
mission: str | None, mission: str | None,
description: str | None, description: str | None,
) -> None: ) -> None:
if self.is_over:
raise ForbiddenOperation("Can't update sector in a closed campaign.")
sect = self.get_sector(sector_id) sect = self.get_sector(sector_id)
old_round_id = sect.round_id old_round_id = sect.round_id
@ -219,16 +224,16 @@ class Campaign:
rounds_str = ", ".join( rounds_str = ", ".join(
str(self.get_round_index(rnd.id)) for rnd in affected_rounds str(self.get_round_index(rnd.id)) for rnd in affected_rounds
) )
raise UpdateRequiresConfirmation( raise RequiresConfirmation(
message=(
f"Changing the round of this sector will affect round(s): {rounds_str}." f"Changing the round of this sector will affect round(s): {rounds_str}."
"\nRelated battles and choices will be cleared.\n" "\nRelated battles and choices will be cleared.\n"
"Do you want to continue?" "Do you want to continue?",
), action=cleanup_and_update,
apply_update=cleanup_and_update,
) )
def remove_sector(self, sector_id: str) -> None: 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] = [] rounds_blocking: list[Round] = []
for rnd in self.rounds: for rnd in self.rounds:
if rnd.has_battle_with_sector(sector_id) or rnd.has_choice_with_sector( if rnd.has_battle_with_sector(sector_id) or rnd.has_choice_with_sector(
@ -248,13 +253,11 @@ class Campaign:
rounds_str = ", ".join( rounds_str = ", ".join(
str(self.get_round_index(rnd.id)) for rnd in rounds_blocking str(self.get_round_index(rnd.id)) for rnd in rounds_blocking
) )
raise DeletionRequiresConfirmation( raise RequiresConfirmation(
message=(
f"This sector is used in round(s): {rounds_str}.\n" f"This sector is used in round(s): {rounds_str}.\n"
"Battles and choices using this sector will be cleared.\n" "Battles and choices using this sector will be cleared.\n"
"Do you want to continue?" "Do you want to continue?",
), action=cleanup,
cleanup_action=cleanup,
) )
def get_sectors_in_round(self, round_id: str) -> List[Sector]: 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: def has_round(self, round_id: str) -> bool:
return any(r.id == round_id for r in self.rounds) 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: def get_round(self, round_id: str) -> Round:
for rnd in self.rounds: for rnd in self.rounds:
if rnd.id == round_id: if rnd.id == round_id:
@ -276,12 +288,21 @@ class Campaign:
return list(self.rounds) return list(self.rounds)
def add_round(self) -> Round: def add_round(self) -> Round:
if self.is_over:
raise ForbiddenOperation("Can't add round in a closed campaign.")
round = Round() round = Round()
self.rounds.append(round) self.rounds.append(round)
return round return round
def remove_round(self, round_id: str) -> None: 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) 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(): for sect in self.sectors.values():
if sect.round_id == round_id: if sect.round_id == round_id:
sect.round_id = None sect.round_id = None
@ -321,9 +342,6 @@ class Campaign:
# Battle methods # 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: def create_battle(self, round_id: str, sector_id: str) -> Battle:
rnd = self.get_round(round_id) rnd = self.get_round(round_id)
return rnd.create_battle(sector_id) return rnd.create_battle(sector_id)

View file

@ -1,31 +1,25 @@
from typing import Callable from typing import Callable
class DeletionForbidden(Exception): class DomainError(Exception):
def __init__(self, reason: str): """Base class for all domain rule violations."""
self.reason = reason
super().__init__(reason) pass
class DeletionRequiresConfirmation(Exception): class ForbiddenOperation(DomainError):
def __init__( """Generic 'you can't do this' rule."""
self,
message: str, pass
*,
cleanup_action: Callable[[], None],
): class DomainDecision(Exception):
self.message = message """Base class for domain actions requiring user decision."""
self.cleanup_action = cleanup_action
super().__init__(message) pass
class UpdateRequiresConfirmation(Exception): class RequiresConfirmation(DomainDecision):
def __init__( def __init__(self, message: str, action: Callable[[], None]):
self,
message: str,
*,
apply_update: Callable[[], None],
):
self.message = message
self.apply_update = apply_update
super().__init__(message) super().__init__(message)
self.action = action

View file

@ -4,7 +4,7 @@ import json
import shutil import shutil
from datetime import datetime 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.player import Player
from warchron.model.war import War from warchron.model.war import War
from warchron.model.war_participant import WarParticipant from warchron.model.war_participant import WarParticipant
@ -91,7 +91,7 @@ class Model:
wars_using_player.append(war.name) wars_using_player.append(war.name)
if wars_using_player: if wars_using_player:
wars_str = ", ".join(wars_using_player) wars_str = ", ".join(wars_using_player)
raise DeletionForbidden( raise ForbiddenOperation(
f"This player is participating in war(s): {wars_str}.\n" f"This player is participating in war(s): {wars_str}.\n"
"Remove it from participants first." "Remove it from participants first."
) )
@ -178,6 +178,21 @@ class Model:
return list(self.wars.values()) return list(self.wars.values())
def remove_war(self, war_id: str) -> None: 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] del self.wars[war_id]
# Objective methods # Objective methods

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from uuid import uuid4 from uuid import uuid4
from typing import Any, Dict from typing import Any, Dict
from warchron.model.exception import ForbiddenOperation
from warchron.model.choice import Choice from warchron.model.choice import Choice
from warchron.model.battle import Battle from warchron.model.battle import Battle
@ -58,6 +59,8 @@ class Round:
) )
def create_choice(self, participant_id: str) -> Choice: 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: if participant_id not in self.choices:
choice = Choice( choice = Choice(
participant_id=participant_id, participant_id=participant_id,
@ -74,6 +77,8 @@ class Round:
secondary_sector_id: str | None, secondary_sector_id: str | None,
comment: str | None, comment: str | None,
) -> None: ) -> None:
if self.is_over:
raise ForbiddenOperation("Can't update choice in a closed round.")
choice = self.get_choice(participant_id) choice = self.get_choice(participant_id)
if choice: if choice:
choice.set_priority(priority_sector_id) choice.set_priority(priority_sector_id)
@ -88,6 +93,8 @@ class Round:
choice.secondary_sector_id = None choice.secondary_sector_id = None
def remove_choice(self, participant_id: str) -> 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] del self.choices[participant_id]
# Battle methods # Battle methods
@ -104,12 +111,17 @@ class Round:
for bat in self.battles.values() 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: def all_battles_finished(self) -> bool:
return all( return all(
b.winner_id is not None or b.is_draw() for b in self.battles.values() b.winner_id is not None or b.is_draw() for b in self.battles.values()
) )
def create_battle(self, sector_id: str) -> Battle: 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: if sector_id not in self.battles:
battle = Battle(sector_id=sector_id, player_1_id=None, player_2_id=None) battle = Battle(sector_id=sector_id, player_1_id=None, player_2_id=None)
self.battles[sector_id] = battle self.battles[sector_id] = battle
@ -125,6 +137,8 @@ class Round:
victory_condition: str | None, victory_condition: str | None,
comment: str | None, comment: str | None,
) -> None: ) -> None:
if self.is_over:
raise ForbiddenOperation("Can't update battle in a closed round.")
bat = self.get_battle(sector_id) bat = self.get_battle(sector_id)
if bat: if bat:
bat.set_player_1(player_1_id) bat.set_player_1(player_1_id)
@ -144,4 +158,9 @@ class Round:
battle.winner_id = None battle.winner_id = None
def remove_battle(self, sector_id: str) -> 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] del self.battles[sector_id]

View file

@ -3,7 +3,7 @@ from uuid import uuid4
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List 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.war_participant import WarParticipant
from warchron.model.objective import Objective from warchron.model.objective import Objective
from warchron.model.campaign_participant import CampaignParticipant from warchron.model.campaign_participant import CampaignParticipant
@ -87,6 +87,8 @@ class War:
# Objective methods # Objective methods
def add_objective(self, name: str, description: str | None) -> Objective: 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) obj = Objective(name, description)
self.objectives[obj.id] = obj self.objectives[obj.id] = obj
return obj return obj
@ -106,18 +108,22 @@ class War:
def update_objective( def update_objective(
self, objective_id: str, *, name: str, description: str | None self, objective_id: str, *, name: str, description: str | None
) -> None: ) -> None:
if self.is_over:
raise ForbiddenOperation("Can't update objective in a closed war.")
obj = self.get_objective(objective_id) obj = self.get_objective(objective_id)
obj.set_name(name) obj.set_name(name)
obj.set_description(description) obj.set_description(description)
def remove_objective(self, objective_id: str) -> None: 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] = [] camp_using_obj: List[str] = []
for camp in self.campaigns: for camp in self.campaigns:
if camp.has_sector_with_objective(objective_id): if camp.has_sector_with_objective(objective_id):
camp_using_obj.append(camp.name) camp_using_obj.append(camp.name)
if camp_using_obj: if camp_using_obj:
camps_str = ", ".join(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" f"This objective is used in campaign(s) sector(s): {camps_str}.\n"
"Remove it from campaign(s) first." "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()) return any(part.player_id == player_id for part in self.participants.values())
def add_war_participant(self, player_id: str, faction: str) -> WarParticipant: 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): if self.has_player(player_id):
raise ValueError("Player already registered in this war") raise ValueError("Player already registered in this war")
participant = WarParticipant(player_id=player_id, faction=faction) participant = WarParticipant(player_id=player_id, faction=faction)
@ -148,18 +156,22 @@ class War:
return list(self.participants.values()) return list(self.participants.values())
def update_war_participant(self, participant_id: str, *, faction: str) -> None: 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) part = self.get_war_participant(participant_id)
# Can't change referred Model.players # Can't change referred Model.players
part.set_faction(faction) part.set_faction(faction)
def remove_war_participant(self, participant_id: str) -> None: 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] = [] camp_using_part: List[str] = []
for camp in self.campaigns: for camp in self.campaigns:
if camp.has_war_participant(participant_id): if camp.has_war_participant(participant_id):
camp_using_part.append(camp.name) camp_using_part.append(camp.name)
if camp_using_part: if camp_using_part:
camps_str = ", ".join(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" f"This war participant is used in campaign(s): {camps_str}.\n"
"Remove it from campaign(s) first." "Remove it from campaign(s) first."
) )
@ -173,10 +185,21 @@ class War:
def get_default_campaign_values(self) -> Dict[str, Any]: def get_default_campaign_values(self) -> Dict[str, Any]:
return {"month": datetime.now().month} 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: def all_campaigns_finished(self) -> bool:
return all(c.is_over for c in self.campaigns) return all(c.is_over for c in self.campaigns)
def add_campaign(self, name: str, month: int | None = None) -> Campaign: 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: if month is None:
month = self.get_default_campaign_values()["month"] month = self.get_default_campaign_values()["month"]
campaign = Campaign(name, month) campaign = Campaign(name, month)
@ -212,6 +235,8 @@ class War:
raise KeyError(f"Participant {participant_id} not found in any Campaign") raise KeyError(f"Participant {participant_id} not found in any Campaign")
def update_campaign(self, campaign_id: str, *, name: str, month: int) -> None: 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 = self.get_campaign(campaign_id)
camp.set_name(name) camp.set_name(name)
camp.set_month(month) camp.set_month(month)
@ -220,7 +245,20 @@ class War:
return list(self.campaigns) return list(self.campaigns)
def remove_campaign(self, campaign_id: str) -> None: 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) 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) self.campaigns.remove(camp)
# Sector methods # Sector methods