960 lines
31 KiB
Python
960 lines
31 KiB
Python
"""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})
|