commit d81e8a87e379d9ded71f302bcd8c8a809b51634f Author: Marc Wempe Date: Fri Apr 3 23:08:57 2026 +0200 🎉 Initialize module repository diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c5f867 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +.DS_Store +.pytest_cache/ +.ruff_cache/ +*.log +*.swp +*~ diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..cf6083c --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import report +from . import wizards diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..615df00 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,44 @@ +{ + "name": "MVD TCG Deck", + "summary": "Game-neutral deckbuilding, deck lines, and printable reports", + "version": "19.0.10.3.1", + "description": """ +Game-neutral deckbuilding layer for the MVD TCG suite. + +This module provides the shared deckbuilding primitives used across supported +games: +- decks, boards, deck lines, and reusable deck roles +- add-to-deck flows and text import/export +- generic board and line management +- printable deck reports +- backend image zoom support for deck line card thumbnails + +Game-specific rules, analytics, and presentation are added by adapter modules +such as the MTG deck extension. +""", + "category": "Tools", + "author": "Mantjeverse Digital", + "license": "LGPL-3", + "depends": ["mvd_tcg_base", "web"], + "assets": { + "web.assets_backend": [ + "mvd_tcg_deck/static/src/js/mvd_deck_zoom_image_field.js", + "mvd_tcg_deck/static/src/xml/mvd_deck_zoom_image_field.xml", + ], + }, + "data": [ + "security/ir.model.access.csv", + "security/mvd_tcg_deck_security.xml", + "data/mvd_tcg_deck_role_data.xml", + "data/mvd_tcg_deck_role_sync.xml", + "views/mvd_tcg_deck_views.xml", + "views/mvd_tcg_add_to_deck_views.xml", + "views/mvd_tcg_deck_text_transfer_views.xml", + "views/mvd_tcg_card_views.xml", + "views/menu_views.xml", + "report/mvd_tcg_deck_report_templates.xml", + "report/mvd_tcg_deck_report_actions.xml", + ], + "application": False, + "installable": True, +} diff --git a/data/mvd_tcg_deck_role_data.xml b/data/mvd_tcg_deck_role_data.xml new file mode 100644 index 0000000..16f4a35 --- /dev/null +++ b/data/mvd_tcg_deck_role_data.xml @@ -0,0 +1,58 @@ + + + + 10 + ramp + Ramp + 1 + + + + 20 + draw + Draw + 2 + + + + 30 + removal + Removal + 3 + + + + 40 + interaction + Interaction + 4 + + + + 50 + protection + Protection + 5 + + + + 60 + wincon + Win Condition + 6 + + + + 70 + value + Value + 7 + + + + 80 + combo + Combo + 8 + + diff --git a/data/mvd_tcg_deck_role_sync.xml b/data/mvd_tcg_deck_role_sync.xml new file mode 100644 index 0000000..55a6e11 --- /dev/null +++ b/data/mvd_tcg_deck_role_sync.xml @@ -0,0 +1,4 @@ + + + + diff --git a/i18n/de.po b/i18n/de.po new file mode 100644 index 0000000..fd8b789 --- /dev/null +++ b/i18n/de.po @@ -0,0 +1,947 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mvd_tcg_deck +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e-20260324\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-03 05:05+0000\n" +"PO-Revision-Date: 2026-04-03 05:05+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Language: de\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_text_transfer_view_form +msgid "" +"# Mainboard\n" +"1 Card Name\n" +"1 Another Card (TDM 101)\n" +"# Sideboard\n" +"1 Third Card" +msgstr "" +"# Mainboard\n" +"1 Kartenname\n" +"1 Weitere Karte (TDM 101)\n" +"# Sideboard\n" +"1 Dritte Karte" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/models/mvd_tcg_deck.py:0 +msgid "%s Lines" +msgstr "%s Zeilen" + +#. module: mvd_tcg_deck +#: model:ir.actions.report,print_report_name:mvd_tcg_deck.action_report_mvd_tcg_deck +msgid "'Deck - %s' % (object.name)" +msgstr "'Deck - %s' % (object.name)" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "Add Card" +msgstr "Karte hinzufügen" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "Create Default Boards" +msgstr "Standard-Boards anlegen" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "Export List" +msgstr "Liste exportieren" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "Import List" +msgstr "Liste importieren" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_board_view_form +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "Manage Lines" +msgstr "Zeilen verwalten" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_line_view_form +msgid "Open" +msgstr "Öffnen" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid " · " +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "" +" · \n" +" / " +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "" +" · \n" +" / " +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_kanban +msgid "No cover" +msgstr "Kein Cover" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "Boards" +msgstr "Boards" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "Cards" +msgstr "Karten" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "Distinct" +msgstr "Eindeutig" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Boards:" +msgstr "Boards:" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Game:" +msgstr "Spiel:" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Owner:" +msgstr "Besitzer:" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Updated:" +msgstr "Aktualisiert:" + +#. module: mvd_tcg_deck +#: model:ir.model.constraint,message:mvd_tcg_deck.constraint_mvd_tcg_deck_line_board_card_unique +msgid "A card can appear only once per board." +msgstr "Eine Karte darf pro Board nur einmal vorkommen." + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__active +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_role__active +msgid "Active" +msgstr "Aktiv" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_add_to_deck_view_form +msgid "Add" +msgstr "Hinzufügen" + +#. module: mvd_tcg_deck +#: model:ir.model,name:mvd_tcg_deck.model_mvd_tcg_add_to_deck +msgid "Add TCG Card To Deck" +msgstr "TCG-Karte zum Deck hinzufügen" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_line_view_form +msgid "Add an internal note for this deck entry." +msgstr "Füge eine interne Notiz für diesen Deckeintrag hinzu." + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "Add internal deck notes." +msgstr "Füge interne Decknotizen hinzu." + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/models/mvd_tcg_card.py:0 +#: code:addons/mvd_tcg_deck/models/mvd_tcg_deck.py:0 +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_add_to_deck_view_form +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_card_view_form_deck_inherit +msgid "Add to Deck" +msgstr "Zum Deck hinzufügen" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "All Cards" +msgstr "Alle Karten" + +#. module: mvd_tcg_deck +#: model:ir.ui.menu,name:mvd_tcg_deck.mvd_tcg_decks_menu +msgid "All Decks" +msgstr "Alle Decks" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_text_transfer_view_form +msgid "Apply" +msgstr "Anwenden" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_search +msgid "Archived" +msgstr "Archiviert" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_add_to_deck__board_id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__board_id +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_board_view_form +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_line_view_search +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Board" +msgstr "Board" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__board_count +msgid "Board Count" +msgstr "Board-Anzahl" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__line_ids +msgid "Board Lines" +msgstr "Board-Zeilen" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__board_sequence +msgid "Board Sequence" +msgstr "Board-Reihenfolge" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Board Summary" +msgstr "Board-Zusammenfassung" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/models/mvd_tcg_deck.py:0 +msgid "" +"Board codes are technical identifiers and can only be changed by TCG " +"administrators." +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__board_ids +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "Boards" +msgstr "Boards" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Build Participation" +msgstr "Build-Beteiligung" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Build Status" +msgstr "Build-Status" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_add_to_deck_view_form +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_text_transfer_view_form +msgid "Cancel" +msgstr "Abbrechen" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_add_to_deck__card_id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__card_id +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_line_view_form +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Card" +msgstr "Karte" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Card image" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_board_view_form +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_board_view_list +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "Cards" +msgstr "Karten" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Cards counted in the active build" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,help:mvd_tcg_deck.field_mvd_tcg_deck_text_transfer__replace_existing +msgid "" +"Clear all current deck entries before importing the provided deck list." +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_text_transfer_view_form +msgid "Close" +msgstr "Schließen" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__code +msgid "Code" +msgstr "Code" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_role__color +msgid "Color" +msgstr "Farbe" + +#. module: mvd_tcg_deck +#: model:mvd.tcg.deck.role,name:mvd_tcg_deck.mvd_tcg_deck_role_combo +msgid "Combo" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Compact deck overview for print and review." +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Copies" +msgstr "Kopien" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__cover_card_id +msgid "Cover Card" +msgstr "Titelkarte" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__cover_image +msgid "Cover Image" +msgstr "Titelbild" + +#. module: mvd_tcg_deck +#: model_terms:ir.actions.act_window,help:mvd_tcg_deck.mvd_tcg_deck_action +msgid "Create a new deck" +msgstr "Neues Deck anlegen" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_add_to_deck__create_uid +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__create_uid +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__create_uid +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__create_uid +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_role__create_uid +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_text_transfer__create_uid +msgid "Created by" +msgstr "Erstellt von" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_add_to_deck__create_date +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__create_date +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__create_date +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__create_date +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_role__create_date +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_text_transfer__create_date +msgid "Created on" +msgstr "Erstellt am" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/wizards/mvd_tcg_add_to_deck.py:0 +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_add_to_deck__deck_id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__deck_id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__deck_id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_text_transfer__deck_id +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_line_view_search +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "Deck" +msgstr "Deck" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_board_view_form +msgid "Deck Board" +msgstr "Deck-Board" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_card__deck_count +msgid "Deck Count" +msgstr "Deck-Anzahl" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_line_view_form +msgid "Deck Entry" +msgstr "Deckeintrag" + +#. module: mvd_tcg_deck +#: model:ir.actions.act_window,name:mvd_tcg_deck.mvd_tcg_deck_line_action +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_card__deck_line_ids +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__line_ids +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_line_view_search +msgid "Deck Lines" +msgstr "Deckzeilen" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_text_transfer__line_text +msgid "Deck List" +msgstr "Deckliste" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_text_transfer_view_form +msgid "Deck List Transfer" +msgstr "Decklisten-Transfer" + +#. module: mvd_tcg_deck +#: model:ir.actions.report,name:mvd_tcg_deck.action_report_mvd_tcg_deck +msgid "Deck Report" +msgstr "Deckbericht" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_role_view_form +msgid "Deck Role" +msgstr "Deckrolle" + +#. module: mvd_tcg_deck +#: model:ir.actions.act_window,name:mvd_tcg_deck.mvd_tcg_deck_role_action +#: model:ir.ui.menu,name:mvd_tcg_deck.mvd_tcg_deck_roles_menu +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_role_view_list +msgid "Deck Roles" +msgstr "Deckrollen" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Deck Snapshot" +msgstr "Deck-Snapshot" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Deck cover" +msgstr "" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/models/mvd_tcg_deck.py:0 +msgid "Deck lines can only reference cards from the same game as the deck." +msgstr "" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/models/mvd_tcg_deck.py:0 +msgid "Deck role technical keys can only be changed by TCG administrators." +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.actions.act_window,name:mvd_tcg_deck.mvd_tcg_deck_action +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_card_view_form_deck_inherit +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_list +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_search +msgid "Decks" +msgstr "Decks" + +#. module: mvd_tcg_deck +#: model_terms:ir.actions.act_window,help:mvd_tcg_deck.mvd_tcg_deck_action +msgid "" +"Decks stay game-neutral. Boards organize the cards inside one deck,\n" +" while game adapters can later add format- and rules-specific behavior." +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "Describe the deck plan, power level, win conditions or upgrade path." +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_role_view_form +msgid "Describe what this role should represent in deck construction." +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__description +msgid "Description" +msgstr "Beschreibung" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_add_to_deck__display_name +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_card__display_name +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__display_name +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__display_name +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__display_name +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_role__display_name +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_text_transfer__display_name +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_report_mvd_tcg_deck_report_mvd_tcg_deck_document__display_name +msgid "Display Name" +msgstr "Anzeigename" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_board_view_list +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Distinct" +msgstr "Eindeutig" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__distinct_card_count +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__distinct_card_count +msgid "Distinct Card Count" +msgstr "Anzahl eindeutiger Karten" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Distinct Cards" +msgstr "Eindeutige Karten" + +#. module: mvd_tcg_deck +#: model:mvd.tcg.deck.role,name:mvd_tcg_deck.mvd_tcg_deck_role_draw +msgid "Draw" +msgstr "Kartenziehen" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Entries" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields.selection,name:mvd_tcg_deck.selection__mvd_tcg_deck_text_transfer__operation__export +msgid "Export" +msgstr "" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/models/mvd_tcg_deck.py:0 +msgid "Export Deck List" +msgstr "Deckliste exportieren" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_add_to_deck__game_id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__game_id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__game_id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_text_transfer__game_id +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_search +msgid "Game" +msgstr "Spiel" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_add_to_deck__id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_card__id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_role__id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_text_transfer__id +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_report_mvd_tcg_deck_report_mvd_tcg_deck_document__id +msgid "ID" +msgstr "ID" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__card_image_1920 +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Image" +msgstr "Bild" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__card_image_128 +msgid "Image 128" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__card_image_512 +msgid "Image 512" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields.selection,name:mvd_tcg_deck.selection__mvd_tcg_deck_text_transfer__operation__import +msgid "Import" +msgstr "Import" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/models/mvd_tcg_deck.py:0 +msgid "Import Deck List" +msgstr "Deckliste importieren" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__include_in_total +msgid "Include In Total" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Included" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Included in build" +msgstr "" + +#. module: mvd_tcg_deck +#: model:mvd.tcg.deck.role,name:mvd_tcg_deck.mvd_tcg_deck_role_interaction +msgid "Interaction" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Internal Notes" +msgstr "Interne Notizen" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_add_to_deck__write_uid +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__write_uid +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__write_uid +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__write_uid +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_role__write_uid +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_text_transfer__write_uid +msgid "Last Updated by" +msgstr "Zuletzt aktualisiert von" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_add_to_deck__write_date +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__write_date +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__write_date +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__write_date +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_role__write_date +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_text_transfer__write_date +msgid "Last Updated on" +msgstr "Zuletzt aktualisiert am" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/wizards/mvd_tcg_deck_text_transfer.py:0 +msgid "Line %(line)s could not be parsed: %(value)s" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "MVD TCG Decksheet" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_search +msgid "My Decks" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__name +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__name +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_role__name +msgid "Name" +msgstr "Name" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/wizards/mvd_tcg_deck_text_transfer.py:0 +msgid "No active card named '%s' exists in the current game." +msgstr "" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/wizards/mvd_tcg_deck_text_transfer.py:0 +msgid "No board exists for code '%s'." +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "No cards in this board." +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "No deck overview has been written yet." +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "No internal notes recorded." +msgstr "" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/models/mvd_tcg_deck.py:0 +msgid "No target board exists for code '%s'." +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_add_to_deck__note +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__note +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__note +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__note +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_role__note +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Note" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_board_view_form +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +msgid "Notes" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_text_transfer__operation +msgid "Operation" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_form +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Overview" +msgstr "Übersicht" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__user_id +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_search +msgid "Owner" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Per-board size, distinct cards, and build participation." +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__primary_role_id +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_line_view_search +msgid "Primary Role" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__primary_role_sequence +msgid "Primary Role Sequence" +msgstr "" + +#. module: mvd_tcg_deck +#: model:mvd.tcg.deck.role,name:mvd_tcg_deck.mvd_tcg_deck_role_protection +msgid "Protection" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Qty" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_add_to_deck__quantity +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__quantity +msgid "Quantity" +msgstr "" + +#. module: mvd_tcg_deck +#: model:mvd.tcg.deck.role,name:mvd_tcg_deck.mvd_tcg_deck_role_ramp +msgid "Ramp" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Reference" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Reference Only" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Reference board" +msgstr "" + +#. module: mvd_tcg_deck +#: model:mvd.tcg.deck.role,name:mvd_tcg_deck.mvd_tcg_deck_role_removal +msgid "Removal" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_text_transfer__replace_existing +msgid "Replace Existing Deck Lines" +msgstr "Vorhandene Deckzeilen ersetzen" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_role_view_form +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Role" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.ui.menu,name:mvd_tcg_deck.mvd_tcg_deck_roles_menu +msgid "Deck Roles" +msgstr "Deckrollen" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Role Coverage" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_add_to_deck__role_ids +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__role_ids +msgid "Roles" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Roles summarize how the deck is currently tagged across all boards." +msgstr "" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/wizards/mvd_tcg_add_to_deck.py:0 +msgid "Select a card first." +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__sequence +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_line__sequence +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_role__sequence +msgid "Sequence" +msgstr "Reihenfolge" + +#. module: mvd_tcg_deck +#: model:ir.model,name:mvd_tcg_deck.model_mvd_tcg_card +msgid "TCG Card" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model,name:mvd_tcg_deck.model_mvd_tcg_deck +msgid "TCG Deck" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model,name:mvd_tcg_deck.model_mvd_tcg_deck_board +msgid "TCG Deck Board" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model,name:mvd_tcg_deck.model_mvd_tcg_deck_line +msgid "TCG Deck Line" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model,name:mvd_tcg_deck.model_report_mvd_tcg_deck_report_mvd_tcg_deck_document +msgid "TCG Deck Report" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model,name:mvd_tcg_deck.model_mvd_tcg_deck_role +msgid "TCG Deck Role" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model,name:mvd_tcg_deck.model_mvd_tcg_deck_text_transfer +msgid "TCG Deck Text Transfer" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_board_view_form +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_role_view_form +msgid "Technical" +msgstr "Technisch" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_role__technical_key +msgid "Technical Key" +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.constraint,message:mvd_tcg_deck.constraint_mvd_tcg_deck_board_board_code_unique +msgid "The board code must be unique inside a deck." +msgstr "" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/wizards/mvd_tcg_deck_text_transfer.py:0 +msgid "" +"The card '%(name)s' matches multiple printings. Add a print hint such as " +"'(TDM 101)' or pick the card directly in the deck builder." +msgstr "" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/wizards/mvd_tcg_deck_text_transfer.py:0 +msgid "" +"The card '%(name)s' matches multiple printings. Add a print hint such as " +"'(TDM 101)' or use a more specific card name." +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.constraint,message:mvd_tcg_deck.constraint_mvd_tcg_deck_role_name_unique +msgid "The deck role name must be unique." +msgstr "" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/wizards/mvd_tcg_deck_text_transfer.py:0 +msgid "" +"The explicit reference '%(reference)s' matches multiple cards. Narrow the " +"import source first." +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.constraint,message:mvd_tcg_deck.constraint_mvd_tcg_deck_line_quantity_positive +msgid "The quantity must be greater than zero." +msgstr "" + +#. module: mvd_tcg_deck +#. odoo-python +#: code:addons/mvd_tcg_deck/models/mvd_tcg_deck.py:0 +msgid "" +"The selected card already exists in the target board. Merge or adjust that " +"line first." +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.constraint,message:mvd_tcg_deck.constraint_mvd_tcg_deck_role_technical_key_unique +msgid "The technical role key must be unique." +msgstr "" + +#. module: mvd_tcg_deck +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck__total_card_count +#: model:ir.model.fields,field_description:mvd_tcg_deck.field_mvd_tcg_deck_board__total_card_count +msgid "Total Card Count" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Total Copies" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "Unique references across boards" +msgstr "" + +#. module: mvd_tcg_deck +#: model:mvd.tcg.deck.role,name:mvd_tcg_deck.mvd_tcg_deck_role_value +msgid "Value" +msgstr "" + +#. module: mvd_tcg_deck +#: model:mvd.tcg.deck.role,name:mvd_tcg_deck.mvd_tcg_deck_role_wincon +msgid "Win Condition" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_line_view_search +msgid "With Roles" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_kanban +msgid "boards" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_kanban +msgid "cards" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "copies ·" +msgstr "" + +#. module: mvd_tcg_deck +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.mvd_tcg_deck_view_kanban +#: model_terms:ir.ui.view,arch_db:mvd_tcg_deck.report_mvd_tcg_deck_document +msgid "distinct" +msgstr "" diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..5b637ec --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,2 @@ +from . import mvd_tcg_card +from . import mvd_tcg_deck diff --git a/models/mvd_tcg_card.py b/models/mvd_tcg_card.py new file mode 100644 index 0000000..cde34a3 --- /dev/null +++ b/models/mvd_tcg_card.py @@ -0,0 +1,67 @@ +"""Deck integration on top of game-neutral card references.""" + +from odoo import _, api, fields, models + + +class MvdTcgCard(models.Model): + """Extend cards with deck usage helpers.""" + + _inherit = "mvd.tcg.card" + + deck_line_ids = fields.One2many( + "mvd.tcg.deck.line", + "card_id", + string="Deck Lines", + readonly=True, + ) + deck_count = fields.Integer( + compute="_compute_deck_count", + readonly=True, + ) + + @api.depends("deck_line_ids.deck_id") + def _compute_deck_count(self): + """Compute how many distinct decks reference each card. + + Returns: + None: The method updates records in place. + """ + for card in self: + card.deck_count = len(card.deck_line_ids.mapped("deck_id")) + + def action_open_decks(self): + """Open all decks that reference the current card. + + Returns: + dict: Window action filtered to linked decks. + """ + self.ensure_one() + deck_ids = self.deck_line_ids.mapped("deck_id").ids + action = self.env["ir.actions.actions"]._for_xml_id( + "mvd_tcg_deck.mvd_tcg_deck_action" + ) + action["domain"] = [("id", "in", deck_ids or [0])] + if self.game_id: + action["context"] = {"default_game_id": self.game_id.id} + return action + + def action_open_add_to_deck_wizard(self): + """Open the deck-assignment wizard for the current card. + + Returns: + dict: Modal wizard action. + """ + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Add to Deck"), + "res_model": "mvd.tcg.add.to.deck", + "view_mode": "form", + "target": "new", + "context": { + "default_card_id": self.id, + "default_game_id": self.game_id.id, + "active_model": self._name, + "active_id": self.id, + }, + } diff --git a/models/mvd_tcg_deck.py b/models/mvd_tcg_deck.py new file mode 100644 index 0000000..18bd447 --- /dev/null +++ b/models/mvd_tcg_deck.py @@ -0,0 +1,959 @@ +"""Game-neutral deck builder models.""" + +import re + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class MvdTcgDeck(models.Model): + """Store one curated deck for one supported TCG.""" + + _name = "mvd.tcg.deck" + _description = "TCG Deck" + _order = "write_date desc, id desc" + _MVD_TCG_BOARD_CODE_ALIASES = { + "mainboard": {"main", "mainboard"}, + "sideboard": {"side", "sideboard"}, + "maybeboard": {"maybe", "maybeboard"}, + "command_zone": {"command", "command_zone", "commander", "command zone"}, + } + _MVD_TCG_BOARD_EXPORT_HEADINGS = { + "mainboard": "Mainboard", + "sideboard": "Sideboard", + "maybeboard": "Maybeboard", + "command_zone": "Command Zone", + } + + active = fields.Boolean(default=True) + name = fields.Char(required=True, index="trigram") + game_id = fields.Many2one( + "mvd.tcg.game", + string="Game", + required=True, + index=True, + ondelete="restrict", + ) + user_id = fields.Many2one( + "res.users", + string="Owner", + required=True, + default=lambda self: self.env.user, + index=True, + ) + description = fields.Html() + note = fields.Text() + board_ids = fields.One2many( + "mvd.tcg.deck.board", + "deck_id", + string="Boards", + copy=True, + ) + line_ids = fields.One2many( + "mvd.tcg.deck.line", + "deck_id", + string="Deck Lines", + readonly=True, + ) + board_count = fields.Integer( + compute="_compute_counts", + readonly=True, + ) + total_card_count = fields.Integer( + compute="_compute_counts", + readonly=True, + ) + distinct_card_count = fields.Integer( + compute="_compute_counts", + readonly=True, + ) + cover_card_id = fields.Many2one( + "mvd.tcg.card", + compute="_compute_cover", + readonly=True, + ) + cover_image = fields.Image( + compute="_compute_cover", + readonly=True, + ) + + def _mvd_tcg_can_manage_deck_owner(self): + """Return whether the current user may assign deck ownership. + + Returns: + bool: ``True`` for TCG managers, administrators and system users. + """ + return self.env.is_superuser() or any( + self.env.user.has_group(xmlid) + for xmlid in ( + "mvd_tcg_base.mvd_tcg_base_group_manager", + "base.group_system", + ) + ) + + @api.model_create_multi + def create(self, vals_list): + """Create decks and seed their default boards when needed. + + Args: + vals_list: Standard ORM payloads for new decks. + + Returns: + Model: Created deck records. + """ + prepared_vals_list = [] + for vals in vals_list: + prepared_vals = dict(vals) + requested_owner_id = prepared_vals.get("user_id") + if ( + requested_owner_id + and requested_owner_id != self.env.user.id + and not self._mvd_tcg_can_manage_deck_owner() + ): + raise UserError( + _( + "Only TCG managers can assign decks to another owner." + ) + ) + prepared_vals_list.append(prepared_vals) + + decks = super().create(prepared_vals_list) + for deck, vals in zip(decks, vals_list): + if vals.get("board_ids"): + continue + deck._mvd_tcg_seed_default_boards() + return decks + + def write(self, vals): + """Protect deck ownership from normal user reassignment. + + Args: + vals: Field values to update. + + Returns: + bool: Result of the underlying ORM write. + """ + if "user_id" in vals: + requested_owner_id = vals.get("user_id") + if ( + requested_owner_id + and requested_owner_id != self.env.user.id + and not self._mvd_tcg_can_manage_deck_owner() + ): + raise UserError( + _( + "Only TCG managers can reassign a deck to another owner." + ) + ) + return super().write(vals) + + @api.depends("board_ids", "board_ids.total_card_count", "line_ids.card_id") + def _compute_counts(self): + """Compute aggregate counters for each deck. + + Returns: + None: The method updates records in place. + """ + for deck in self: + deck.board_count = len(deck.board_ids) + deck.total_card_count = sum( + deck.board_ids.filtered("include_in_total").mapped("total_card_count") + ) + deck.distinct_card_count = len(deck.line_ids.mapped("card_id")) + + @api.depends( + "line_ids.sequence", + "line_ids.board_sequence", + "line_ids.card_id", + "line_ids.card_id.image_1920", + ) + def _compute_cover(self): + """Pick a stable cover card and image for each deck. + + Returns: + None: The method updates records in place. + """ + for deck in self: + ordered_lines = sorted( + deck.line_ids.filtered("card_id"), + key=lambda line: (line.board_sequence, line.sequence, line.id), + ) + cover_card = ordered_lines[0].card_id if ordered_lines else False + deck.cover_card_id = cover_card + deck.cover_image = ( + cover_card._mvd_tcg_get_deck_image_binary() if cover_card else False + ) + + def _mvd_tcg_seed_default_boards(self): + """Create default boards for decks that do not have any yet. + + Returns: + None: The method creates child board records in place. + """ + board_model = self.env["mvd.tcg.deck.board"] + for deck in self.filtered(lambda current_deck: not current_deck.board_ids): + board_values = deck.game_id._mvd_tcg_get_default_deck_board_templates() + board_model.with_context( + mvd_tcg_bypass_board_code_write=True + ).create( + [ + { + "deck_id": deck.id, + "name": values["name"], + "code": values["code"], + "sequence": values.get("sequence", 10), + "include_in_total": values.get("include_in_total", True), + } + for values in board_values + ] + ) + + def action_seed_default_boards(self): + """Recreate default boards when a deck has none. + + Returns: + bool: ``True`` when the operation completed. + """ + self._mvd_tcg_seed_default_boards() + return True + + def action_open_cards(self): + """Open all cards that currently belong to the deck. + + Returns: + dict: Window action filtered to linked cards. + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "mvd_tcg_base.mvd_tcg_card_action" + ) + action["domain"] = [("id", "in", self.line_ids.mapped("card_id").ids or [0])] + action["context"] = {"default_game_id": self.game_id.id} + return action + + def action_open_line_manager(self): + """Open the standalone line manager for the current deck. + + Returns: + dict: Window action filtered to the current deck lines. + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "mvd_tcg_deck.mvd_tcg_deck_line_action" + ) + action["name"] = _("%s Lines") % self.display_name + action["domain"] = [("deck_id", "=", self.id)] + action["context"] = { + "default_deck_id": self.id, + "search_default_group_by_board": 1, + } + return action + + def action_open_add_to_deck_wizard(self): + """Open the generic add-to-deck wizard for the current deck. + + Returns: + dict: Window action for the add-to-deck wizard. + """ + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Add to Deck"), + "res_model": "mvd.tcg.add.to.deck", + "view_mode": "form", + "target": "new", + "context": { + "default_game_id": self.game_id.id, + "default_deck_id": self.id, + }, + } + + def action_open_deck_import_wizard(self): + """Open the text import wizard for the current deck. + + Returns: + dict: Window action for the deck text transfer wizard. + """ + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Import Deck List"), + "res_model": "mvd.tcg.deck.text.transfer", + "view_mode": "form", + "target": "new", + "context": { + "default_deck_id": self.id, + "default_operation": "import", + }, + } + + def action_open_deck_export_wizard(self): + """Open the text export wizard for the current deck. + + Returns: + dict: Window action for the deck text transfer wizard. + """ + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Export Deck List"), + "res_model": "mvd.tcg.deck.text.transfer", + "view_mode": "form", + "target": "new", + "context": { + "default_deck_id": self.id, + "default_operation": "export", + }, + } + + @classmethod + def _mvd_tcg_normalize_board_code(cls, raw_code): + """Resolve one raw board label to its stable technical board code. + + Args: + raw_code: Free-form board name or code. + + Returns: + str | bool: Stable board code or ``False`` when no alias matches. + """ + normalized_code = (raw_code or "").strip().lower() + for board_code, aliases in cls._MVD_TCG_BOARD_CODE_ALIASES.items(): + if normalized_code in aliases or normalized_code == board_code: + return board_code + return False + + @classmethod + def _mvd_tcg_get_board_export_heading(cls, board): + """Return one stable, human-readable board heading for exports. + + Args: + board: Deck board record. + + Returns: + str: Export heading that remains round-trippable. + """ + normalized_code = cls._mvd_tcg_normalize_board_code(board.code) + return cls._MVD_TCG_BOARD_EXPORT_HEADINGS.get( + normalized_code, + board.name, + ) + + def _mvd_tcg_get_board_by_code(self, board_code): + """Return the first board matching the requested code. + + Args: + board_code: Stable technical board code such as ``mainboard``. + + Returns: + mvd.tcg.deck.board: Matching board record, if any. + """ + self.ensure_one() + normalized_code = self._mvd_tcg_normalize_board_code(board_code) or board_code + accepted_codes = self._MVD_TCG_BOARD_CODE_ALIASES.get( + normalized_code, + {normalized_code}, + ) + return self.board_ids.filtered(lambda board: board.code in accepted_codes)[:1] + + def _mvd_tcg_add_card_to_board( + self, + card, + board, + quantity=1, + role_ids=False, + note=False, + ): + """Create or increment one deck line through the standard add rules. + + Args: + card: Card record that should be added. + board: Target board inside the current deck. + quantity: Quantity delta to add. + role_ids: Optional deck roles to merge onto the target line. + note: Optional note to apply to the resulting line. + + Returns: + mvd.tcg.deck.line: Created or updated deck line. + """ + self.ensure_one() + line_model = self.env["mvd.tcg.deck.line"] + existing_line = line_model.search( + [ + ("board_id", "=", board.id), + ("card_id", "=", card.id), + ], + limit=1, + ) + self._mvd_tcg_validate_add_to_board( + card, + board, + quantity=quantity, + existing_line=existing_line, + ) + if existing_line: + values = {"quantity": existing_line.quantity + quantity} + if role_ids: + merged_roles = existing_line.role_ids | role_ids + values["role_ids"] = [(6, 0, merged_roles.ids)] + if note: + values["note"] = note + existing_line.write(values) + return existing_line + + return line_model.create( + { + "board_id": board.id, + "quantity": quantity, + "card_id": card.id, + "role_ids": [(6, 0, role_ids.ids)] if role_ids else False, + "note": note, + } + ) + + def _mvd_tcg_action_open_board(self, board_code): + """Open one logical board of the current deck. + + Args: + board_code: Stable technical board code such as ``mainboard``. + + Returns: + dict | bool: Window action for the board form, or ``False``. + """ + self.ensure_one() + board = self._mvd_tcg_get_board_by_code(board_code) + if not board: + self._mvd_tcg_seed_default_boards() + board = self._mvd_tcg_get_board_by_code(board_code) + return board.action_open_board() if board else False + + def _mvd_tcg_action_open_add_to_board_wizard(self, board_code): + """Open the add-to-deck wizard with a preselected board. + + Args: + board_code: Stable technical board code such as ``mainboard``. + + Returns: + dict: Window action for the add-to-deck wizard. + """ + self.ensure_one() + board = self._mvd_tcg_get_board_by_code(board_code) + if not board: + self._mvd_tcg_seed_default_boards() + board = self._mvd_tcg_get_board_by_code(board_code) + return { + "type": "ir.actions.act_window", + "name": _("Add to Deck"), + "res_model": "mvd.tcg.add.to.deck", + "view_mode": "form", + "target": "new", + "context": { + "default_game_id": self.game_id.id, + "default_deck_id": self.id, + "default_board_id": board.id if board else False, + }, + } + + def _mvd_tcg_validate_add_to_board( + self, + card, + board, + quantity=1, + existing_line=False, + ): + """Validate whether one card can be added to one target board. + + Args: + card: Card record that should be added. + board: Target board record. + quantity: Quantity delta requested by the user. + existing_line: Existing matching board line when the add operation + would increment an already present card. + + Returns: + bool: ``True`` when no game-specific rule blocks the add flow. + """ + self.ensure_one() + return True + + +class MvdTcgDeckBoard(models.Model): + """Store one logical board inside a deck.""" + + _name = "mvd.tcg.deck.board" + _description = "TCG Deck Board" + _order = "deck_id, sequence, id" + + sequence = fields.Integer(default=10) + deck_id = fields.Many2one( + "mvd.tcg.deck", + required=True, + ondelete="cascade", + index=True, + ) + name = fields.Char(required=True, translate=True) + code = fields.Char(required=True, index=True) + include_in_total = fields.Boolean(default=True) + note = fields.Text() + line_ids = fields.One2many( + "mvd.tcg.deck.line", + "board_id", + string="Board Lines", + copy=True, + ) + total_card_count = fields.Integer( + compute="_compute_counts", + readonly=True, + ) + distinct_card_count = fields.Integer( + compute="_compute_counts", + readonly=True, + ) + + _board_code_unique = models.Constraint( + "UNIQUE(deck_id, code)", + "The board code must be unique inside a deck.", + ) + + @api.model + def _mvd_tcg_generate_board_code(self, name): + """Return a slug-like technical code for one board name.""" + return re.sub(r"[^a-z0-9]+", "_", (name or "").strip().lower()).strip("_") or "board" + + @api.model + def _mvd_tcg_get_unique_board_code(self, deck_id, name): + """Return a unique board code inside one deck.""" + base_code = self._mvd_tcg_generate_board_code(name) + if not deck_id: + return base_code + existing_codes = set(self.search([("deck_id", "=", deck_id)]).mapped("code")) + if base_code not in existing_codes: + return base_code + suffix = 2 + while f"{base_code}_{suffix}" in existing_codes: + suffix += 1 + return f"{base_code}_{suffix}" + + def _mvd_tcg_can_edit_board_code(self): + """Return whether the current user may edit board codes.""" + return self.env.is_superuser() or any( + self.env.user.has_group(xmlid) + for xmlid in ( + "mvd_tcg_base.mvd_tcg_base_group_administrator", + "base.group_system", + ) + ) + + @api.model_create_multi + def create(self, vals_list): + """Generate technical codes for manually created boards.""" + prepared_vals_list = [] + for vals in vals_list: + prepared_vals = dict(vals) + if ( + prepared_vals.get("code") + and not self.env.context.get("mvd_tcg_bypass_board_code_write") + and not self._mvd_tcg_can_edit_board_code() + ): + raise UserError( + _( + "Board codes are technical identifiers and can only be set " + "by TCG administrators." + ) + ) + if not prepared_vals.get("code"): + prepared_vals["code"] = self._mvd_tcg_get_unique_board_code( + prepared_vals.get("deck_id"), + prepared_vals.get("name"), + ) + prepared_vals_list.append(prepared_vals) + return super().create(prepared_vals_list) + + def write(self, vals): + """Protect technical board codes from normal business edits.""" + if "code" in vals and not self.env.context.get("mvd_tcg_bypass_board_code_write"): + if not self._mvd_tcg_can_edit_board_code(): + raise UserError( + _( + "Board codes are technical identifiers and can only be changed " + "by TCG administrators." + ) + ) + return super().write(vals) + + @api.depends("line_ids.quantity", "line_ids.card_id") + def _compute_counts(self): + """Compute per-board card counters. + + Returns: + None: The method updates records in place. + """ + for board in self: + board.total_card_count = sum(board.line_ids.mapped("quantity")) + board.distinct_card_count = len(board.line_ids.mapped("card_id")) + + def action_open_board(self): + """Open the current board in form view. + + Returns: + dict: Window action for the current board. + """ + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": self.display_name, + "res_model": "mvd.tcg.deck.board", + "view_mode": "form", + "res_id": self.id, + "target": "current", + } + + def action_open_line_manager(self): + """Open the standalone line manager for the current board. + + Returns: + dict: Window action filtered to the current board lines. + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "mvd_tcg_deck.mvd_tcg_deck_line_action" + ) + action["name"] = _("%s Lines") % self.display_name + action["domain"] = [("board_id", "=", self.id)] + action["context"] = { + "default_deck_id": self.deck_id.id, + "default_board_id": self.id, + "search_default_group_by_primary_role": 1, + } + return action + + +class MvdTcgDeckLine(models.Model): + """Store one card entry inside one deck board.""" + + _name = "mvd.tcg.deck.line" + _description = "TCG Deck Line" + _order = "deck_id, board_sequence, sequence, id" + + sequence = fields.Integer(default=10) + board_id = fields.Many2one( + "mvd.tcg.deck.board", + required=True, + ondelete="cascade", + index=True, + ) + deck_id = fields.Many2one( + "mvd.tcg.deck", + related="board_id.deck_id", + store=True, + readonly=True, + index=True, + ) + board_sequence = fields.Integer( + related="board_id.sequence", + string="Board Sequence", + store=True, + readonly=True, + index=True, + ) + quantity = fields.Integer(required=True, default=1) + card_id = fields.Many2one( + "mvd.tcg.card", + string="Card", + required=True, + index=True, + domain=[("active", "=", True)], + ) + game_id = fields.Many2one( + "mvd.tcg.game", + related="deck_id.game_id", + store=True, + readonly=True, + index=True, + ) + card_image_128 = fields.Image( + related="card_id.image_128", + readonly=True, + ) + card_image_512 = fields.Image( + related="card_id.image_512", + readonly=True, + ) + card_image_1920 = fields.Image( + related="card_id.image_1920", + readonly=True, + ) + role_ids = fields.Many2many( + "mvd.tcg.deck.role", + "mvd_tcg_deck_line_role_rel", + "line_id", + "role_id", + string="Roles", + ) + primary_role_id = fields.Many2one( + "mvd.tcg.deck.role", + compute="_compute_primary_role", + store=True, + readonly=True, + index=True, + ) + primary_role_sequence = fields.Integer( + compute="_compute_primary_role", + store=True, + readonly=True, + index=True, + ) + note = fields.Char() + + _board_card_unique = models.Constraint( + "UNIQUE(board_id, card_id)", + "A card can appear only once per board.", + ) + _quantity_positive = models.Constraint( + "CHECK(quantity > 0)", + "The quantity must be greater than zero.", + ) + + @api.constrains("card_id", "game_id") + def _check_card_game_matches_deck(self): + """Ensure cards only enter decks of the same game. + + Returns: + None: The method validates records in place. + + Raises: + ValidationError: If the card and deck game differ. + """ + for line in self: + if ( + line.card_id + and line.game_id + and line.card_id.game_id + and line.card_id.game_id != line.game_id + ): + raise ValidationError( + _( + "Deck lines can only reference cards from the same game as " + "the deck." + ) + ) + + @api.depends("role_ids", "role_ids.sequence", "role_ids.name") + def _compute_primary_role(self): + """Expose one stable primary role for sorting and grouping. + + Returns: + None: The compute updates records in place. + """ + for line in self: + role = line.role_ids.sorted( + key=lambda current_role: ( + current_role.sequence, + current_role.name or "", + current_role.id, + ) + )[:1] + line.primary_role_id = role + line.primary_role_sequence = role.sequence if role else 9999 + + def action_open_line(self): + """Open the current deck line in form view. + + Returns: + dict: Window action for the current deck line. + """ + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": self.display_name, + "res_model": "mvd.tcg.deck.line", + "view_mode": "form", + "res_id": self.id, + "target": "current", + } + + def action_open_card_preview(self): + """Open the linked card in a modal preview form. + + Returns: + dict: Window action for the linked card form. + """ + self.ensure_one() + return self._mvd_tcg_get_card_preview_action() + + def _mvd_tcg_get_card_form_view_xmlid(self): + """Return the preferred form view XMLID for linked card previews. + + Returns: + str: Stable XMLID for the target card form view. + """ + return "mvd_tcg_base.mvd_tcg_card_view_form" + + def _mvd_tcg_get_card_preview_action(self): + """Build the window action that opens the linked card preview. + + Returns: + dict: Window action for the linked card form. + """ + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": self.card_id.display_name, + "res_model": "mvd.tcg.card", + "view_mode": "form", + "res_id": self.card_id.id, + "views": [ + ( + self.env.ref(self._mvd_tcg_get_card_form_view_xmlid()).id, + "form", + ) + ], + "target": "new", + } + + def _mvd_tcg_move_to_board(self, board_code): + """Move the current line to another logical board of the same deck. + + Args: + board_code: Canonical board code such as ``mainboard``. + + Returns: + bool: ``True`` when the move completed. + + Raises: + UserError: If no matching board exists or a duplicate would be created. + """ + self.ensure_one() + target_board = self.deck_id._mvd_tcg_get_board_by_code(board_code) + if not target_board: + raise UserError(_("No target board exists for code '%s'.") % board_code) + if target_board == self.board_id: + return True + self._mvd_tcg_validate_move_to_board(target_board) + conflicting_line = self.search( + [ + ("board_id", "=", target_board.id), + ("card_id", "=", self.card_id.id), + ("id", "!=", self.id), + ], + limit=1, + ) + if conflicting_line: + raise UserError( + _( + "The selected card already exists in the target board. Merge or " + "adjust that line first." + ) + ) + self.board_id = target_board + return True + + def _mvd_tcg_validate_move_to_board(self, target_board): + """Validate whether the current line can move to another board. + + Args: + target_board: Destination board record. + + Returns: + bool: ``True`` when no game-specific rule blocks the move. + """ + self.ensure_one() + return True + +class MvdTcgDeckRole(models.Model): + """Store reusable deck line roles such as ramp or removal.""" + + _name = "mvd.tcg.deck.role" + _description = "TCG Deck Role" + _order = "sequence, name, id" + + active = fields.Boolean(default=True) + sequence = fields.Integer(default=10) + name = fields.Char(required=True, translate=True, index="trigram") + technical_key = fields.Char( + index=True, + copy=False, + groups="mvd_tcg_base.mvd_tcg_base_group_administrator,base.group_system", + ) + color = fields.Integer(default=0) + note = fields.Text(translate=True) + + _name_unique = models.Constraint( + "UNIQUE(name)", + "The deck role name must be unique.", + ) + _technical_key_unique = models.Constraint( + "UNIQUE(technical_key)", + "The technical role key must be unique.", + ) + + @api.model + def _mvd_tcg_generate_technical_key(self, name): + """Return a slug-like technical key for one deck role name.""" + return re.sub(r"[^a-z0-9]+", "_", (name or "").strip().lower()).strip("_") or "role" + + @api.model + def _mvd_tcg_get_unique_technical_key(self, name): + """Return a globally unique technical key for one deck role name.""" + base_key = self._mvd_tcg_generate_technical_key(name) + existing_keys = set(self.search([]).mapped("technical_key")) + if base_key not in existing_keys: + return base_key + suffix = 2 + while f"{base_key}_{suffix}" in existing_keys: + suffix += 1 + return f"{base_key}_{suffix}" + + def _mvd_tcg_can_edit_technical_key(self): + """Return whether the current user may edit technical role keys.""" + return self.env.is_superuser() or any( + self.env.user.has_group(xmlid) + for xmlid in ( + "mvd_tcg_base.mvd_tcg_base_group_administrator", + "base.group_system", + ) + ) + + @api.model_create_multi + def create(self, vals_list): + """Generate missing technical keys for manually created deck roles.""" + prepared_vals_list = [] + for vals in vals_list: + prepared_vals = dict(vals) + if not prepared_vals.get("technical_key"): + prepared_vals["technical_key"] = self._mvd_tcg_get_unique_technical_key( + prepared_vals.get("name") + ) + prepared_vals_list.append(prepared_vals) + return super().create(prepared_vals_list) + + def write(self, vals): + """Protect technical role keys from normal business edits.""" + if "technical_key" in vals and not self.env.context.get( + "mvd_tcg_bypass_role_key_write" + ): + if not self._mvd_tcg_can_edit_technical_key(): + raise UserError( + _( + "Deck role technical keys can only be changed by TCG " + "administrators." + ) + ) + return super().write(vals) + + @api.model + def _mvd_tcg_sync_seed_role_keys(self): + """Backfill technical keys for seeded deck roles on module updates.""" + role_xmlids = { + "ramp": "mvd_tcg_deck.mvd_tcg_deck_role_ramp", + "draw": "mvd_tcg_deck.mvd_tcg_deck_role_draw", + "removal": "mvd_tcg_deck.mvd_tcg_deck_role_removal", + "interaction": "mvd_tcg_deck.mvd_tcg_deck_role_interaction", + "protection": "mvd_tcg_deck.mvd_tcg_deck_role_protection", + "wincon": "mvd_tcg_deck.mvd_tcg_deck_role_wincon", + "value": "mvd_tcg_deck.mvd_tcg_deck_role_value", + "combo": "mvd_tcg_deck.mvd_tcg_deck_role_combo", + } + for technical_key, xmlid in role_xmlids.items(): + role = self.env.ref(xmlid, raise_if_not_found=False) + if role and role.technical_key != technical_key: + role.sudo().write({"technical_key": technical_key}) diff --git a/report/__init__.py b/report/__init__.py new file mode 100644 index 0000000..4351f55 --- /dev/null +++ b/report/__init__.py @@ -0,0 +1 @@ +from . import mvd_tcg_deck_report diff --git a/report/mvd_tcg_deck_report.py b/report/mvd_tcg_deck_report.py new file mode 100644 index 0000000..749108d --- /dev/null +++ b/report/mvd_tcg_deck_report.py @@ -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"]*>.*?

