"""QWeb report helpers for deck exports.""" import base64 from collections import defaultdict from functools import lru_cache from pathlib import Path import re from markupsafe import Markup, escape from odoo import api, models class ReportMvdTcgDeck(models.AbstractModel): """Prepare stable, wkhtmltopdf-friendly data for deck reports.""" _name = "report.mvd_tcg_deck.report_mvd_tcg_deck_document" _description = "TCG Deck Report" _MANA_SYMBOL_FILENAME_MAP = { "∞": "INFINITY", "½": "HALF", } _COLOR_BADGE_MAP = { "W": { "background": "#f8f2d4", "border": "#d9c676", "text": "#5c5020", }, "U": { "background": "#d8ebfb", "border": "#8bb4dc", "text": "#1d4874", }, "B": { "background": "#d9d7de", "border": "#a39ba8", "text": "#2f2a33", }, "R": { "background": "#f6d8d2", "border": "#d78f81", "text": "#742c1f", }, "G": { "background": "#dcebd8", "border": "#9ebc91", "text": "#274f2e", }, "C": { "background": "#eef1f4", "border": "#c7cfd8", "text": "#46525f", }, } _COLOR_ORDER = "WUBRGC" @staticmethod @lru_cache(maxsize=256) def _get_symbol_data_uri(filename): """Return one local MTG symbol asset as an inline data URI. Args: filename: Symbol filename stem such as ``W`` or ``2W``. Returns: str: Base64-encoded SVG data URI for direct report embedding. """ addons_root = Path(__file__).resolve().parents[2] symbol_path = ( addons_root / "mvd_tcg_mtg" / "static" / "src" / "img" / "card-symbols" / f"{filename}.svg" ) svg_bytes = symbol_path.read_bytes() encoded = base64.b64encode(svg_bytes).decode("ascii") return f"data:image/svg+xml;base64,{encoded}" @staticmethod @lru_cache(maxsize=64) def _get_raster_symbol_data_uri(filename): """Return one local raster MTG symbol asset as an inline data URI. Args: filename: Symbol filename stem such as ``W``. Returns: str: Base64-encoded PNG data URI for direct report embedding. """ addons_root = Path(__file__).resolve().parents[2] symbol_path = ( addons_root / "mvd_tcg_mtg" / "static" / "src" / "img" / "card-symbols-report" / f"{filename}.png" ) png_bytes = symbol_path.read_bytes() encoded = base64.b64encode(png_bytes).decode("ascii") return f"data:image/png;base64,{encoded}" @staticmethod @lru_cache(maxsize=256) def _get_report_symbol_data_uri(filename): """Return the most stable report symbol asset as an inline data URI. Args: filename: Symbol filename stem such as ``W`` or ``T``. Returns: str: Prefer a raster PNG data URI when available, else fall back to the SVG asset. """ addons_root = Path(__file__).resolve().parents[2] raster_path = ( addons_root / "mvd_tcg_mtg" / "static" / "src" / "img" / "card-symbols-report" / f"{filename}.png" ) if raster_path.exists(): return ReportMvdTcgDeck._get_raster_symbol_data_uri(filename) return ReportMvdTcgDeck._get_symbol_data_uri(filename) @api.model def _get_board_sections(self, deck): """Return ordered board sections with role-grouped deck lines. Args: deck: Deck record that should be rendered. Returns: list[dict]: Section dictionaries for the report template. """ sections = [] for board in deck.board_ids.sorted( lambda current_board: (current_board.sequence, current_board.id) ): grouped_lines = self._get_board_line_groups(board) sections.append( { "board": board, "lines": board.line_ids.sorted( lambda line: (line.sequence, line.card_id.display_name or "", line.id) ), "line_groups": grouped_lines, "include_in_total": board.include_in_total, "note": board.note, "total_card_count": board.total_card_count, "distinct_card_count": board.distinct_card_count, } ) return sections @api.model def _get_board_line_groups(self, board): """Group one board's lines by primary role and sorted card order. Args: board: Deck board record that should be rendered. Returns: list[dict]: Ordered groups with ordered line records. """ grouped_lines = defaultdict(list) role_labels = {} role_order = {} for line in board.line_ids.filtered("card_id"): primary_role = line.role_ids.sorted( key=lambda role: (role.sequence, role.name or "", role.id) )[:1] if primary_role: role_key = primary_role.id role_labels[role_key] = primary_role.name role_order[role_key] = (0, primary_role.sequence, primary_role.name or "", primary_role.id) else: role_key = "unassigned" role_labels[role_key] = "Unassigned" role_order[role_key] = (1, 9999, "Unassigned", 0) grouped_lines[role_key].append(line) ordered_groups = [] for role_key in sorted(grouped_lines, key=lambda current_key: role_order[current_key]): lines = sorted( grouped_lines[role_key], key=lambda line: ( getattr(line, "mtg_mana_value", 0.0), line.card_id.display_name or "", getattr(line, "mtg_collector_number", "") or "", line.id, ), ) ordered_groups.append( { "key": role_key, "label": role_labels[role_key], "lines": lines, "total_card_count": sum(line.quantity for line in lines), "distinct_card_count": len(lines), } ) return ordered_groups @api.model def _get_color_badges(self, signature): """Return MTG color symbol descriptors for one color identity signature. Args: signature: Compact MTG color signature such as ``WUB``. Returns: list[dict]: Symbol descriptors with local static asset paths. """ badges = [] for code in (signature or "").strip().upper(): if code not in {"W", "U", "B", "R", "G", "C"}: continue palette = self._COLOR_BADGE_MAP[code] badges.append( { "label": code, "src": self._get_raster_symbol_data_uri(code), "background": palette["background"], "border": palette["border"], "text": palette["text"], } ) return badges @api.model def _get_report_overview_markup(self, deck): """Return one compact overview block for the report cover page. Args: deck: Deck record rendered in the report. Returns: Markup | bool: First paragraph of the deck description, or ``False``. """ html_value = (deck.description or "").strip() if not html_value: return False paragraph_match = re.search( r"
]*>.*?
", html_value, flags=re.IGNORECASE | re.DOTALL, ) if paragraph_match: plain_text = re.sub(r"<[^>]+>", " ", paragraph_match.group(0)) plain_text = " ".join(plain_text.split()) if not plain_text: return False return Markup(f"{escape(plain_text)}
") plain_text = re.sub(r"<[^>]+>", " ", html_value) plain_text = " ".join(plain_text.split()) if not plain_text: return False return Markup(f"{escape(plain_text)}
") @api.model def _get_mana_color_signature(self, mana_cost): """Return the ordered color signature implied by one mana cost. Args: mana_cost: Raw Scryfall mana string such as ``{2}{U}{R}``. Returns: str: Ordered distinct MTG color signature such as ``UR``. """ colors = [] for token in re.findall(r"\{([^}]+)\}", mana_cost or ""): normalized_token = (token or "").strip().upper() for color_code in "WUBRGC": if color_code in normalized_token and color_code not in colors: colors.append(color_code) return self._normalize_color_signature("".join(colors)) @api.model def _normalize_color_signature(self, signature): """Normalize one MTG color signature to the canonical WUBRGC order. Args: signature: Raw color signature such as ``URW``. Returns: str: Canonically ordered signature such as ``WUR``. """ normalized = [] for color_code in self._COLOR_ORDER: if color_code in (signature or "").upper() and color_code not in normalized: normalized.append(color_code) return "".join(normalized) @api.model def _get_line_color_badges(self, line): """Return color badges only when they add information beyond mana cost. Args: line: Deck line record rendered in the report. Returns: list[dict]: Color badge descriptors for the current line. """ signature = ( line.card_id.mtg_color_signature or line.card_id.mtg_color_identity_signature or "" ).strip().upper() signature = self._normalize_color_signature(signature) if not signature or line.board_id.code == "command_zone": return [] mana_signature = self._get_mana_color_signature(getattr(line, "mtg_mana_cost", False)) if mana_signature and mana_signature == signature: return [] return self._get_color_badges(signature) @api.model def _get_mana_symbols(self, mana_cost): """Return static symbol asset descriptors for one mana cost. Args: mana_cost: Raw Scryfall mana string such as ``{1}{W}{U}``. Returns: list[dict]: Ordered symbol descriptors for report rendering. """ tokens = re.findall(r"\{([^}]+)\}", mana_cost or "") symbols = [] for token in tokens: normalized_token = (token or "").strip().upper() filename = self._MANA_SYMBOL_FILENAME_MAP.get( normalized_token, normalized_token.replace("/", ""), ) symbols.append( { "label": normalized_token, "src": self._get_report_symbol_data_uri(filename), } ) return symbols @api.model def _render_mtg_rules_text(self, rules_text): """Render MTG rules text with inline mana and action symbols. Args: rules_text: Raw MTG oracle or rules text that may contain Scryfall symbol tokens such as ``{W}`` or ``{T}``. Returns: Markup: Safe HTML with inline symbol images and line breaks. """ if not rules_text: return Markup("") rendered_chunks = [] for chunk in re.split(r"(\{[^}]+\})", rules_text): if not chunk: continue if chunk.startswith("{") and chunk.endswith("}"): symbol_markup = self._render_mtg_inline_symbols(chunk) if symbol_markup: rendered_chunks.append(symbol_markup) continue rendered_chunks.append(str(escape(chunk)).replace("\n", "