🎉 Initialize module repository

This commit is contained in:
Marc Wempe
2026-04-03 23:08:57 +02:00
commit d81e8a87e3
25 changed files with 4584 additions and 0 deletions

View File

@@ -0,0 +1,509 @@
"""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,
}