", + 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", "
")) + 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( + ( + '' + '' + "" + ) + ) + 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, + } diff --git a/report/mvd_tcg_deck_report_actions.xml b/report/mvd_tcg_deck_report_actions.xml new file mode 100644 index 0000000..f4752a1 --- /dev/null +++ b/report/mvd_tcg_deck_report_actions.xml @@ -0,0 +1,28 @@ + + + + MVD TCG Deck Report + + A4 + Portrait + 8 + 8 + 7 + 7 + + 0 + 96 + + + + Deck Report + mvd.tcg.deck + qweb-pdf + mvd_tcg_deck.report_mvd_tcg_deck_document + mvd_tcg_deck.report_mvd_tcg_deck_document + 'Deck - %s' % (object.name) + + + report + + diff --git a/report/mvd_tcg_deck_report_templates.xml b/report/mvd_tcg_deck_report_templates.xml new file mode 100644 index 0000000..7e0fd9d --- /dev/null +++ b/report/mvd_tcg_deck_report_templates.xml @@ -0,0 +1,763 @@ + + + + diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv new file mode 100644 index 0000000..d5b65d6 --- /dev/null +++ b/security/ir.model.access.csv @@ -0,0 +1,17 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mvd_tcg_deck_user,mvd.tcg.deck.user,model_mvd_tcg_deck,mvd_tcg_base.mvd_tcg_base_group_user,1,1,1,1 +access_mvd_tcg_deck_manager,mvd.tcg.deck.manager,model_mvd_tcg_deck,mvd_tcg_base.mvd_tcg_base_group_manager,1,1,1,1 +access_mvd_tcg_deck_system,mvd.tcg.deck.system,model_mvd_tcg_deck,base.group_system,1,1,1,1 +access_mvd_tcg_deck_board_user,mvd.tcg.deck.board.user,model_mvd_tcg_deck_board,mvd_tcg_base.mvd_tcg_base_group_user,1,1,1,1 +access_mvd_tcg_deck_board_manager,mvd.tcg.deck.board.manager,model_mvd_tcg_deck_board,mvd_tcg_base.mvd_tcg_base_group_manager,1,1,1,1 +access_mvd_tcg_deck_board_system,mvd.tcg.deck.board.system,model_mvd_tcg_deck_board,base.group_system,1,1,1,1 +access_mvd_tcg_deck_line_user,mvd.tcg.deck.line.user,model_mvd_tcg_deck_line,mvd_tcg_base.mvd_tcg_base_group_user,1,1,1,1 +access_mvd_tcg_deck_line_manager,mvd.tcg.deck.line.manager,model_mvd_tcg_deck_line,mvd_tcg_base.mvd_tcg_base_group_manager,1,1,1,1 +access_mvd_tcg_deck_line_system,mvd.tcg.deck.line.system,model_mvd_tcg_deck_line,base.group_system,1,1,1,1 +access_mvd_tcg_deck_role_user,mvd.tcg.deck.role.user,model_mvd_tcg_deck_role,mvd_tcg_base.mvd_tcg_base_group_user,1,0,0,0 +access_mvd_tcg_deck_role_manager,mvd.tcg.deck.role.manager,model_mvd_tcg_deck_role,mvd_tcg_base.mvd_tcg_base_group_manager,1,1,1,1 +access_mvd_tcg_deck_role_system,mvd.tcg.deck.role.system,model_mvd_tcg_deck_role,base.group_system,1,1,1,1 +access_mvd_tcg_add_to_deck_user,mvd.tcg.add.to.deck.user,model_mvd_tcg_add_to_deck,mvd_tcg_base.mvd_tcg_base_group_user,1,1,1,1 +access_mvd_tcg_add_to_deck_system,mvd.tcg.add.to.deck.system,model_mvd_tcg_add_to_deck,base.group_system,1,1,1,1 +access_mvd_tcg_deck_text_transfer_user,mvd.tcg.deck.text.transfer.user,model_mvd_tcg_deck_text_transfer,mvd_tcg_base.mvd_tcg_base_group_user,1,1,1,1 +access_mvd_tcg_deck_text_transfer_system,mvd.tcg.deck.text.transfer.system,model_mvd_tcg_deck_text_transfer,base.group_system,1,1,1,1 diff --git a/security/mvd_tcg_deck_security.xml b/security/mvd_tcg_deck_security.xml new file mode 100644 index 0000000..ce55b4c --- /dev/null +++ b/security/mvd_tcg_deck_security.xml @@ -0,0 +1,44 @@ + + + + Own decks + + [('user_id', '=', user.id)] + + + + + All decks for managers + + [(1, '=', 1)] + + + + + Own deck boards + + [('deck_id.user_id', '=', user.id)] + + + + + All deck boards for managers + + [(1, '=', 1)] + + + + + Own deck lines + + [('deck_id.user_id', '=', user.id)] + + + + + All deck lines for managers + + [(1, '=', 1)] + + + diff --git a/static/src/js/mvd_deck_zoom_image_field.js b/static/src/js/mvd_deck_zoom_image_field.js new file mode 100644 index 0000000..e652250 --- /dev/null +++ b/static/src/js/mvd_deck_zoom_image_field.js @@ -0,0 +1,50 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { ImageField, imageField } from "@web/views/fields/image/image_field"; + +class MvdDeckZoomImageField extends ImageField { + static template = "mvd_tcg_deck.MvdDeckZoomImageField"; + + setup() { + super.setup(); + this.actionService = useService("action"); + } + + get isOpenable() { + return Boolean(this.props.record?.resId && this.props.record?.data?.card_id); + } + + get tooltipAttributes() { + const fieldName = this.props.previewImage || this.props.name; + return { + template: "web.ImageZoomTooltip", + info: JSON.stringify({ url: this.getUrl(fieldName) }), + }; + } + + async onImageClick(ev) { + if (!this.isOpenable) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + const action = await this.orm.call( + this.props.record.resModel, + "action_open_card_preview", + [[this.props.record.resId]] + ); + if (action) { + return this.actionService.doAction(action); + } + return undefined; + } +} + +export const mvdDeckZoomImageField = { + ...imageField, + component: MvdDeckZoomImageField, +}; + +registry.category("fields").add("mvd_deck_zoom_image", mvdDeckZoomImageField); diff --git a/static/src/xml/mvd_deck_zoom_image_field.xml b/static/src/xml/mvd_deck_zoom_image_field.xml new file mode 100644 index 0000000..3fa6a42 --- /dev/null +++ b/static/src/xml/mvd_deck_zoom_image_field.xml @@ -0,0 +1,15 @@ + + + + + + `${imgClass}${isOpenable ? ' cursor-pointer' : ''}` + onImageClick + + + + diff --git a/views/menu_views.xml b/views/menu_views.xml new file mode 100644 index 0000000..ef520e7 --- /dev/null +++ b/views/menu_views.xml @@ -0,0 +1,11 @@ + + + + diff --git a/views/mvd_tcg_add_to_deck_views.xml b/views/mvd_tcg_add_to_deck_views.xml new file mode 100644 index 0000000..448c18d --- /dev/null +++ b/views/mvd_tcg_add_to_deck_views.xml @@ -0,0 +1,23 @@ + + + + mvd.tcg.add.to.deck.view.form + mvd.tcg.add.to.deck + +
+ + + + + + + + +
+
+
+
+
+
diff --git a/views/mvd_tcg_card_views.xml b/views/mvd_tcg_card_views.xml new file mode 100644 index 0000000..12e6b6b --- /dev/null +++ b/views/mvd_tcg_card_views.xml @@ -0,0 +1,28 @@ + + + + mvd.tcg.card.view.form.deck.inherit + mvd.tcg.card + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + mvd.tcg.deck.line.view.list + mvd.tcg.deck.line + + + + + + + + + + + + + + + + + mvd.tcg.deck.line.view.form + mvd.tcg.deck.line + +
+ +
+ +
+
+ +
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + mvd.tcg.deck.role.view.list + mvd.tcg.deck.role + + + + + + + + + + + + mvd.tcg.deck.role.view.form + mvd.tcg.deck.role + +
+ +
+
+ + + + + + + + + + + + + + + +
+
+
+
+ + + mvd.tcg.deck.view.form + mvd.tcg.deck + +
+ + +
+ + + + + + +
+
+ +
+

