🎉 Initialize module repository
This commit is contained in:
499
models/mvd_tcg_card.py
Normal file
499
models/mvd_tcg_card.py
Normal file
@@ -0,0 +1,499 @@
|
||||
"""Magic: The Gathering card extensions for the TCG suite."""
|
||||
|
||||
import re
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
COLLECTOR_NUMBER_PATTERN = re.compile(r"^(\d+)(.*)$")
|
||||
|
||||
|
||||
class MvdTcgCard(models.Model):
|
||||
"""Extend neutral card references with MTG-specific metadata."""
|
||||
|
||||
_inherit = "mvd.tcg.card"
|
||||
|
||||
is_mtg_game = fields.Boolean(compute="_compute_is_mtg_game")
|
||||
mtg_set_id = fields.Many2one(
|
||||
"mvd.tcg.mtg.set",
|
||||
string="Set",
|
||||
index=True,
|
||||
ondelete="restrict",
|
||||
)
|
||||
mtg_set_code = fields.Char(
|
||||
related="mtg_set_id.code",
|
||||
string="Set Code",
|
||||
store=True,
|
||||
index=True,
|
||||
)
|
||||
mtg_rarity_id = fields.Many2one(
|
||||
"mvd.tcg.mtg.rarity",
|
||||
string="Rarity",
|
||||
index=True,
|
||||
ondelete="restrict",
|
||||
)
|
||||
mtg_rarity_code = fields.Char(
|
||||
related="mtg_rarity_id.code",
|
||||
string="Rarity Code",
|
||||
store=True,
|
||||
index=True,
|
||||
)
|
||||
mtg_collector_number = fields.Char(string="Collector Number", index=True)
|
||||
mtg_collector_sort_key = fields.Char(
|
||||
string="Collector Sort Key",
|
||||
compute="_compute_mtg_collector_sort_key",
|
||||
store=True,
|
||||
index=True,
|
||||
)
|
||||
mtg_oracle_id = fields.Char(string="Oracle ID", index=True)
|
||||
mtg_layout = fields.Char(string="Layout", index=True)
|
||||
mtg_mana_cost = fields.Char(string="Mana Cost")
|
||||
mtg_mana_value = fields.Float(string="Mana Value")
|
||||
mtg_type_line = fields.Char(string="Type Line", translate=True)
|
||||
mtg_oracle_text = fields.Text(string="Oracle Text", translate=True)
|
||||
mtg_flavor_text = fields.Text(string="Flavor Text", translate=True)
|
||||
mtg_color_ids = fields.Many2many(
|
||||
"mvd.tcg.mtg.color",
|
||||
"mvd_tcg_card_mtg_color_rel",
|
||||
"card_id",
|
||||
"color_id",
|
||||
string="Colors",
|
||||
)
|
||||
mtg_color_identity_ids = fields.Many2many(
|
||||
"mvd.tcg.mtg.color",
|
||||
"mvd_tcg_card_mtg_color_identity_rel",
|
||||
"card_id",
|
||||
"color_id",
|
||||
string="Color Identity",
|
||||
)
|
||||
mtg_color_identity_signature = fields.Char(
|
||||
string="Color Identity Signature",
|
||||
compute="_compute_mtg_color_identity_signature",
|
||||
store=True,
|
||||
index=True,
|
||||
help="Ordered MTG color identity signature, for example W, UB or WUBRG.",
|
||||
)
|
||||
mtg_card_type_ids = fields.Many2many(
|
||||
"mvd.tcg.mtg.card.type",
|
||||
"mvd_tcg_card_mtg_card_type_rel",
|
||||
"card_id",
|
||||
"type_id",
|
||||
string="Card Types",
|
||||
)
|
||||
mtg_keyword_ids = fields.Many2many(
|
||||
"mvd.tcg.mtg.keyword",
|
||||
"mvd_tcg_card_mtg_keyword_rel",
|
||||
"card_id",
|
||||
"keyword_id",
|
||||
string="Keywords",
|
||||
)
|
||||
mtg_game_platform_ids = fields.Many2many(
|
||||
"mvd.tcg.mtg.platform",
|
||||
"mvd_tcg_card_mtg_platform_rel",
|
||||
"card_id",
|
||||
"platform_id",
|
||||
string="Games",
|
||||
)
|
||||
mtg_finish_ids = fields.Many2many(
|
||||
"mvd.tcg.mtg.finish",
|
||||
"mvd_tcg_card_mtg_finish_rel",
|
||||
"card_id",
|
||||
"finish_id",
|
||||
string="Finishes",
|
||||
)
|
||||
mtg_power = fields.Char(string="Power")
|
||||
mtg_toughness = fields.Char(string="Toughness")
|
||||
mtg_loyalty = fields.Char(string="Loyalty")
|
||||
mtg_artist = fields.Char(string="Artist", index="trigram")
|
||||
mtg_color_signature = fields.Char(
|
||||
string="Color Signature",
|
||||
compute="_compute_mtg_color_signature",
|
||||
store=True,
|
||||
index=True,
|
||||
help="Ordered MTG color signature, for example W, UB or WUBRG.",
|
||||
)
|
||||
mtg_face_ids = fields.One2many("mvd.tcg.mtg.card.face", "card_id", string="Faces")
|
||||
mtg_face_count = fields.Integer(
|
||||
string="Face Count",
|
||||
compute="_compute_mtg_face_count",
|
||||
store=True,
|
||||
)
|
||||
mtg_legality_ids = fields.One2many(
|
||||
"mvd.tcg.mtg.card.legality",
|
||||
"card_id",
|
||||
string="Legalities",
|
||||
)
|
||||
mtg_is_token = fields.Boolean(string="Token")
|
||||
mtg_is_reprint = fields.Boolean(string="Reprint")
|
||||
mtg_is_promo = fields.Boolean(string="Promo")
|
||||
mtg_is_digital = fields.Boolean(string="Digital")
|
||||
mtg_is_full_art = fields.Boolean(string="Full Art")
|
||||
mtg_is_textless = fields.Boolean(string="Textless")
|
||||
mtg_set_released_on = fields.Date(
|
||||
related="mtg_set_id.released_on",
|
||||
string="Released On",
|
||||
store=True,
|
||||
index=True,
|
||||
)
|
||||
mtg_set_type = fields.Char(
|
||||
related="mtg_set_id.set_type",
|
||||
string="Set Type",
|
||||
store=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
_mtg_set_collector_unique = models.Constraint(
|
||||
"UNIQUE (mtg_set_id, mtg_collector_number)",
|
||||
"The collector number must be unique inside an MTG set.",
|
||||
)
|
||||
|
||||
@api.depends("game_id.code")
|
||||
def _compute_is_mtg_game(self):
|
||||
"""Flag whether the current card belongs to the MTG adapter."""
|
||||
for card in self:
|
||||
card.is_mtg_game = card.game_id.code == "mtg"
|
||||
|
||||
@api.depends("mtg_collector_number")
|
||||
def _compute_mtg_collector_sort_key(self):
|
||||
"""Build a stable natural-sort key for MTG collector numbers.
|
||||
|
||||
Returns:
|
||||
None: The compute updates records in place.
|
||||
"""
|
||||
for card in self:
|
||||
collector_number = (card.mtg_collector_number or "").strip().lower()
|
||||
if not collector_number:
|
||||
card.mtg_collector_sort_key = "99999999:"
|
||||
continue
|
||||
|
||||
match = COLLECTOR_NUMBER_PATTERN.match(collector_number)
|
||||
if match:
|
||||
numeric_part, suffix = match.groups()
|
||||
card.mtg_collector_sort_key = (
|
||||
f"{int(numeric_part):08d}:{suffix.strip()}"
|
||||
)
|
||||
continue
|
||||
|
||||
card.mtg_collector_sort_key = f"99999999:{collector_number}"
|
||||
|
||||
@api.depends("mtg_color_ids", "mtg_color_ids.sequence", "mtg_color_ids.code")
|
||||
def _compute_mtg_color_signature(self):
|
||||
"""Build the canonical MTG color signature from selected colors.
|
||||
|
||||
Returns:
|
||||
None: The compute updates records in place.
|
||||
"""
|
||||
for card in self:
|
||||
colors = card.mtg_color_ids.sorted(
|
||||
key=lambda color: (color.sequence, color.code or "", color.id)
|
||||
)
|
||||
card.mtg_color_signature = "".join(
|
||||
(color.code or "").strip().upper() for color in colors
|
||||
) or False
|
||||
|
||||
@api.depends(
|
||||
"mtg_color_identity_ids",
|
||||
"mtg_color_identity_ids.sequence",
|
||||
"mtg_color_identity_ids.code",
|
||||
)
|
||||
def _compute_mtg_color_identity_signature(self):
|
||||
"""Build the canonical MTG color identity signature.
|
||||
|
||||
Returns:
|
||||
None: The compute updates records in place.
|
||||
"""
|
||||
for card in self:
|
||||
colors = card.mtg_color_identity_ids.sorted(
|
||||
key=lambda color: (color.sequence, color.code or "", color.id)
|
||||
)
|
||||
card.mtg_color_identity_signature = "".join(
|
||||
(color.code or "").strip().upper() for color in colors
|
||||
) or False
|
||||
|
||||
@api.depends("mtg_face_ids")
|
||||
def _compute_mtg_face_count(self):
|
||||
"""Compute how many explicit faces are linked to each card.
|
||||
|
||||
Returns:
|
||||
None: The compute updates records in place.
|
||||
"""
|
||||
face_data = self.env["mvd.tcg.mtg.card.face"]._read_group(
|
||||
[("card_id", "in", self.ids)],
|
||||
["card_id"],
|
||||
["__count"],
|
||||
)
|
||||
counts_by_card = {record.id: count for record, count in face_data}
|
||||
for card in self:
|
||||
card.mtg_face_count = counts_by_card.get(card.id, 0)
|
||||
|
||||
def action_open_mtg_set(self):
|
||||
"""Open the linked MTG set in form view.
|
||||
|
||||
Returns:
|
||||
dict: Window action for the linked MTG set.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.mtg_set_id:
|
||||
return False
|
||||
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": self.mtg_set_id.display_name,
|
||||
"res_model": "mvd.tcg.mtg.set",
|
||||
"view_mode": "form",
|
||||
"res_id": self.mtg_set_id.id,
|
||||
"target": "current",
|
||||
}
|
||||
|
||||
def mtg_get_rules_sections(self):
|
||||
"""Return one face-aware MTG rules structure for the current card.
|
||||
|
||||
Returns:
|
||||
list[dict[str, object]]: Ordered rules sections. Multi-face cards
|
||||
return one section per face, while single-face cards return one
|
||||
fallback section from the card header fields.
|
||||
"""
|
||||
self.ensure_one()
|
||||
face_sections = []
|
||||
for face in self.mtg_face_ids.sorted(lambda current_face: (current_face.sequence, current_face.id)):
|
||||
section = {
|
||||
"sequence": face.sequence,
|
||||
"name": face.name or False,
|
||||
"mana_cost": face.mana_cost or False,
|
||||
"type_line": face.type_line or False,
|
||||
"oracle_text": face.oracle_text or False,
|
||||
"flavor_text": face.flavor_text or False,
|
||||
"power": face.power or False,
|
||||
"toughness": face.toughness or False,
|
||||
"loyalty": face.loyalty or False,
|
||||
}
|
||||
if any(section.values()):
|
||||
face_sections.append(section)
|
||||
if face_sections:
|
||||
return face_sections
|
||||
return [
|
||||
{
|
||||
"sequence": 10,
|
||||
"name": self.name or False,
|
||||
"mana_cost": self.mtg_mana_cost or False,
|
||||
"type_line": self.mtg_type_line or False,
|
||||
"oracle_text": self.mtg_oracle_text or False,
|
||||
"flavor_text": self.mtg_flavor_text or False,
|
||||
"power": self.mtg_power or False,
|
||||
"toughness": self.mtg_toughness or False,
|
||||
"loyalty": self.mtg_loyalty or False,
|
||||
}
|
||||
]
|
||||
|
||||
def mtg_get_rules_summary(self):
|
||||
"""Return one analysis-friendly MTG rules summary.
|
||||
|
||||
Returns:
|
||||
str | bool: Single-face cards return their oracle text directly.
|
||||
Multi-face cards return a combined face-aware rules summary.
|
||||
"""
|
||||
self.ensure_one()
|
||||
rule_sections = self.mtg_get_rules_sections()
|
||||
if len(rule_sections) <= 1:
|
||||
return rule_sections and rule_sections[0]["oracle_text"] or False
|
||||
|
||||
rendered_sections = []
|
||||
for section in rule_sections:
|
||||
section_lines = []
|
||||
heading_parts = [section["name"]]
|
||||
if section["mana_cost"]:
|
||||
heading_parts.append(section["mana_cost"])
|
||||
heading = " ".join(part for part in heading_parts if part)
|
||||
if heading:
|
||||
section_lines.append(heading)
|
||||
if section["type_line"]:
|
||||
section_lines.append(section["type_line"])
|
||||
if section["oracle_text"]:
|
||||
section_lines.append(section["oracle_text"])
|
||||
if section_lines:
|
||||
rendered_sections.append("\n".join(section_lines))
|
||||
return "\n\n//\n\n".join(rendered_sections) or False
|
||||
|
||||
def mtg_get_singleton_key_aliases(self):
|
||||
"""Return stable MTG identity aliases across printings and styles.
|
||||
|
||||
Returns:
|
||||
tuple[str, ...]: Ordered identity aliases. Cards with a missing
|
||||
Oracle ID still expose rules-based fallbacks that can match
|
||||
styled variants of the same card.
|
||||
"""
|
||||
self.ensure_one()
|
||||
english_card = self.with_context(lang="en_US")
|
||||
aliases = []
|
||||
oracle_id = (english_card.mtg_oracle_id or "").strip()
|
||||
if oracle_id:
|
||||
aliases.append(f"oracle:{oracle_id}")
|
||||
|
||||
rules_summary = re.sub(
|
||||
r"\s+",
|
||||
" ",
|
||||
(english_card.mtg_get_rules_summary() or "").strip(),
|
||||
).strip()
|
||||
if rules_summary:
|
||||
aliases.append(f"rules:{rules_summary}")
|
||||
|
||||
deduplicated_oracle_chunks = []
|
||||
for section in english_card.mtg_get_rules_sections():
|
||||
oracle_text = re.sub(
|
||||
r"\s+",
|
||||
" ",
|
||||
(section["oracle_text"] or "").strip(),
|
||||
).strip()
|
||||
if oracle_text and oracle_text not in deduplicated_oracle_chunks:
|
||||
deduplicated_oracle_chunks.append(oracle_text)
|
||||
if deduplicated_oracle_chunks:
|
||||
aliases.append(f"oracletext:{' // '.join(deduplicated_oracle_chunks)}")
|
||||
|
||||
face_names = " // ".join(
|
||||
section["name"]
|
||||
for section in english_card.mtg_get_rules_sections()
|
||||
if section["name"]
|
||||
)
|
||||
fallback_name = face_names or (english_card.name or "").strip()
|
||||
fallback_type = (english_card.mtg_type_line or "").strip()
|
||||
fallback_cost = (english_card.mtg_mana_cost or "").strip()
|
||||
if any((fallback_name, fallback_type, fallback_cost)):
|
||||
aliases.append(f"fallback:{fallback_name}|{fallback_type}|{fallback_cost}")
|
||||
|
||||
deduplicated_aliases = []
|
||||
for alias in aliases:
|
||||
if alias and alias not in deduplicated_aliases:
|
||||
deduplicated_aliases.append(alias)
|
||||
return tuple(deduplicated_aliases)
|
||||
|
||||
def mtg_get_singleton_key(self):
|
||||
"""Return the primary MTG singleton key for the current card.
|
||||
|
||||
Returns:
|
||||
str | bool: First stable singleton alias, if any.
|
||||
"""
|
||||
self.ensure_one()
|
||||
aliases = self.mtg_get_singleton_key_aliases()
|
||||
return aliases[0] if aliases else False
|
||||
|
||||
def mtg_allows_unlimited_copies(self):
|
||||
"""Return whether one MTG card bypasses singleton restrictions.
|
||||
|
||||
Returns:
|
||||
bool: ``True`` for basic lands and cards with explicit unlimited-
|
||||
copy rules text.
|
||||
"""
|
||||
self.ensure_one()
|
||||
english_card = self.with_context(lang="en_US")
|
||||
combined_type_line = " ".join(
|
||||
section["type_line"]
|
||||
for section in english_card.mtg_get_rules_sections()
|
||||
if section["type_line"]
|
||||
).lower()
|
||||
rules_summary = (english_card.mtg_get_rules_summary() or "").lower()
|
||||
is_basic_land = "land" in combined_type_line and "basic" in combined_type_line
|
||||
allows_unlimited_copies = (
|
||||
"a deck can have any number of cards named" in rules_summary
|
||||
)
|
||||
return is_basic_land or allows_unlimited_copies
|
||||
|
||||
@api.model
|
||||
def _mtg_exact_color_signature_from_ids(self, color_ids):
|
||||
"""Build the canonical color signature for selected color records.
|
||||
|
||||
Args:
|
||||
color_ids: MTG color record ids from a filter domain.
|
||||
|
||||
Returns:
|
||||
str | bool: Canonical signature like ``WB`` or ``False`` when invalid.
|
||||
"""
|
||||
normalized_ids = [int(color_id) for color_id in color_ids or [] if color_id]
|
||||
if not normalized_ids:
|
||||
return False
|
||||
|
||||
colors = self.env["mvd.tcg.mtg.color"].browse(normalized_ids).exists().sorted(
|
||||
key=lambda color: (color.sequence, color.code or "", color.id)
|
||||
)
|
||||
if len(colors) != len(set(normalized_ids)):
|
||||
return False
|
||||
return "".join((color.code or "").strip().upper() for color in colors)
|
||||
|
||||
@api.model
|
||||
def _mtg_transform_exact_color_domain(self, domain):
|
||||
"""Rewrite MTG color filters into exact color-signature filters.
|
||||
|
||||
Args:
|
||||
domain: Original ORM domain.
|
||||
|
||||
Returns:
|
||||
list: Domain with exact MTG color matching where applicable.
|
||||
"""
|
||||
domain_object = fields.Domain(domain or [])
|
||||
selected_color_ids = []
|
||||
|
||||
for condition in domain_object.iter_conditions():
|
||||
if condition.field_expr != "mtg_color_ids":
|
||||
continue
|
||||
if condition.operator == "=" and condition.value:
|
||||
color_id = int(condition.value)
|
||||
if color_id not in selected_color_ids:
|
||||
selected_color_ids.append(color_id)
|
||||
continue
|
||||
if condition.operator == "in" and isinstance(condition.value, (list, tuple)):
|
||||
for color_id in condition.value:
|
||||
normalized_id = int(color_id)
|
||||
if normalized_id not in selected_color_ids:
|
||||
selected_color_ids.append(normalized_id)
|
||||
|
||||
if not selected_color_ids:
|
||||
return domain
|
||||
|
||||
exact_signature = self._mtg_exact_color_signature_from_ids(selected_color_ids)
|
||||
if not exact_signature:
|
||||
return list(fields.Domain.FALSE)
|
||||
|
||||
remaining_domain = domain_object.map_conditions(
|
||||
lambda condition: (
|
||||
fields.Domain.TRUE
|
||||
if condition.field_expr == "mtg_color_ids"
|
||||
else fields.Domain(condition)
|
||||
)
|
||||
).optimize(self)
|
||||
exact_domain = fields.Domain(
|
||||
[("mtg_color_signature", "=", exact_signature)]
|
||||
)
|
||||
return list(fields.Domain.AND([remaining_domain, exact_domain]).optimize(self))
|
||||
|
||||
@api.model
|
||||
def _search(
|
||||
self,
|
||||
domain,
|
||||
offset=0,
|
||||
limit=None,
|
||||
order=None,
|
||||
*,
|
||||
active_test=True,
|
||||
bypass_access=False,
|
||||
):
|
||||
"""Apply exact MTG color matching for the Magic card action.
|
||||
|
||||
Args:
|
||||
domain: ORM domain for the current search.
|
||||
offset: Search offset.
|
||||
limit: Optional maximal number of records.
|
||||
order: SQL order clause.
|
||||
active_test: Whether active records should be filtered implicitly.
|
||||
bypass_access: Whether access rules should be bypassed.
|
||||
|
||||
Returns:
|
||||
Query: Matching search query.
|
||||
"""
|
||||
if self.env.context.get("mvd_mtg_exact_color_filter"):
|
||||
domain = self._mtg_transform_exact_color_domain(domain)
|
||||
return super()._search(
|
||||
domain,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
order=order,
|
||||
active_test=active_test,
|
||||
bypass_access=bypass_access,
|
||||
)
|
||||
Reference in New Issue
Block a user