"""Game-neutral deck builder models.""" import re from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError class MvdTcgDeck(models.Model): """Store one curated deck for one supported TCG.""" _name = "mvd.tcg.deck" _description = "TCG Deck" _order = "write_date desc, id desc" _MVD_TCG_BOARD_CODE_ALIASES = { "mainboard": {"main", "mainboard"}, "sideboard": {"side", "sideboard"}, "maybeboard": {"maybe", "maybeboard"}, "command_zone": {"command", "command_zone", "commander", "command zone"}, } _MVD_TCG_BOARD_EXPORT_HEADINGS = { "mainboard": "Mainboard", "sideboard": "Sideboard", "maybeboard": "Maybeboard", "command_zone": "Command Zone", } active = fields.Boolean(default=True) name = fields.Char(required=True, index="trigram") game_id = fields.Many2one( "mvd.tcg.game", string="Game", required=True, index=True, ondelete="restrict", ) user_id = fields.Many2one( "res.users", string="Owner", required=True, default=lambda self: self.env.user, index=True, ) description = fields.Html() note = fields.Text() board_ids = fields.One2many( "mvd.tcg.deck.board", "deck_id", string="Boards", copy=True, ) line_ids = fields.One2many( "mvd.tcg.deck.line", "deck_id", string="Deck Lines", readonly=True, ) board_count = fields.Integer( compute="_compute_counts", readonly=True, ) total_card_count = fields.Integer( compute="_compute_counts", readonly=True, ) distinct_card_count = fields.Integer( compute="_compute_counts", readonly=True, ) cover_card_id = fields.Many2one( "mvd.tcg.card", compute="_compute_cover", readonly=True, ) cover_image = fields.Image( compute="_compute_cover", readonly=True, ) def _mvd_tcg_can_manage_deck_owner(self): """Return whether the current user may assign deck ownership. Returns: bool: ``True`` for TCG managers, administrators and system users. """ return self.env.is_superuser() or any( self.env.user.has_group(xmlid) for xmlid in ( "mvd_tcg_base.mvd_tcg_base_group_manager", "base.group_system", ) ) @api.model_create_multi def create(self, vals_list): """Create decks and seed their default boards when needed. Args: vals_list: Standard ORM payloads for new decks. Returns: Model: Created deck records. """ prepared_vals_list = [] for vals in vals_list: prepared_vals = dict(vals) requested_owner_id = prepared_vals.get("user_id") if ( requested_owner_id and requested_owner_id != self.env.user.id and not self._mvd_tcg_can_manage_deck_owner() ): raise UserError( _( "Only TCG managers can assign decks to another owner." ) ) prepared_vals_list.append(prepared_vals) decks = super().create(prepared_vals_list) for deck, vals in zip(decks, vals_list): if vals.get("board_ids"): continue deck._mvd_tcg_seed_default_boards() return decks def write(self, vals): """Protect deck ownership from normal user reassignment. Args: vals: Field values to update. Returns: bool: Result of the underlying ORM write. """ if "user_id" in vals: requested_owner_id = vals.get("user_id") if ( requested_owner_id and requested_owner_id != self.env.user.id and not self._mvd_tcg_can_manage_deck_owner() ): raise UserError( _( "Only TCG managers can reassign a deck to another owner." ) ) return super().write(vals) @api.depends("board_ids", "board_ids.total_card_count", "line_ids.card_id") def _compute_counts(self): """Compute aggregate counters for each deck. Returns: None: The method updates records in place. """ for deck in self: deck.board_count = len(deck.board_ids) deck.total_card_count = sum( deck.board_ids.filtered("include_in_total").mapped("total_card_count") ) deck.distinct_card_count = len(deck.line_ids.mapped("card_id")) @api.depends( "line_ids.sequence", "line_ids.board_sequence", "line_ids.card_id", "line_ids.card_id.image_1920", ) def _compute_cover(self): """Pick a stable cover card and image for each deck. Returns: None: The method updates records in place. """ for deck in self: ordered_lines = sorted( deck.line_ids.filtered("card_id"), key=lambda line: (line.board_sequence, line.sequence, line.id), ) cover_card = ordered_lines[0].card_id if ordered_lines else False deck.cover_card_id = cover_card deck.cover_image = ( cover_card._mvd_tcg_get_deck_image_binary() if cover_card else False ) def _mvd_tcg_seed_default_boards(self): """Create default boards for decks that do not have any yet. Returns: None: The method creates child board records in place. """ board_model = self.env["mvd.tcg.deck.board"] for deck in self.filtered(lambda current_deck: not current_deck.board_ids): board_values = deck.game_id._mvd_tcg_get_default_deck_board_templates() board_model.with_context( mvd_tcg_bypass_board_code_write=True ).create( [ { "deck_id": deck.id, "name": values["name"], "code": values["code"], "sequence": values.get("sequence", 10), "include_in_total": values.get("include_in_total", True), } for values in board_values ] ) def action_seed_default_boards(self): """Recreate default boards when a deck has none. Returns: bool: ``True`` when the operation completed. """ self._mvd_tcg_seed_default_boards() return True def action_open_cards(self): """Open all cards that currently belong to the deck. Returns: dict: Window action filtered to linked cards. """ self.ensure_one() action = self.env["ir.actions.actions"]._for_xml_id( "mvd_tcg_base.mvd_tcg_card_action" ) action["domain"] = [("id", "in", self.line_ids.mapped("card_id").ids or [0])] action["context"] = {"default_game_id": self.game_id.id} return action def action_open_line_manager(self): """Open the standalone line manager for the current deck. Returns: dict: Window action filtered to the current deck lines. """ self.ensure_one() action = self.env["ir.actions.actions"]._for_xml_id( "mvd_tcg_deck.mvd_tcg_deck_line_action" ) action["name"] = _("%s Lines") % self.display_name action["domain"] = [("deck_id", "=", self.id)] action["context"] = { "default_deck_id": self.id, "search_default_group_by_board": 1, } return action def action_open_add_to_deck_wizard(self): """Open the generic add-to-deck wizard for the current deck. Returns: dict: Window action for the add-to-deck wizard. """ self.ensure_one() return { "type": "ir.actions.act_window", "name": _("Add to Deck"), "res_model": "mvd.tcg.add.to.deck", "view_mode": "form", "target": "new", "context": { "default_game_id": self.game_id.id, "default_deck_id": self.id, }, } def action_open_deck_import_wizard(self): """Open the text import wizard for the current deck. Returns: dict: Window action for the deck text transfer wizard. """ self.ensure_one() return { "type": "ir.actions.act_window", "name": _("Import Deck List"), "res_model": "mvd.tcg.deck.text.transfer", "view_mode": "form", "target": "new", "context": { "default_deck_id": self.id, "default_operation": "import", }, } def action_open_deck_export_wizard(self): """Open the text export wizard for the current deck. Returns: dict: Window action for the deck text transfer wizard. """ self.ensure_one() return { "type": "ir.actions.act_window", "name": _("Export Deck List"), "res_model": "mvd.tcg.deck.text.transfer", "view_mode": "form", "target": "new", "context": { "default_deck_id": self.id, "default_operation": "export", }, } @classmethod def _mvd_tcg_normalize_board_code(cls, raw_code): """Resolve one raw board label to its stable technical board code. Args: raw_code: Free-form board name or code. Returns: str | bool: Stable board code or ``False`` when no alias matches. """ normalized_code = (raw_code or "").strip().lower() for board_code, aliases in cls._MVD_TCG_BOARD_CODE_ALIASES.items(): if normalized_code in aliases or normalized_code == board_code: return board_code return False @classmethod def _mvd_tcg_get_board_export_heading(cls, board): """Return one stable, human-readable board heading for exports. Args: board: Deck board record. Returns: str: Export heading that remains round-trippable. """ normalized_code = cls._mvd_tcg_normalize_board_code(board.code) return cls._MVD_TCG_BOARD_EXPORT_HEADINGS.get( normalized_code, board.name, ) def _mvd_tcg_get_board_by_code(self, board_code): """Return the first board matching the requested code. Args: board_code: Stable technical board code such as ``mainboard``. Returns: mvd.tcg.deck.board: Matching board record, if any. """ self.ensure_one() normalized_code = self._mvd_tcg_normalize_board_code(board_code) or board_code accepted_codes = self._MVD_TCG_BOARD_CODE_ALIASES.get( normalized_code, {normalized_code}, ) return self.board_ids.filtered(lambda board: board.code in accepted_codes)[:1] def _mvd_tcg_add_card_to_board( self, card, board, quantity=1, role_ids=False, note=False, ): """Create or increment one deck line through the standard add rules. Args: card: Card record that should be added. board: Target board inside the current deck. quantity: Quantity delta to add. role_ids: Optional deck roles to merge onto the target line. note: Optional note to apply to the resulting line. Returns: mvd.tcg.deck.line: Created or updated deck line. """ self.ensure_one() line_model = self.env["mvd.tcg.deck.line"] existing_line = line_model.search( [ ("board_id", "=", board.id), ("card_id", "=", card.id), ], limit=1, ) self._mvd_tcg_validate_add_to_board( card, board, quantity=quantity, existing_line=existing_line, ) if existing_line: values = {"quantity": existing_line.quantity + quantity} if role_ids: merged_roles = existing_line.role_ids | role_ids values["role_ids"] = [(6, 0, merged_roles.ids)] if note: values["note"] = note existing_line.write(values) return existing_line return line_model.create( { "board_id": board.id, "quantity": quantity, "card_id": card.id, "role_ids": [(6, 0, role_ids.ids)] if role_ids else False, "note": note, } ) def _mvd_tcg_action_open_board(self, board_code): """Open one logical board of the current deck. Args: board_code: Stable technical board code such as ``mainboard``. Returns: dict | bool: Window action for the board form, or ``False``. """ self.ensure_one() board = self._mvd_tcg_get_board_by_code(board_code) if not board: self._mvd_tcg_seed_default_boards() board = self._mvd_tcg_get_board_by_code(board_code) return board.action_open_board() if board else False def _mvd_tcg_action_open_add_to_board_wizard(self, board_code): """Open the add-to-deck wizard with a preselected board. Args: board_code: Stable technical board code such as ``mainboard``. Returns: dict: Window action for the add-to-deck wizard. """ self.ensure_one() board = self._mvd_tcg_get_board_by_code(board_code) if not board: self._mvd_tcg_seed_default_boards() board = self._mvd_tcg_get_board_by_code(board_code) return { "type": "ir.actions.act_window", "name": _("Add to Deck"), "res_model": "mvd.tcg.add.to.deck", "view_mode": "form", "target": "new", "context": { "default_game_id": self.game_id.id, "default_deck_id": self.id, "default_board_id": board.id if board else False, }, } def _mvd_tcg_validate_add_to_board( self, card, board, quantity=1, existing_line=False, ): """Validate whether one card can be added to one target board. Args: card: Card record that should be added. board: Target board record. quantity: Quantity delta requested by the user. existing_line: Existing matching board line when the add operation would increment an already present card. Returns: bool: ``True`` when no game-specific rule blocks the add flow. """ self.ensure_one() return True class MvdTcgDeckBoard(models.Model): """Store one logical board inside a deck.""" _name = "mvd.tcg.deck.board" _description = "TCG Deck Board" _order = "deck_id, sequence, id" sequence = fields.Integer(default=10) deck_id = fields.Many2one( "mvd.tcg.deck", required=True, ondelete="cascade", index=True, ) name = fields.Char(required=True, translate=True) code = fields.Char(required=True, index=True) include_in_total = fields.Boolean(default=True) note = fields.Text() line_ids = fields.One2many( "mvd.tcg.deck.line", "board_id", string="Board Lines", copy=True, ) total_card_count = fields.Integer( compute="_compute_counts", readonly=True, ) distinct_card_count = fields.Integer( compute="_compute_counts", readonly=True, ) _board_code_unique = models.Constraint( "UNIQUE(deck_id, code)", "The board code must be unique inside a deck.", ) @api.model def _mvd_tcg_generate_board_code(self, name): """Return a slug-like technical code for one board name.""" return re.sub(r"[^a-z0-9]+", "_", (name or "").strip().lower()).strip("_") or "board" @api.model def _mvd_tcg_get_unique_board_code(self, deck_id, name): """Return a unique board code inside one deck.""" base_code = self._mvd_tcg_generate_board_code(name) if not deck_id: return base_code existing_codes = set(self.search([("deck_id", "=", deck_id)]).mapped("code")) if base_code not in existing_codes: return base_code suffix = 2 while f"{base_code}_{suffix}" in existing_codes: suffix += 1 return f"{base_code}_{suffix}" def _mvd_tcg_can_edit_board_code(self): """Return whether the current user may edit board codes.""" return self.env.is_superuser() or any( self.env.user.has_group(xmlid) for xmlid in ( "mvd_tcg_base.mvd_tcg_base_group_administrator", "base.group_system", ) ) @api.model_create_multi def create(self, vals_list): """Generate technical codes for manually created boards.""" prepared_vals_list = [] for vals in vals_list: prepared_vals = dict(vals) if ( prepared_vals.get("code") and not self.env.context.get("mvd_tcg_bypass_board_code_write") and not self._mvd_tcg_can_edit_board_code() ): raise UserError( _( "Board codes are technical identifiers and can only be set " "by TCG administrators." ) ) if not prepared_vals.get("code"): prepared_vals["code"] = self._mvd_tcg_get_unique_board_code( prepared_vals.get("deck_id"), prepared_vals.get("name"), ) prepared_vals_list.append(prepared_vals) return super().create(prepared_vals_list) def write(self, vals): """Protect technical board codes from normal business edits.""" if "code" in vals and not self.env.context.get("mvd_tcg_bypass_board_code_write"): if not self._mvd_tcg_can_edit_board_code(): raise UserError( _( "Board codes are technical identifiers and can only be changed " "by TCG administrators." ) ) return super().write(vals) @api.depends("line_ids.quantity", "line_ids.card_id") def _compute_counts(self): """Compute per-board card counters. Returns: None: The method updates records in place. """ for board in self: board.total_card_count = sum(board.line_ids.mapped("quantity")) board.distinct_card_count = len(board.line_ids.mapped("card_id")) def action_open_board(self): """Open the current board in form view. Returns: dict: Window action for the current board. """ self.ensure_one() return { "type": "ir.actions.act_window", "name": self.display_name, "res_model": "mvd.tcg.deck.board", "view_mode": "form", "res_id": self.id, "target": "current", } def action_open_line_manager(self): """Open the standalone line manager for the current board. Returns: dict: Window action filtered to the current board lines. """ self.ensure_one() action = self.env["ir.actions.actions"]._for_xml_id( "mvd_tcg_deck.mvd_tcg_deck_line_action" ) action["name"] = _("%s Lines") % self.display_name action["domain"] = [("board_id", "=", self.id)] action["context"] = { "default_deck_id": self.deck_id.id, "default_board_id": self.id, "search_default_group_by_primary_role": 1, } return action class MvdTcgDeckLine(models.Model): """Store one card entry inside one deck board.""" _name = "mvd.tcg.deck.line" _description = "TCG Deck Line" _order = "deck_id, board_sequence, sequence, id" sequence = fields.Integer(default=10) board_id = fields.Many2one( "mvd.tcg.deck.board", required=True, ondelete="cascade", index=True, ) deck_id = fields.Many2one( "mvd.tcg.deck", related="board_id.deck_id", store=True, readonly=True, index=True, ) board_sequence = fields.Integer( related="board_id.sequence", string="Board Sequence", store=True, readonly=True, index=True, ) quantity = fields.Integer(required=True, default=1) card_id = fields.Many2one( "mvd.tcg.card", string="Card", required=True, index=True, domain=[("active", "=", True)], ) game_id = fields.Many2one( "mvd.tcg.game", related="deck_id.game_id", store=True, readonly=True, index=True, ) card_image_128 = fields.Image( related="card_id.image_128", readonly=True, ) card_image_512 = fields.Image( related="card_id.image_512", readonly=True, ) card_image_1920 = fields.Image( related="card_id.image_1920", readonly=True, ) role_ids = fields.Many2many( "mvd.tcg.deck.role", "mvd_tcg_deck_line_role_rel", "line_id", "role_id", string="Roles", ) primary_role_id = fields.Many2one( "mvd.tcg.deck.role", compute="_compute_primary_role", store=True, readonly=True, index=True, ) primary_role_sequence = fields.Integer( compute="_compute_primary_role", store=True, readonly=True, index=True, ) note = fields.Char() _board_card_unique = models.Constraint( "UNIQUE(board_id, card_id)", "A card can appear only once per board.", ) _quantity_positive = models.Constraint( "CHECK(quantity > 0)", "The quantity must be greater than zero.", ) @api.constrains("card_id", "game_id") def _check_card_game_matches_deck(self): """Ensure cards only enter decks of the same game. Returns: None: The method validates records in place. Raises: ValidationError: If the card and deck game differ. """ for line in self: if ( line.card_id and line.game_id and line.card_id.game_id and line.card_id.game_id != line.game_id ): raise ValidationError( _( "Deck lines can only reference cards from the same game as " "the deck." ) ) @api.depends("role_ids", "role_ids.sequence", "role_ids.name") def _compute_primary_role(self): """Expose one stable primary role for sorting and grouping. Returns: None: The compute updates records in place. """ for line in self: role = line.role_ids.sorted( key=lambda current_role: ( current_role.sequence, current_role.name or "", current_role.id, ) )[:1] line.primary_role_id = role line.primary_role_sequence = role.sequence if role else 9999 def action_open_line(self): """Open the current deck line in form view. Returns: dict: Window action for the current deck line. """ self.ensure_one() return { "type": "ir.actions.act_window", "name": self.display_name, "res_model": "mvd.tcg.deck.line", "view_mode": "form", "res_id": self.id, "target": "current", } def action_open_card_preview(self): """Open the linked card in a modal preview form. Returns: dict: Window action for the linked card form. """ self.ensure_one() return self._mvd_tcg_get_card_preview_action() def _mvd_tcg_get_card_form_view_xmlid(self): """Return the preferred form view XMLID for linked card previews. Returns: str: Stable XMLID for the target card form view. """ return "mvd_tcg_base.mvd_tcg_card_view_form" def _mvd_tcg_get_card_preview_action(self): """Build the window action that opens the linked card preview. Returns: dict: Window action for the linked card form. """ self.ensure_one() return { "type": "ir.actions.act_window", "name": self.card_id.display_name, "res_model": "mvd.tcg.card", "view_mode": "form", "res_id": self.card_id.id, "views": [ ( self.env.ref(self._mvd_tcg_get_card_form_view_xmlid()).id, "form", ) ], "target": "new", } def _mvd_tcg_move_to_board(self, board_code): """Move the current line to another logical board of the same deck. Args: board_code: Canonical board code such as ``mainboard``. Returns: bool: ``True`` when the move completed. Raises: UserError: If no matching board exists or a duplicate would be created. """ self.ensure_one() target_board = self.deck_id._mvd_tcg_get_board_by_code(board_code) if not target_board: raise UserError(_("No target board exists for code '%s'.") % board_code) if target_board == self.board_id: return True self._mvd_tcg_validate_move_to_board(target_board) conflicting_line = self.search( [ ("board_id", "=", target_board.id), ("card_id", "=", self.card_id.id), ("id", "!=", self.id), ], limit=1, ) if conflicting_line: raise UserError( _( "The selected card already exists in the target board. Merge or " "adjust that line first." ) ) self.board_id = target_board return True def _mvd_tcg_validate_move_to_board(self, target_board): """Validate whether the current line can move to another board. Args: target_board: Destination board record. Returns: bool: ``True`` when no game-specific rule blocks the move. """ self.ensure_one() return True class MvdTcgDeckRole(models.Model): """Store reusable deck line roles such as ramp or removal.""" _name = "mvd.tcg.deck.role" _description = "TCG Deck Role" _order = "sequence, name, id" active = fields.Boolean(default=True) sequence = fields.Integer(default=10) name = fields.Char(required=True, translate=True, index="trigram") technical_key = fields.Char( index=True, copy=False, groups="mvd_tcg_base.mvd_tcg_base_group_administrator,base.group_system", ) color = fields.Integer(default=0) note = fields.Text(translate=True) _name_unique = models.Constraint( "UNIQUE(name)", "The deck role name must be unique.", ) _technical_key_unique = models.Constraint( "UNIQUE(technical_key)", "The technical role key must be unique.", ) @api.model def _mvd_tcg_generate_technical_key(self, name): """Return a slug-like technical key for one deck role name.""" return re.sub(r"[^a-z0-9]+", "_", (name or "").strip().lower()).strip("_") or "role" @api.model def _mvd_tcg_get_unique_technical_key(self, name): """Return a globally unique technical key for one deck role name.""" base_key = self._mvd_tcg_generate_technical_key(name) existing_keys = set(self.search([]).mapped("technical_key")) if base_key not in existing_keys: return base_key suffix = 2 while f"{base_key}_{suffix}" in existing_keys: suffix += 1 return f"{base_key}_{suffix}" def _mvd_tcg_can_edit_technical_key(self): """Return whether the current user may edit technical role keys.""" return self.env.is_superuser() or any( self.env.user.has_group(xmlid) for xmlid in ( "mvd_tcg_base.mvd_tcg_base_group_administrator", "base.group_system", ) ) @api.model_create_multi def create(self, vals_list): """Generate missing technical keys for manually created deck roles.""" prepared_vals_list = [] for vals in vals_list: prepared_vals = dict(vals) if not prepared_vals.get("technical_key"): prepared_vals["technical_key"] = self._mvd_tcg_get_unique_technical_key( prepared_vals.get("name") ) prepared_vals_list.append(prepared_vals) return super().create(prepared_vals_list) def write(self, vals): """Protect technical role keys from normal business edits.""" if "technical_key" in vals and not self.env.context.get( "mvd_tcg_bypass_role_key_write" ): if not self._mvd_tcg_can_edit_technical_key(): raise UserError( _( "Deck role technical keys can only be changed by TCG " "administrators." ) ) return super().write(vals) @api.model def _mvd_tcg_sync_seed_role_keys(self): """Backfill technical keys for seeded deck roles on module updates.""" role_xmlids = { "ramp": "mvd_tcg_deck.mvd_tcg_deck_role_ramp", "draw": "mvd_tcg_deck.mvd_tcg_deck_role_draw", "removal": "mvd_tcg_deck.mvd_tcg_deck_role_removal", "interaction": "mvd_tcg_deck.mvd_tcg_deck_role_interaction", "protection": "mvd_tcg_deck.mvd_tcg_deck_role_protection", "wincon": "mvd_tcg_deck.mvd_tcg_deck_role_wincon", "value": "mvd_tcg_deck.mvd_tcg_deck_role_value", "combo": "mvd_tcg_deck.mvd_tcg_deck_role_combo", } for technical_key, xmlid in role_xmlids.items(): role = self.env.ref(xmlid, raise_if_not_found=False) if role and role.technical_key != technical_key: role.sudo().write({"technical_key": technical_key})