🎉 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

1
report/__init__.py Normal file
View File

@@ -0,0 +1 @@
from . import mvd_tcg_deck_report

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,
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="paperformat_mvd_tcg_deck" model="report.paperformat">
<field name="name">MVD TCG Deck Report</field>
<field name="default" eval="False"/>
<field name="format">A4</field>
<field name="orientation">Portrait</field>
<field name="margin_top">8</field>
<field name="margin_bottom">8</field>
<field name="margin_left">7</field>
<field name="margin_right">7</field>
<field name="header_line" eval="False"/>
<field name="header_spacing">0</field>
<field name="dpi">96</field>
</record>
<record id="action_report_mvd_tcg_deck" model="ir.actions.report">
<field name="name">Deck Report</field>
<field name="model">mvd.tcg.deck</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">mvd_tcg_deck.report_mvd_tcg_deck_document</field>
<field name="report_file">mvd_tcg_deck.report_mvd_tcg_deck_document</field>
<field name="print_report_name">'Deck - %s' % (object.name)</field>
<field name="paperformat_id" ref="mvd_tcg_deck.paperformat_mvd_tcg_deck"/>
<field name="binding_model_id" ref="mvd_tcg_deck.model_mvd_tcg_deck"/>
<field name="binding_type">report</field>
</record>
</odoo>

View File

