510 lines
17 KiB
Python
510 lines
17 KiB
Python
"""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"<p\b[^>]*>.*?</p>",
|
|
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"<p>{escape(plain_text)}</p>")
|
|
plain_text = re.sub(r"<[^>]+>", " ", html_value)
|
|
plain_text = " ".join(plain_text.split())
|
|
if not plain_text:
|
|
return False
|
|
return Markup(f"<p>{escape(plain_text)}</p>")
|
|
|
|
@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", "<br/>"))
|
|
return Markup("".join(rendered_chunks))
|
|
|
|
@api.model
|
|
def _render_mtg_inline_symbols(self, mana_cost):
|
|
"""Render one or more MTG symbol tokens as inline report HTML.
|
|
|
|
Args:
|
|
mana_cost: Token string such as ``{1}{U}`` or ``{T}``.
|
|
|
|
Returns:
|
|
Markup | str: Inline HTML markup or an empty string if unresolved.
|
|
"""
|
|
symbols = self._get_mana_symbols(mana_cost)
|
|
if not symbols:
|
|
return ""
|
|
rendered = []
|
|
for symbol in symbols:
|
|
rendered.append(
|
|
(
|
|
'<span class="o_mvd_tcg_report_oracle_symbol_frame">'
|
|
'<img class="o_mvd_tcg_report_oracle_symbol_icon" '
|
|
f'src="{escape(symbol["src"])}" '
|
|
f'alt="{escape(symbol["label"])}" '
|
|
f'title="{escape(symbol["label"])}"/>'
|
|
"</span>"
|
|
)
|
|
)
|
|
return Markup("".join(rendered))
|
|
|
|
@api.model
|
|
def _get_line_rule_sections(self, line):
|
|
"""Return face-aware MTG rules sections for one report line.
|
|
|
|
Args:
|
|
line: Deck line record rendered in the report.
|
|
|
|
Returns:
|
|
list[dict[str, object]]: Ordered rules sections for the current
|
|
line's card. Non-MTG lines fall back to an empty list.
|
|
"""
|
|
card = line.card_id
|
|
if not card:
|
|
return []
|
|
get_sections = getattr(card, "mtg_get_rules_sections", False)
|
|
if not callable(get_sections):
|
|
return []
|
|
return get_sections()
|
|
|
|
@api.model
|
|
def _get_mtg_type_breakdown(self, deck):
|
|
"""Return a report-friendly MTG type breakdown.
|
|
|
|
Args:
|
|
deck: Deck record that should be rendered.
|
|
|
|
Returns:
|
|
list[dict]: Ordered MTG type rows with relative bar widths.
|
|
"""
|
|
metric_rows = [
|
|
("Creatures", deck.mtg_creature_count, "#2f855a"),
|
|
("Instants", deck.mtg_instant_count, "#3182ce"),
|
|
("Sorceries", deck.mtg_sorcery_count, "#805ad5"),
|
|
("Artifacts", deck.mtg_artifact_count, "#4a5568"),
|
|
("Enchantments", deck.mtg_enchantment_count, "#b7791f"),
|
|
("Planeswalkers", deck.mtg_planeswalker_count, "#c05621"),
|
|
("Lands", deck.mtg_land_count, "#718096"),
|
|
]
|
|
max_value = max((row[1] for row in metric_rows), default=0) or 1
|
|
return [
|
|
{
|
|
"label": label,
|
|
"value": value,
|
|
"accent": accent,
|
|
"bar_width": round((value / max_value) * 100, 2) if value else 0.0,
|
|
}
|
|
for label, value, accent in metric_rows
|
|
]
|
|
|
|
@api.model
|
|
def _get_role_summary(self, deck):
|
|
"""Aggregate role usage across the whole deck.
|
|
|
|
Args:
|
|
deck: Deck record that should be rendered.
|
|
|
|
Returns:
|
|
list[dict]: Ordered role usage counters.
|
|
"""
|
|
role_totals = defaultdict(lambda: {"line_count": 0, "quantity_total": 0})
|
|
for line in deck.line_ids:
|
|
for role in line.role_ids:
|
|
role_totals[role]["line_count"] += 1
|
|
role_totals[role]["quantity_total"] += line.quantity
|
|
return [
|
|
{
|
|
"role": role,
|
|
"line_count": counters["line_count"],
|
|
"quantity_total": counters["quantity_total"],
|
|
}
|
|
for role, counters in sorted(
|
|
role_totals.items(),
|
|
key=lambda item: (item[0].sequence, item[0].name or "", item[0].id),
|
|
)
|
|
]
|
|
|
|
@api.model
|
|
def _get_report_values(self, docids, data=None):
|
|
"""Build the report context for QWeb rendering.
|
|
|
|
Args:
|
|
docids: Selected deck identifiers.
|
|
data: Optional wizard payload passed by Odoo.
|
|
|
|
Returns:
|
|
dict: Context consumed by the QWeb template.
|
|
"""
|
|
docs = self.env["mvd.tcg.deck"].browse(docids)
|
|
return {
|
|
"doc_ids": docs.ids,
|
|
"doc_model": "mvd.tcg.deck",
|
|
"docs": docs,
|
|
"data": data or {},
|
|
"get_board_sections": self._get_board_sections,
|
|
"get_color_badges": self._get_color_badges,
|
|
"get_line_color_badges": self._get_line_color_badges,
|
|
"get_line_rule_sections": self._get_line_rule_sections,
|
|
"get_mana_symbols": self._get_mana_symbols,
|
|
"get_mtg_type_breakdown": self._get_mtg_type_breakdown,
|
|
"render_mtg_rules_text": self._render_mtg_rules_text,
|
|
"get_role_summary": self._get_role_summary,
|
|
"get_report_overview_markup": self._get_report_overview_markup,
|
|
}
|