+ +

+
+ + +
+
+ + Boards + + + + Cards + + + + Distinct + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + Deck Lines + mvd.tcg.deck.line + list,form + + + + + Deck Roles + mvd.tcg.deck.role + list,form + + + + Decks + mvd.tcg.deck + kanban,list,form + + + +

+ Create a new deck +

+

+ Decks stay game-neutral. Boards organize the cards inside one deck, + while game adapters can later add format- and rules-specific behavior. +

+
+
+ + +
diff --git a/wizards/__init__.py b/wizards/__init__.py new file mode 100644 index 0000000..d41b786 --- /dev/null +++ b/wizards/__init__.py @@ -0,0 +1,2 @@ +from . import mvd_tcg_add_to_deck +from . import mvd_tcg_deck_text_transfer diff --git a/wizards/mvd_tcg_add_to_deck.py b/wizards/mvd_tcg_add_to_deck.py new file mode 100644 index 0000000..77d780f --- /dev/null +++ b/wizards/mvd_tcg_add_to_deck.py @@ -0,0 +1,114 @@ +"""Transient helpers for adding cards to game-neutral decks.""" + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class MvdTcgAddToDeck(models.TransientModel): + """Add one reference card to one deck board.""" + + _name = "mvd.tcg.add.to.deck" + _description = "Add TCG Card To Deck" + + card_id = fields.Many2one( + "mvd.tcg.card", + required=True, + readonly=True, + ) + game_id = fields.Many2one( + "mvd.tcg.game", + readonly=True, + ) + deck_id = fields.Many2one( + "mvd.tcg.deck", + required=True, + domain="[('active', '=', True), ('game_id', '=', game_id)]", + ) + board_id = fields.Many2one( + "mvd.tcg.deck.board", + required=True, + domain="[('deck_id', '=', deck_id)]", + ) + quantity = fields.Integer(required=True, default=1) + role_ids = fields.Many2many( + "mvd.tcg.deck.role", + string="Roles", + ) + note = fields.Char() + + @api.model + def default_get(self, field_names): + """Prefill the wizard from the active card context. + + Args: + field_names: Requested wizard fields. + + Returns: + dict: Initial field values. + """ + defaults = super().default_get(field_names) + card_id = defaults.get("card_id") or self.env.context.get("active_id") + if self.env.context.get("active_model") == "mvd.tcg.card" and card_id: + card = self.env["mvd.tcg.card"].browse(card_id).exists() + if card: + defaults["card_id"] = card.id + defaults["game_id"] = card.game_id.id + deck_id = defaults.get("deck_id") or self.env.context.get("default_deck_id") + if deck_id: + deck = self.env["mvd.tcg.deck"].browse(deck_id).exists() + if deck: + defaults["deck_id"] = deck.id + defaults.setdefault("game_id", deck.game_id.id) + board_id = defaults.get("board_id") or self.env.context.get("default_board_id") + if board_id: + board = self.env["mvd.tcg.deck.board"].browse(board_id).exists() + if board: + defaults["board_id"] = board.id + defaults.setdefault("deck_id", board.deck_id.id) + defaults.setdefault("game_id", board.deck_id.game_id.id) + return defaults + + @api.onchange("deck_id") + def _onchange_deck_id(self): + """Preselect the most likely target board for the chosen deck. + + Returns: + None: The method updates wizard fields in place. + """ + if not self.deck_id: + self.board_id = False + return + preferred_board = self.deck_id.board_ids.filtered( + lambda board: board.code == "mainboard" + )[:1] + self.board_id = preferred_board or self.deck_id.board_ids[:1] + + def action_add_to_deck(self): + """Create or increment a deck line for the selected card. + + Returns: + dict: Window action for the updated deck. + + Raises: + UserError: If the wizard lacks a valid card context. + """ + self.ensure_one() + if not self.card_id: + raise UserError(_("Select a card first.")) + + self.deck_id._mvd_tcg_add_card_to_board( + self.card_id, + self.board_id, + quantity=self.quantity, + role_ids=self.role_ids, + note=self.note, + ) + + return { + "type": "ir.actions.act_window", + "name": _("Deck"), + "res_model": "mvd.tcg.deck", + "view_mode": "form", + "res_id": self.deck_id.id, + "target": "current", + } diff --git a/wizards/mvd_tcg_deck_text_transfer.py b/wizards/mvd_tcg_deck_text_transfer.py new file mode 100644 index 0000000..461287d --- /dev/null +++ b/wizards/mvd_tcg_deck_text_transfer.py @@ -0,0 +1,335 @@ +"""Import and export decklists as plain text.""" + +import re + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class MvdTcgDeckTextTransfer(models.TransientModel): + """Round-trip decklists through a text-based import/export wizard.""" + + _name = "mvd.tcg.deck.text.transfer" + _description = "TCG Deck Text Transfer" + + _LINE_PATTERN = re.compile( + r"^(?:(?P\d+)\s*x?\s+)?(?P.+?)" + r"(?:\s+\[(?P[^\]]+)\]" + r"|\s+\((?P[A-Za-z0-9]+)\s+(?P[^)]+)\))?$" + ) + _BOARD_ALIASES = { + "mainboard": "mainboard", + "main": "mainboard", + "command zone": "command_zone", + "commandzone": "command_zone", + "command": "command_zone", + "commander": "command_zone", + "sideboard": "sideboard", + "side": "sideboard", + "maybeboard": "maybeboard", + "maybe": "maybeboard", + } + + deck_id = fields.Many2one( + "mvd.tcg.deck", + required=True, + readonly=True, + ) + game_id = fields.Many2one( + "mvd.tcg.game", + related="deck_id.game_id", + readonly=True, + ) + operation = fields.Selection( + [ + ("import", "Import"), + ("export", "Export"), + ], + required=True, + default="import", + readonly=True, + ) + replace_existing = fields.Boolean( + string="Replace Existing Deck Lines", + help="Clear all current deck entries before importing the provided deck list.", + ) + line_text = fields.Text( + string="Deck List", + required=True, + ) + + @api.model + def default_get(self, field_names): + """Initialize the wizard from the current deck context. + + Args: + field_names: Requested wizard fields. + + Returns: + dict: Prefilled wizard values. + """ + defaults = super().default_get(field_names) + deck_id = defaults.get("deck_id") or self.env.context.get("default_deck_id") + if not deck_id and self.env.context.get("active_model") == "mvd.tcg.deck": + deck_id = self.env.context.get("active_id") + if deck_id: + deck = self.env["mvd.tcg.deck"].browse(deck_id).exists() + if deck: + defaults["deck_id"] = deck.id + if ( + defaults.get("operation") == "export" + and defaults.get("deck_id") + and "line_text" in field_names + ): + deck = self.env["mvd.tcg.deck"].browse(defaults["deck_id"]).exists() + if deck: + defaults["line_text"] = self._build_export_text(deck) + return defaults + + def _normalize_board_heading(self, raw_heading): + """Resolve one free-form board heading to a canonical code. + + Args: + raw_heading: Raw heading text from the import payload. + + Returns: + str | bool: Canonical board code or ``False`` when unmatched. + """ + heading = (raw_heading or "").strip().lstrip("#").strip().lower() + heading = re.sub(r"[^a-z\s_]", "", heading).replace("_", " ") + heading = re.sub(r"\s+", " ", heading) + return self.env["mvd.tcg.deck"]._mvd_tcg_normalize_board_code( + self._BOARD_ALIASES.get(heading) or heading + ) + + def _build_export_text(self, deck): + """Render one deck as a deterministic plain-text list. + + Args: + deck: Deck record to serialize. + + Returns: + str: Plain-text export payload with board headings. + """ + exported_sections = [] + for board in deck.board_ids.sorted(key=lambda current_board: (current_board.sequence, current_board.id)): + if not board.line_ids: + continue + exported_sections.append( + f"# {deck._mvd_tcg_get_board_export_heading(board)}" + ) + for line in board.line_ids.sorted( + key=lambda current_line: ( + current_line.primary_role_sequence, + current_line.board_sequence, + current_line.sequence, + current_line.id, + ) + ): + card_name = line.card_id.display_name + print_hint = self._get_export_print_hint(line.card_id) + exported_sections.append( + f"{line.quantity} {card_name}{print_hint}" + ) + exported_sections.append("") + return "\n".join(exported_sections).strip() + + @classmethod + def _normalize_import_reference(cls, match): + """Convert one parsed optional print reference into the canonical key. + + Args: + match: Regex match produced by ``_LINE_PATTERN``. + + Returns: + str: Canonical import reference such as ``tdm:101``. + """ + bracket_reference = (match.group("bracket_ref") or "").strip().lower() + if bracket_reference: + return bracket_reference + + print_set = (match.group("print_set") or "").strip().lower() + print_collector = (match.group("print_collector") or "").strip().lower() + if print_set and print_collector: + return f"{print_set}:{print_collector}" + return "" + + @classmethod + def _get_export_print_hint(cls, card): + """Return one human-readable print hint for export when available. + + Args: + card: Card record being exported. + + Returns: + str: Optional print hint such as `` (TDM 101)``. + """ + external_ref = (card.external_ref or "").strip() + if ":" not in external_ref: + return "" + + set_code, collector_number = external_ref.split(":", 1) + set_code = set_code.strip().upper() + collector_number = collector_number.strip() + if not set_code or not collector_number: + return "" + return f" ({set_code} {collector_number})" + + def _find_card_for_import(self, deck, raw_name, raw_ref=False): + """Resolve one import line to one unique reference card. + + Args: + deck: Target deck record. + raw_name: Human-readable imported card name. + raw_ref: Optional explicit reference such as ``tdm:101``. + + Returns: + mvd.tcg.card: Unique matching card record. + + Raises: + UserError: If no unique card could be resolved. + """ + Card = self.env["mvd.tcg.card"] + base_domain = [ + ("game_id", "=", deck.game_id.id), + ("active", "=", True), + ] + external_ref = (raw_ref or "").strip().lower() + card_name = (raw_name or "").strip() + + if external_ref: + exact_by_ref = Card.search(base_domain + [("external_ref", "=", external_ref)]) + if len(exact_by_ref) == 1: + return exact_by_ref + if len(exact_by_ref) > 1: + raise UserError( + _( + "The explicit reference '%(reference)s' matches multiple cards. Narrow the import source first." + ) + % {"reference": external_ref} + ) + + exact_current_lang = Card.search(base_domain + [("name", "=", card_name)]) + exact_english = Card.with_context(lang="en_US").search( + base_domain + [("name", "=", card_name)] + ) + exact_matches = (exact_current_lang | exact_english) + if len(exact_matches) == 1: + return exact_matches + if len(exact_matches) > 1: + raise UserError( + _( + "The card '%(name)s' matches multiple printings. Add a print hint such as '(TDM 101)' or pick the card directly in the deck builder." + ) + % {"name": card_name} + ) + + fuzzy_current_lang = Card.search(base_domain + [("name", "=ilike", card_name)]) + fuzzy_english = Card.with_context(lang="en_US").search( + base_domain + [("name", "=ilike", card_name)] + ) + fuzzy_matches = (fuzzy_current_lang | fuzzy_english) + if len(fuzzy_matches) == 1: + return fuzzy_matches + if len(fuzzy_matches) > 1: + raise UserError( + _( + "The card '%(name)s' matches multiple printings. Add a print hint such as '(TDM 101)' or use a more specific card name." + ) + % {"name": card_name} + ) + raise UserError(_("No active card named '%s' exists in the current game.") % card_name) + + def _parse_import_payload(self): + """Parse the current text payload into structured import commands. + + Returns: + list[dict]: Parsed commands with board, quantity and card lookup data. + + Raises: + UserError: If the text payload contains invalid lines. + """ + self.ensure_one() + default_board = self.deck_id._mvd_tcg_get_board_by_code("mainboard") or self.deck_id.board_ids[:1] + current_board_code = default_board.code if default_board else False + parsed_lines = [] + parsing_errors = [] + + for line_number, raw_line in enumerate((self.line_text or "").splitlines(), start=1): + stripped_line = raw_line.strip() + if not stripped_line: + continue + if stripped_line.startswith("//"): + continue + normalized_heading = self._normalize_board_heading(stripped_line) + if normalized_heading: + current_board_code = normalized_heading + continue + if stripped_line.startswith("#"): + normalized_heading = self._normalize_board_heading(stripped_line[1:]) + if normalized_heading: + current_board_code = normalized_heading + continue + match = self._LINE_PATTERN.match(stripped_line) + if not match or not current_board_code: + parsing_errors.append(_("Line %(line)s could not be parsed: %(value)s") % { + "line": line_number, + "value": stripped_line, + }) + continue + parsed_lines.append( + { + "line_number": line_number, + "board_code": current_board_code, + "quantity": int(match.group("qty") or 1), + "name": (match.group("name") or "").strip(), + "external_ref": self._normalize_import_reference(match), + } + ) + + if parsing_errors: + raise UserError("\n".join(parsing_errors)) + return parsed_lines + + def action_apply_text_transfer(self): + """Run the selected import or export operation. + + Returns: + dict: Window action returning to the current deck. + """ + self.ensure_one() + if self.operation == "export": + return {"type": "ir.actions.act_window_close"} + + deck = self.deck_id + deck._mvd_tcg_seed_default_boards() + board_map = {board.code: board for board in deck.board_ids} + parsed_lines = self._parse_import_payload() + if self.replace_existing: + deck.line_ids.unlink() + + for parsed_line in parsed_lines: + target_board = board_map.get(parsed_line["board_code"]) + if not target_board: + raise UserError( + _("No board exists for code '%s'.") % parsed_line["board_code"] + ) + card = self._find_card_for_import( + deck, + parsed_line["name"], + raw_ref=parsed_line["external_ref"], + ) + deck._mvd_tcg_add_card_to_board( + card, + target_board, + quantity=parsed_line["quantity"], + ) + + return { + "type": "ir.actions.act_window", + "name": deck.display_name, + "res_model": "mvd.tcg.deck", + "view_mode": "form", + "res_id": deck.id, + "target": "current", + }