@@ -0,0 +1,763 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_mvd_tcg_deck_document">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-set="board_sections" t-value="get_board_sections(doc)"/>
<t t-set="role_summary" t-value="get_role_summary(doc)"/>
<div
class="article o_mvd_tcg_report_article"
t-att-data-oe-model="doc._name"
t-att-data-oe-id="doc.id"
t-att-data-oe-lang="doc.env.context.get('lang')"
>
<style type="text/css">
.o_mvd_tcg_report_article {
padding: 0;
}
.o_mvd_tcg_report {
color: #17212b;
font-size: 10.5px;
line-height: 1.42;
}
.o_mvd_tcg_report table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.o_mvd_tcg_report.page,
.o_mvd_tcg_report .page {
background: #ffffff;
}
.o_mvd_tcg_report .o_mvd_tcg_report_hero {
margin: 0 0 10px;
background: #ffffff;
color: #0f172a;
border-radius: 12px;
overflow: hidden;
border: 1px solid #d9e2ec;
border-top: 4px solid #4f46e5;
}
.o_mvd_tcg_report .o_mvd_tcg_report_hero td {
vertical-align: top;
padding: 0;
}
.o_mvd_tcg_report .o_mvd_tcg_report_hero_main {
padding: 12px 14px 10px;
}
.o_mvd_tcg_report .o_mvd_tcg_report_hero_cover {
width: 5.8cm;
padding: 10px 12px 10px 0;
text-align: right;
}
.o_mvd_tcg_report .o_mvd_tcg_report_eyebrow {
margin: 0 0 6px;
font-size: 9px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #4f46e5;
}
.o_mvd_tcg_report .o_mvd_tcg_report_title {
margin: 0 0 6px;
font-size: 24px;
line-height: 1.1;
font-weight: 700;
color: #0f172a;
}
.o_mvd_tcg_report .o_mvd_tcg_report_subtitle {
margin: 0 0 8px;
color: #52606d;
}
.o_mvd_tcg_report .o_mvd_tcg_report_chip {
display: inline-block;
margin: 0 6px 6px 0;
padding: 4px 10px;
border: 1px solid #d9e2ec;
border-radius: 999px;
background: #f8fafc;
color: #334e68;
white-space: nowrap;
}
.o_mvd_tcg_report .o_mvd_tcg_report_chip strong {
color: #102a43;
}
.o_mvd_tcg_report .o_mvd_tcg_report_cover {
max-width: 5.2cm;
max-height: 7.3cm;
border-radius: 10px;
border: 1px solid #cbd5e1;
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.12);
}
.o_mvd_tcg_report .o_mvd_tcg_report_hero .o_mvd_tcg_report_kpi_grid {
margin-top: 8px;
}
.o_mvd_tcg_report .o_mvd_tcg_report_section {
margin: 0 0 8px;
padding: 8px 10px;
border: 1px solid #dde5f0;
border-radius: 10px;
background: #ffffff;
}
.o_mvd_tcg_report .o_mvd_tcg_report_section_emphasis {
border-color: #d9e2ec;
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
}
.o_mvd_tcg_report .o_mvd_tcg_report_section_title {
margin: 0 0 6px;
font-size: 13px;
line-height: 1.2;
color: #0f172a;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.o_mvd_tcg_report .o_mvd_tcg_report_section_intro {
margin: 0 0 6px;
color: #5b6777;
font-size: 9px;
}
.o_mvd_tcg_report .o_mvd_tcg_report_kpi_grid td {
width: 25%;
padding: 0 8px 0 0;
vertical-align: top;
}
.o_mvd_tcg_report .o_mvd_tcg_report_kpi_grid td:last-child {
padding-right: 0;
}
.o_mvd_tcg_report .o_mvd_tcg_report_kpi {
min-height: 40px;
padding: 6px 8px;
border: 1px solid #dde5f0;
border-radius: 10px;
background: #ffffff;
}
.o_mvd_tcg_report .o_mvd_tcg_report_kpi_label {
margin: 0 0 4px;
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #748195;
}
.o_mvd_tcg_report .o_mvd_tcg_report_kpi_value {
margin: 0;
font-size: 15px;
line-height: 1.1;
font-weight: 700;
color: #0f172a;
}
.o_mvd_tcg_report .o_mvd_tcg_report_kpi_hint {
margin: 2px 0 0;
color: #64748b;
font-size: 9px;
}
.o_mvd_tcg_report .o_mvd_tcg_report_snapshot td {
width: 50%;
padding: 0 10px 0 0;
vertical-align: top;
}
.o_mvd_tcg_report .o_mvd_tcg_report_snapshot td:last-child {
padding-right: 0;
}
.o_mvd_tcg_report .o_mvd_tcg_report_snapshot_block {
min-height: 56px;
padding: 8px 10px;
border-radius: 10px;
background: #fbfdff;
border: 1px solid #dde5f0;
}
.o_mvd_tcg_report .o_mvd_tcg_report_snapshot_block h4 {
margin: 0 0 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #334155;
}
.o_mvd_tcg_report .o_mvd_tcg_report_snapshot_block p {
margin: 0;
}
.o_mvd_tcg_report .o_mvd_tcg_report_snapshot_note {
white-space: pre-line;
color: #52606d;
}
.o_mvd_tcg_report .o_mvd_tcg_report_data_table th,
.o_mvd_tcg_report .o_mvd_tcg_report_data_table td {
padding: 6px 8px;
border-bottom: 1px solid #e5edf5;
vertical-align: top;
}
.o_mvd_tcg_report .o_mvd_tcg_report_data_table thead th {
background: #ecf2f9;
color: #334155;
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.o_mvd_tcg_report .o_mvd_tcg_report_data_table tbody tr:nth-child(even) td {
background: #fbfdff;
}
.o_mvd_tcg_report .o_mvd_tcg_report_data_table tbody tr:last-child td {
border-bottom: 0;
}
.o_mvd_tcg_report .o_mvd_tcg_report_roles_list span {
display: inline-block;
margin: 0 5px 5px 0;
padding: 3px 8px;
border: 1px solid #d9e2ec;
border-radius: 999px;
background: #ffffff;
white-space: nowrap;
}
.o_mvd_tcg_report .o_mvd_tcg_report_pill {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 700;
white-space: nowrap;
}
.o_mvd_tcg_report .o_mvd_tcg_report_pill_success {
background: #dcfce7;
color: #166534;
}
.o_mvd_tcg_report .o_mvd_tcg_report_pill_muted {
background: #e5edf5;
color: #486581;
}
.o_mvd_tcg_report .o_mvd_tcg_report_board_header {
margin: 0 0 12px;
padding: 14px 16px;
border-radius: 10px;
background: #ffffff;
color: #0f172a;
border: 1px solid #dde5f0;
border-left: 4px solid #4f46e5;
}
.o_mvd_tcg_report .o_mvd_tcg_report_board_header h2 {
margin: 0 0 8px;
font-size: 18px;
line-height: 1.1;
}
.o_mvd_tcg_report .o_mvd_tcg_report_board_header p {
margin: 0;
color: #52606d;
}
.o_mvd_tcg_report .o_mvd_tcg_report_board_meta_table td {
width: 33.33%;
padding: 0 10px 0 0;
vertical-align: top;
}
.o_mvd_tcg_report .o_mvd_tcg_report_board_meta_table td:last-child {
padding-right: 0;
}
.o_mvd_tcg_report .o_mvd_tcg_report_board_meta_card {
padding: 10px 12px;
border: 1px solid #dde5f0;
border-radius: 10px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.o_mvd_tcg_report .o_mvd_tcg_report_board_meta_card .o_mvd_tcg_report_kpi_value {
font-size: 18px;
}
.o_mvd_tcg_report .o_mvd_tcg_report_card_name {
font-weight: 700;
color: #102a43;
line-height: 1.15;
}
.o_mvd_tcg_report .o_mvd_tcg_report_card_name_text {
display: inline;
}
.o_mvd_tcg_report .o_mvd_tcg_report_inline_symbol_group {
display: inline-block;
margin-left: 4px;
vertical-align: text-bottom;
white-space: nowrap;
line-height: 0;
}
.o_mvd_tcg_report .o_mvd_tcg_report_inline_symbol_group + .o_mvd_tcg_report_inline_symbol_group {
margin-left: 6px;
}
.o_mvd_tcg_report .o_mvd_tcg_report_group_heading {
margin: 0 0 6px;
padding: 7px 10px;
border-left: 4px solid #4f46e5;
border-radius: 8px;
background: #f8fbff;
}
.o_mvd_tcg_report .o_mvd_tcg_report_group_heading strong {
color: #0f172a;
}
.o_mvd_tcg_report .o_mvd_tcg_report_group_heading span {
color: #64748b;
margin-left: 8px;
font-size: 9px;
}
.o_mvd_tcg_report .o_mvd_tcg_report_card_thumb {
width: 15mm;
text-align: center;
vertical-align: top;
}
.o_mvd_tcg_report .o_mvd_tcg_report_card_thumb img {
display: block;
width: 12mm;
height: auto;
border-radius: 4px;
border: 1px solid #dbe4ee;
}
.o_mvd_tcg_report .o_mvd_tcg_report_qty_cell {
width: 8mm;
text-align: center;
vertical-align: top;
font-weight: 700;
color: #102a43;
}
.o_mvd_tcg_report .o_mvd_tcg_report_card_table th,
.o_mvd_tcg_report .o_mvd_tcg_report_card_table td {
padding: 4px 6px;
}
.o_mvd_tcg_report .o_mvd_tcg_report_card_meta {
margin-top: 2px;
color: #52606d;
font-size: 8.6px;
line-height: 1.2;
}
.o_mvd_tcg_report .o_mvd_tcg_report_oracle {
margin-top: 2px;
color: #334155;
font-size: 8.2px;
line-height: 1.2;
white-space: pre-line;
}
.o_mvd_tcg_report .o_mvd_tcg_report_face_block {
margin-top: 4px;
padding-top: 4px;
border-top: 1px dashed #dbe4ee;
}
.o_mvd_tcg_report .o_mvd_tcg_report_face_block:first-child {
margin-top: 2px;
padding-top: 0;
border-top: 0;
}
.o_mvd_tcg_report .o_mvd_tcg_report_face_title {
font-weight: 700;
color: #102a43;
line-height: 1.15;
}
.o_mvd_tcg_report .o_mvd_tcg_report_oracle_symbol_frame {
display: inline-block;
width: 6.4px;
height: 6.4px;
margin: 0 1px;
vertical-align: 0;
line-height: 0;
}
.o_mvd_tcg_report .o_mvd_tcg_report_oracle_symbol_icon {
display: block !important;
width: 6.4px !important;
height: 6.4px !important;
min-width: 6.4px;
min-height: 6.4px;
max-width: 6.4px !important;
max-height: 6.4px !important;
vertical-align: top;
}
.o_mvd_tcg_report .o_mvd_tcg_report_callout {
padding: 12px 14px;
border-left: 4px solid #4f46e5;
border-radius: 10px;
background: #f5f7ff;
color: #3730a3;
}
.o_mvd_tcg_report .o_mvd_tcg_report_symbol_group {
display: inline-block;
vertical-align: middle;
white-space: nowrap;
line-height: 0;
}
.o_mvd_tcg_report .o_mvd_tcg_report_symbol_frame {
display: inline-block;
width: 15px;
height: 15px;
margin-right: 2px;
vertical-align: middle;
line-height: 0;
}
.o_mvd_tcg_report .o_mvd_tcg_report_symbol_icon {
display: block !important;
width: 15px !important;
height: 15px !important;
min-width: 15px;
min-height: 15px;
max-width: 15px !important;
max-height: 15px !important;
vertical-align: top;
}
.o_mvd_tcg_report .o_mvd_tcg_report_inline_symbol_frame {
width: 11px;
height: 11px;
margin-right: 1px;
}
.o_mvd_tcg_report .o_mvd_tcg_report_inline_symbol_icon {
width: 11px !important;
height: 11px !important;
min-width: 11px;
min-height: 11px;
max-width: 11px !important;
max-height: 11px !important;
}
.o_mvd_tcg_report .o_mvd_tcg_report_identity_frame {
width: 14px;
height: 14px;
margin-right: 2px;
}
.o_mvd_tcg_report .o_mvd_tcg_report_identity_icon {
width: 14px !important;
height: 14px !important;
min-width: 14px;
min-height: 14px;
max-width: 14px !important;
max-height: 14px !important;
}
.o_mvd_tcg_report .o_mvd_tcg_report_color_chip {
display: inline-block;
width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 999px;
font-size: 9px;
font-weight: 700;
margin-right: 3px;
vertical-align: middle;
}
.o_mvd_tcg_report .o_mvd_tcg_report_muted {
color: #6b7280;
}
.o_mvd_tcg_report .o_mvd_tcg_report_empty {
padding: 14px 0;
color: #6b7280;
font-style: italic;
}
.o_mvd_tcg_report .o_mvd_tcg_report_page_meta {
display: none;
}
.o_mvd_tcg_report .o_mvd_tcg_report_footer {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid #dbe4ee;
font-size: 9px;
color: #64748b;
text-align: right;
}
.o_mvd_tcg_report .o_mvd_tcg_report_page_break {
page-break-before: always;
}
</style>
<div class="page o_mvd_tcg_report">
<table class="o_mvd_tcg_report_hero">
<tbody>
<tr>
<td class="o_mvd_tcg_report_hero_main">
<p class="o_mvd_tcg_report_eyebrow">MVD TCG Decksheet</p>
<h1 class="o_mvd_tcg_report_title">
<span t-field="doc.name"/>
</h1>
<p class="o_mvd_tcg_report_subtitle">
Compact deck overview for print and review.
</p>
<div>
<span class="o_mvd_tcg_report_chip">
<strong>Game:</strong>
<span t-field="doc.game_id"/>
</span>
<span class="o_mvd_tcg_report_chip">
<strong>Boards:</strong>
<span t-field="doc.board_count"/>
</span>
<span class="o_mvd_tcg_report_chip">
<strong>Owner:</strong>
<span t-field="doc.user_id"/>
</span>
<span class="o_mvd_tcg_report_chip">
<strong>Updated:</strong>
<span t-field="doc.write_date"/>
</span>
</div>
<table class="o_mvd_tcg_report_kpi_grid">
<tbody>
<tr>
<td>
<div class="o_mvd_tcg_report_kpi">
<p class="o_mvd_tcg_report_kpi_label">Total Copies</p>
<p class="o_mvd_tcg_report_kpi_value">
<span t-field="doc.total_card_count"/>
</p>
<p class="o_mvd_tcg_report_kpi_hint">Cards counted in the active build</p>
</div>
</td>
<td>
<div class="o_mvd_tcg_report_kpi">
<p class="o_mvd_tcg_report_kpi_label">Distinct Cards</p>
<p class="o_mvd_tcg_report_kpi_value">
<span t-field="doc.distinct_card_count"/>
</p>
<p class="o_mvd_tcg_report_kpi_hint">Unique references across boards</p>
</div>
</td>
</tr>
</tbody>
</table>
</td>
<td class="o_mvd_tcg_report_hero_cover">
<img
t-if="doc.cover_image"
t-att-src="image_data_uri(doc.cover_image)"
class="o_mvd_tcg_report_cover"
alt="Deck cover"
/>
</td>
</tr>
</tbody>
</table>
<div class="o_mvd_tcg_report_section">
<h3 class="o_mvd_tcg_report_section_title">Deck Snapshot</h3>
<table class="o_mvd_tcg_report_snapshot">
<tbody>
<tr>
<td>
<div class="o_mvd_tcg_report_snapshot_block">
<h4>Overview</h4>
<div t-if="get_report_overview_markup(doc)" t-out="get_report_overview_markup(doc)"/>
<p t-else="" class="o_mvd_tcg_report_muted">
No deck overview has been written yet.
</p>
</div>
</td>
</tr>
<tr>
<td>
<div class="o_mvd_tcg_report_snapshot_block">
<h4>Internal Notes</h4>
<p
t-if="doc.note"
class="o_mvd_tcg_report_snapshot_note"
t-out="doc.note"
/>
<p t-else="" class="o_mvd_tcg_report_muted">
No internal notes recorded.
</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="o_mvd_tcg_report_extension_hook"/>
<div class="o_mvd_tcg_report_page_break"/>
<div class="o_mvd_tcg_report_section">
<h3 class="o_mvd_tcg_report_section_title">Board Summary</h3>
<p class="o_mvd_tcg_report_section_intro">Per-board size, distinct cards, and build participation.</p>
<table class="o_mvd_tcg_report_data_table">
<thead>
<tr>
<th style="width: 28%;">Board</th>
<th style="width: 12%;">Copies</th>
<th style="width: 12%;">Distinct</th>
<th style="width: 18%;">Build Status</th>
<th style="width: 30%;">Note</th>
</tr>
</thead>
<tbody>
<t t-foreach="board_sections" t-as="section">
<tr>
<td>
<strong t-field="section['board'].name"/>
</td>
<td><t t-out="section['total_card_count']"/></td>
<td><t t-out="section['distinct_card_count']"/></td>
<td>
<span
t-if="section['include_in_total']"
class="o_mvd_tcg_report_pill o_mvd_tcg_report_pill_success"
>
Included
</span>
<span
t-else=""
class="o_mvd_tcg_report_pill o_mvd_tcg_report_pill_muted"
>
Reference Only
</span>
</td>
<td>
<span
t-if="section['note']"
t-out="section['note']"
/>
<span t-else="" class="o_mvd_tcg_report_muted"></span>
</td>
</tr>
</t>
</tbody>
</table>
</div>
<div t-if="role_summary" class="o_mvd_tcg_report_section">
<h3 class="o_mvd_tcg_report_section_title">Role Coverage</h3>
<p class="o_mvd_tcg_report_section_intro">
Roles summarize how the deck is currently tagged across all boards.
</p>
<table class="o_mvd_tcg_report_data_table">
<thead>
<tr>
<th style="width: 48%;">Role</th>
<th style="width: 26%;">Entries</th>
<th style="width: 26%;">Total Copies</th>
</tr>
</thead>
<tbody>
<t t-foreach="role_summary" t-as="role_row">
<tr>
<td><strong t-field="role_row['role'].name"/></td>
<td><t t-out="role_row['line_count']"/></td>
<td><t t-out="role_row['quantity_total']"/></td>
</tr>
</t>
</tbody>
</table>
</div>
<div class="o_mvd_tcg_report_footer">
<span t-field="doc.name"/>
<span> · </span>
<span t-field="doc.game_id"/>
<span> · </span>
<span t-field="doc.write_date"/>
<span> · </span>
<span class="page"/> / <span class="topage"/>
</div>
</div>
<t t-foreach="board_sections" t-as="section">
<div
class="page o_mvd_tcg_report o_mvd_tcg_report_page_break"
style="page-break-before: always;"
>
<div class="o_mvd_tcg_report_board_header">
<h2>
<span t-field="section['board'].name"/>
</h2>
<p>
<span
t-if="section['include_in_total']"
class="o_mvd_tcg_report_pill o_mvd_tcg_report_pill_success"
>
Included in build
</span>
<span
t-else=""
class="o_mvd_tcg_report_pill o_mvd_tcg_report_pill_muted"
>
Reference board
</span>
<t t-if="section['note']">
<span style="margin-left: 8px;" t-out="section['note']"/>
</t>
</p>
</div>
<table class="o_mvd_tcg_report_board_meta_table" style="margin-bottom: 10px;">
<tbody>
<tr>
<td>
<div class="o_mvd_tcg_report_board_meta_card">
<p class="o_mvd_tcg_report_kpi_label">Copies</p>
<p class="o_mvd_tcg_report_kpi_value">
<t t-out="section['total_card_count']"/>
</p>
</div>
</td>
<td>
<div class="o_mvd_tcg_report_board_meta_card">
<p class="o_mvd_tcg_report_kpi_label">Distinct Cards</p>
<p class="o_mvd_tcg_report_kpi_value">
<t t-out="section['distinct_card_count']"/>
</p>
</div>
</td>
<td>
<div class="o_mvd_tcg_report_board_meta_card">
<p class="o_mvd_tcg_report_kpi_label">Build Participation</p>
<p class="o_mvd_tcg_report_kpi_value" style="font-size: 15px;">
<t t-if="section['include_in_total']">Included</t>
<t t-else="">Reference</t>
</p>
</div>
</td>
</tr>
</tbody>
</table>
<t t-if="section['line_groups']">
<t t-foreach="section['line_groups']" t-as="group">
<div class="o_mvd_tcg_report_group_heading">
<strong t-out="group['label']"/>
<span>
<t t-out="group['total_card_count']"/> copies ·
<t t-out="group['distinct_card_count']"/> distinct
</span>
</div>
<table class="o_mvd_tcg_report_data_table o_mvd_tcg_report_card_table" style="margin-bottom: 8px;">
<thead>
<tr>
<th style="width: 10%;">Image</th>
<th style="width: 8%;">Qty</th>
<th style="width: 82%;">Card</th>
</tr>
</thead>
<tbody>
<t t-foreach="group['lines']" t-as="line">
<tr>
<td class="o_mvd_tcg_report_card_thumb">
<img
t-if="line.card_image_128"
t-att-src="image_data_uri(line.card_image_128)"
alt="Card image"
/>
</td>
<td class="o_mvd_tcg_report_qty_cell">
<span t-field="line.quantity"/>
</td>
<td class="o_mvd_tcg_report_card_cell">
<div class="o_mvd_tcg_report_card_name">
<span t-field="line.card_id"/>
</div>
</td>
</tr>
</t>
</tbody>
</table>
</t>
</t>
<p t-else="" class="o_mvd_tcg_report_empty">No cards in this board.</p>
<div class="o_mvd_tcg_report_footer">
<span t-field="doc.name"/>
<span> · </span>
<span t-field="doc.game_id"/>
<span> · </span>
<span class="page"/> / <span class="topage"/>
</div>
</div>
</t>
</div>
</t>
</t>
</template>
</odoo>