"""Scryfall-specific MTG card import helpers for the MTG connector.""" import re from odoo import _, Command, api, fields, models KEYWORD_CODE_PATTERN = re.compile(r"[^a-z0-9]+") class MvdTcgCard(models.Model): """Extend MTG card references with Scryfall connector metadata.""" _inherit = "mvd.tcg.card" mtg_scryfall_id = fields.Char(string="Scryfall ID", index=True) mtg_scryfall_uri = fields.Char(string="Scryfall URL", copy=False, readonly=True) mtg_scryfall_last_import_run_id = fields.Many2one( "mvd.tcg.mtg.scryfall.import.run", string="Last Import Run", copy=False, index=True, ondelete="set null", readonly=True, ) mtg_scryfall_last_synced_at = fields.Datetime( string="Last Synced At", copy=False, readonly=True, ) mtg_scryfall_image_ids = fields.One2many( "mvd.tcg.mtg.card.image", "card_id", string="Localized Images", copy=False, readonly=True, ) mtg_display_image_1920 = fields.Image( string="Localized Image", compute="_compute_mtg_display_images", max_width=1920, max_height=1920, ) mtg_display_image_512 = fields.Image( string="Localized Preview Image", compute="_compute_mtg_display_images", max_width=512, max_height=512, ) def _mvd_tcg_get_deck_image_binary(self): """Prefer localized display images for deck previews. Returns: str | bool: Base64 image data for deck-related previews. """ self.ensure_one() return ( self.mtg_display_image_512 or self.mtg_display_image_1920 or super()._mvd_tcg_get_deck_image_binary() ) def _mtg_scryfall_get_refresh_query(self): """Return the preferred lookup query for a Scryfall refresh. Returns: str | bool: URL or identifier reusable by the shared lookup parser. """ self.ensure_one() if self.mtg_scryfall_uri: return self.mtg_scryfall_uri if self.mtg_scryfall_id: return self.mtg_scryfall_id if self.mtg_set_code and self.mtg_collector_number: return ( f"https://api.scryfall.com/cards/" f"{self.mtg_set_code}/{self.mtg_collector_number}" ) return False @api.model def _mtg_scryfall_build_external_ref(self, payload): """Build the stable MTG print reference used by the connector. Args: payload: Raw Scryfall card payload. Returns: str: Stable reference in ``set_code:collector_number`` form. """ set_code = (payload.get("set") or "").strip().lower() collector_number = (payload.get("collector_number") or "").strip().lower() return f"{set_code}:{collector_number}" @api.model def _mtg_scryfall_select_primary_payload(self, payloads): """Return the payload that should drive primary MTG field values. Args: payloads: Raw localized Scryfall payloads for one print group. Returns: dict: Primary payload, preferring English when available. """ valid_payloads = [ payload for payload in (payloads or []) if payload.get("object") == "card" and payload.get("id") ] if not valid_payloads: return {} english_payload = next( ( payload for payload in valid_payloads if (payload.get("lang") or "").strip().lower() == "en" ), False, ) return english_payload or valid_payloads[0] @api.model def _mtg_scryfall_extract_image_url(self, payload): """Return the best image URL available for a Scryfall payload. Args: payload: Raw Scryfall card payload. Returns: str | bool: Image URL or ``False`` when none is available. """ image_uris = payload.get("image_uris") or {} if image_uris.get("normal"): return image_uris["normal"] for face_payload in payload.get("card_faces") or []: face_image_uris = face_payload.get("image_uris") or {} if face_image_uris.get("normal"): return face_image_uris["normal"] return False @api.model def _mtg_scryfall_load_group_image_base64(self, payloads): """Load the best available card image for one localized print group. Args: payloads: Raw localized Scryfall payloads for one print group. Returns: str | bool: Base64-encoded image data or ``False`` when unavailable. """ scryfall_api = self.env["mvd.tcg.mtg.scryfall.api"] for payload in payloads or (): image_base64 = scryfall_api.load_image_base64( self._mtg_scryfall_extract_image_url(payload) ) if image_base64: return image_base64 return False @api.model def _mtg_scryfall_collect_group_images(self, payloads): """Collect localized imported images for one Scryfall print group. Args: payloads: Raw localized Scryfall payloads for one print group. Returns: dict[str, str]: Base64 image data keyed by Scryfall language code. """ scryfall_api = self.env["mvd.tcg.mtg.scryfall.api"] collected_images = {} for payload in payloads or (): language_code = (payload.get("lang") or "").strip().lower() or "en" if language_code in collected_images: continue image_base64 = scryfall_api.load_image_base64( self._mtg_scryfall_extract_image_url(payload) ) if image_base64: collected_images[language_code] = image_base64 return collected_images @api.model def _mtg_scryfall_extract_card_type_codes(self, payload): """Extract normalized MTG card type codes from a Scryfall payload. Args: payload: Raw Scryfall card payload. Returns: list[str]: Known MTG card type codes present on the card. """ known_codes = { card_type.code: card_type.code for card_type in self.env["mvd.tcg.mtg.card.type"].search([]) } raw_type_values = [payload.get("type_line")] raw_type_values.extend( face_payload.get("type_line") for face_payload in payload.get("card_faces") or [] ) extracted_codes = [] for raw_type_value in raw_type_values: normalized_value = (raw_type_value or "").replace("—", " ") for token in normalized_value.split(): type_code = token.strip().lower() if type_code in known_codes and type_code not in extracted_codes: extracted_codes.append(type_code) return extracted_codes @api.model def _mtg_scryfall_extract_face_payloads(self, payload): """Return ordered card-face payloads from one Scryfall payload. Args: payload: Raw Scryfall card payload. Returns: list[dict]: Ordered face payloads, or an empty list for single-face cards. """ return [ face_payload for face_payload in (payload.get("card_faces") or []) if isinstance(face_payload, dict) ] @api.model def _mtg_scryfall_normalize_keyword_code(self, keyword_name): """Build a stable code for one MTG keyword. Args: keyword_name: Human keyword such as ``Double strike``. Returns: str: Normalized keyword code. """ normalized_keyword = KEYWORD_CODE_PATTERN.sub( "-", (keyword_name or "").strip().lower(), ).strip("-") return normalized_keyword or False @api.model def _mtg_scryfall_resolve_taxonomy_records( self, model_name, codes, *, display_names=None, create_missing=False, ): """Resolve normalized taxonomy records from codes. Args: model_name: Technical taxonomy model name. codes: Raw taxonomy codes. display_names: Optional display names keyed by normalized code. create_missing: Whether unknown codes should be created. Returns: Model: Matching taxonomy records. """ normalized_codes = [] for code in codes or []: normalized_code = (code or "").strip().lower() if normalized_code and normalized_code not in normalized_codes: normalized_codes.append(normalized_code) if not normalized_codes: return self.env[model_name] taxonomy_model = self.env[model_name] taxonomy_records = taxonomy_model.search([("code", "in", normalized_codes)]) records_by_code = {record.code: record for record in taxonomy_records} if create_missing: for normalized_code in normalized_codes: if normalized_code in records_by_code: continue display_name = (display_names or {}).get(normalized_code) or normalized_code taxonomy_record = taxonomy_model.create( { "name": display_name, "code": normalized_code, } ) records_by_code[normalized_code] = taxonomy_record return taxonomy_model.browse( [records_by_code[code].id for code in normalized_codes if code in records_by_code] ) @api.model def _mtg_scryfall_map_translation_language_codes(self, scryfall_language_code): """Map one Scryfall language code to active Odoo language codes. Args: scryfall_language_code: Raw Scryfall language code such as ``de``. Returns: list[str]: Active Odoo language codes for translation updates. """ return self.env["mvd.tcg.mtg.scryfall.api"].map_translation_language_codes( scryfall_language_code ) def _mtg_scryfall_update_translations_from_payload(self, payload): """Update translated MTG card fields from one localized payload. Args: payload: Raw localized Scryfall card payload. Returns: None: The record is updated in place. """ self.ensure_one() card = self.with_context( mvd_tcg_bypass_validated_write=True, mvd_tcg_bypass_external_ref_write=True, ) language_codes = card._mtg_scryfall_map_translation_language_codes( payload.get("lang") ) if not language_codes: return translated_values = { "name": payload.get("printed_name") or payload.get("name") or False, "mtg_type_line": payload.get("printed_type_line") or payload.get("type_line") or False, "mtg_oracle_text": payload.get("printed_text") or payload.get("oracle_text") or False, "mtg_flavor_text": payload.get("flavor_text") or False, } for field_name, field_value in translated_values.items(): if field_value: card.update_field_translations( field_name, { language_code: field_value for language_code in language_codes }, ) def _mtg_scryfall_update_face_translations_from_payload(self, payload): """Update translated face fields from one localized Scryfall payload. Args: payload: Raw localized Scryfall card payload. Returns: None: The record is updated in place. """ self.ensure_one() language_codes = self._mtg_scryfall_map_translation_language_codes( payload.get("lang") ) if not language_codes: return localized_faces = self._mtg_scryfall_extract_face_payloads(payload) if not localized_faces: return faces_by_sequence = {face.sequence: face for face in self.mtg_face_ids} for index, face_payload in enumerate(localized_faces, start=1): face = faces_by_sequence.get(index) if not face: continue translated_values = { "name": face_payload.get("printed_name") or face_payload.get("name") or False, "type_line": face_payload.get("printed_type_line") or face_payload.get("type_line") or False, "oracle_text": face_payload.get("printed_text") or face_payload.get("oracle_text") or False, "flavor_text": face_payload.get("flavor_text") or False, } for field_name, field_value in translated_values.items(): if field_value: face.with_context( mvd_tcg_bypass_validated_write=True, mvd_tcg_bypass_external_ref_write=True, ).update_field_translations( field_name, { language_code: field_value for language_code in language_codes }, ) def _mtg_scryfall_get_preferred_image_language_codes(self): """Return preferred Scryfall image languages for the current UI context. Returns: list[str]: Ordered Scryfall language codes with fallbacks. """ self.ensure_one() scryfall_api = self.env["mvd.tcg.mtg.scryfall.api"] current_odoo_language_code = self.env.context.get("lang") or self.env.user.lang language_codes = [] preferred_scryfall_code = scryfall_api.map_scryfall_language_code( current_odoo_language_code ) if preferred_scryfall_code: language_codes.append(preferred_scryfall_code) if "en" not in language_codes: language_codes.append("en") return language_codes def _mtg_scryfall_sync_localized_images(self, localized_images): """Create or update localized imported images for the current card. Args: localized_images: Base64 image data keyed by Scryfall language code. Returns: None: The record is updated in place. """ self.ensure_one() card = self.with_context( mvd_tcg_bypass_validated_write=True, mvd_tcg_bypass_external_ref_write=True, ) image_model = self.env["mvd.tcg.mtg.card.image"] existing_images = { (image.language_code or "").strip().lower(): image for image in card.mtg_scryfall_image_ids } for language_code, image_base64 in (localized_images or {}).items(): normalized_language_code = (language_code or "").strip().lower() if not normalized_language_code or not image_base64: continue existing_image = existing_images.get(normalized_language_code) values = { "card_id": self.id, "language_code": normalized_language_code, "image_1920": image_base64, } if existing_image: existing_image.write({"image_1920": image_base64}) else: image_model.create(values) def _mtg_scryfall_sync_faces_from_payloads(self, payloads): """Create or update ordered MTG card faces from localized payloads. Args: payloads: Raw localized Scryfall payloads for one print group. Returns: None: The card faces are synchronized in place. """ self.ensure_one() card = self.with_context( mvd_tcg_bypass_validated_write=True, mvd_tcg_bypass_external_ref_write=True, ) primary_payload = self._mtg_scryfall_select_primary_payload(payloads) primary_face_payloads = self._mtg_scryfall_extract_face_payloads(primary_payload) existing_faces = {face.sequence: face for face in card.mtg_face_ids} kept_face_ids = [] for index, face_payload in enumerate(primary_face_payloads, start=1): values = { "card_id": self.id, "sequence": index, "name": face_payload.get("name") or _("Face %s") % index, "mana_cost": face_payload.get("mana_cost") or False, "type_line": face_payload.get("type_line") or False, "oracle_text": face_payload.get("oracle_text") or False, "flavor_text": face_payload.get("flavor_text") or False, "power": face_payload.get("power") or False, "toughness": face_payload.get("toughness") or False, "loyalty": face_payload.get("loyalty") or False, "artist": face_payload.get("artist") or False, } face = existing_faces.get(index) if face: face.with_context( mvd_tcg_bypass_validated_write=True, mvd_tcg_bypass_external_ref_write=True, ).write(values) else: face = self.env["mvd.tcg.mtg.card.face"].create(values) kept_face_ids.append(face.id) ( card.mtg_face_ids - self.env["mvd.tcg.mtg.card.face"].browse(kept_face_ids) ).with_context(mvd_tcg_bypass_validated_write=True).unlink() for payload in payloads or (): card._mtg_scryfall_update_face_translations_from_payload(payload) def _mtg_scryfall_sync_legalities(self, primary_payload): """Create or update legality rows from one primary Scryfall payload. Args: primary_payload: Canonical payload for the current print group. Returns: None: The legality rows are synchronized in place. """ self.ensure_one() card = self.with_context( mvd_tcg_bypass_validated_write=True, mvd_tcg_bypass_external_ref_write=True, ) legality_payload = primary_payload.get("legalities") or {} format_records = self._mtg_scryfall_resolve_taxonomy_records( "mvd.tcg.mtg.format", legality_payload.keys(), create_missing=False, ) existing_legalities = { legality.format_id.id: legality for legality in card.mtg_legality_ids } kept_legality_ids = [] for format_record in format_records: status = (legality_payload.get(format_record.code) or "").strip().lower() or "not_legal" values = { "card_id": self.id, "format_id": format_record.id, "status": status, } legality = existing_legalities.get(format_record.id) if legality: legality.with_context(mvd_tcg_bypass_validated_write=True).write( {"status": status} ) else: legality = self.env["mvd.tcg.mtg.card.legality"].create(values) kept_legality_ids.append(legality.id) ( card.mtg_legality_ids - self.env["mvd.tcg.mtg.card.legality"].browse(kept_legality_ids) ).with_context(mvd_tcg_bypass_validated_write=True).unlink() @api.depends( "image_1920", "mtg_scryfall_image_ids", "mtg_scryfall_image_ids.language_code", "mtg_scryfall_image_ids.image_1920", ) @api.depends_context("lang") def _compute_mtg_display_images(self): """Render the best localized card image for the current UI language. Returns: None: The compute updates records in place. """ for card in self: localized_images = { (image.language_code or "").strip().lower(): image.image_1920 for image in card.mtg_scryfall_image_ids if image.image_1920 } display_image = False for language_code in card._mtg_scryfall_get_preferred_image_language_codes(): display_image = localized_images.get(language_code) if display_image: break display_image = display_image or card.image_1920 card.mtg_display_image_1920 = display_image card.mtg_display_image_512 = display_image @api.model def _mtg_scryfall_prepare_group_values(self, primary_payload, *, import_run=None): """Prepare ORM values for one localized Scryfall print group. Args: primary_payload: Canonical payload that drives the shared fields. import_run: Optional import run record for traceability. Returns: dict: ORM values for create or write operations. """ mtg_game = self.env["mvd.tcg.game"]._mvd_tcg_get_mtg_game() mtg_set = self.env["mvd.tcg.mtg.set"].mtg_scryfall_upsert_from_payload( primary_payload, import_run=import_run, ) rarity = self.env["mvd.tcg.mtg.rarity"].search( [("code", "=", (primary_payload.get("rarity") or "").strip().lower())], limit=1, ) color_codes = [ (color_code or "").strip().upper() for color_code in (primary_payload.get("colors") or []) if color_code ] color_records = self.env["mvd.tcg.mtg.color"].search( [("code", "in", color_codes)] ) color_identity_codes = [ (color_code or "").strip().upper() for color_code in (primary_payload.get("color_identity") or []) if color_code ] color_identity_records = self.env["mvd.tcg.mtg.color"].search( [("code", "in", color_identity_codes)] ) card_type_codes = self._mtg_scryfall_extract_card_type_codes(primary_payload) card_type_records = self.env["mvd.tcg.mtg.card.type"].search( [("code", "in", card_type_codes)] ) keyword_display_names = { self._mtg_scryfall_normalize_keyword_code(keyword_name): keyword_name for keyword_name in (primary_payload.get("keywords") or []) if self._mtg_scryfall_normalize_keyword_code(keyword_name) } keyword_records = self._mtg_scryfall_resolve_taxonomy_records( "mvd.tcg.mtg.keyword", keyword_display_names.keys(), display_names=keyword_display_names, create_missing=True, ) platform_records = self._mtg_scryfall_resolve_taxonomy_records( "mvd.tcg.mtg.platform", primary_payload.get("games") or [], ) finish_records = self._mtg_scryfall_resolve_taxonomy_records( "mvd.tcg.mtg.finish", primary_payload.get("finishes") or [], ) values = { "active": True, "external_ref": self._mtg_scryfall_build_external_ref(primary_payload), "game_id": mtg_game.id, "state": "validated", "mtg_scryfall_id": primary_payload.get("id") or False, "mtg_scryfall_uri": primary_payload.get("scryfall_uri") or False, "mtg_set_id": mtg_set.id, "mtg_rarity_id": rarity.id or False, "mtg_collector_number": primary_payload.get("collector_number") or False, "mtg_oracle_id": primary_payload.get("oracle_id") or False, "mtg_layout": primary_payload.get("layout") or False, "mtg_mana_cost": primary_payload.get("mana_cost") or False, "mtg_mana_value": primary_payload.get("cmc") if primary_payload.get("cmc") is not None else False, "mtg_color_ids": [Command.set(color_records.ids)], "mtg_color_identity_ids": [Command.set(color_identity_records.ids)], "mtg_card_type_ids": [Command.set(card_type_records.ids)], "mtg_keyword_ids": [Command.set(keyword_records.ids)], "mtg_game_platform_ids": [Command.set(platform_records.ids)], "mtg_finish_ids": [Command.set(finish_records.ids)], "mtg_power": primary_payload.get("power") or False, "mtg_toughness": primary_payload.get("toughness") or False, "mtg_loyalty": primary_payload.get("loyalty") or False, "mtg_artist": primary_payload.get("artist") or False, "mtg_is_token": bool( primary_payload.get("layout") == "token" or primary_payload.get("set_type") == "token" ), "mtg_is_reprint": bool(primary_payload.get("reprint")), "mtg_is_promo": bool(primary_payload.get("promo")), "mtg_is_digital": bool(primary_payload.get("digital")), "mtg_is_full_art": bool(primary_payload.get("full_art")), "mtg_is_textless": bool(primary_payload.get("textless")), } if import_run: values.update( { "mtg_scryfall_last_import_run_id": import_run.id, "mtg_scryfall_last_synced_at": fields.Datetime.now(), } ) return values @api.model def mtg_scryfall_upsert_group_from_payloads(self, payloads, import_run=None): """Create or update one MTG print group from localized payloads. Args: payloads: Raw localized Scryfall payloads for one print group. import_run: Optional import run record for traceability. Returns: mvd.tcg.card: Upserted MTG card reference record. """ grouped_payloads = [ payload for payload in (payloads or []) if payload.get("object") == "card" and payload.get("id") ] if not grouped_payloads: return self.browse() primary_payload = self._mtg_scryfall_select_primary_payload(grouped_payloads) external_ref = self._mtg_scryfall_build_external_ref(primary_payload) if not external_ref or external_ref == ":": return self.browse() mtg_game = self.env["mvd.tcg.game"]._mvd_tcg_get_mtg_game() card = self.search( [("game_id", "=", mtg_game.id), ("external_ref", "=", external_ref)], limit=1, ) values = self._mtg_scryfall_prepare_group_values( primary_payload, import_run=import_run, ) should_write_primary_values = bool( not card or (primary_payload.get("lang") or "").strip().lower() in {"", "en"} ) if should_write_primary_values: values.update( { "name": primary_payload.get("name") or primary_payload.get("printed_name") or external_ref.upper(), "mtg_type_line": primary_payload.get("type_line") or False, "mtg_oracle_text": primary_payload.get("oracle_text") or False, "mtg_flavor_text": primary_payload.get("flavor_text") or False, } ) localized_images = self._mtg_scryfall_collect_group_images(grouped_payloads) image_base64 = ( localized_images.get((primary_payload.get("lang") or "").strip().lower()) or localized_images.get("en") or self._mtg_scryfall_load_group_image_base64(grouped_payloads) ) if image_base64 and (not card or not card.image_1920): values["image_1920"] = image_base64 if card: card = card.with_context( mvd_tcg_bypass_validated_write=True, mvd_tcg_bypass_external_ref_write=True, ) card.write(values) else: if "name" not in values: values["name"] = ( primary_payload.get("name") or primary_payload.get("printed_name") or external_ref.upper() ) card = self.with_context( mvd_tcg_bypass_external_ref_write=True, ).create(values) for payload in grouped_payloads: card._mtg_scryfall_update_translations_from_payload(payload) card._mtg_scryfall_sync_faces_from_payloads(grouped_payloads) card._mtg_scryfall_sync_legalities(primary_payload) card._mtg_scryfall_sync_localized_images(localized_images) return card @api.model def mtg_scryfall_upsert_from_payload(self, payload): """Create or update one MTG card reference from one Scryfall payload. Args: payload: Raw Scryfall card payload from the public API. Returns: mvd.tcg.card: Upserted MTG card reference record. """ return self.mtg_scryfall_upsert_group_from_payloads([payload]) def action_open_mtg_scryfall_last_import_run(self): """Open the most recent Scryfall import run linked to this card. Returns: dict | bool: Window action or ``False`` when no run is linked. """ self.ensure_one() if not self.mtg_scryfall_last_import_run_id: return False return { "type": "ir.actions.act_window", "name": self.mtg_scryfall_last_import_run_id.display_name, "res_model": "mvd.tcg.mtg.scryfall.import.run", "view_mode": "form", "res_id": self.mtg_scryfall_last_import_run_id.id, "target": "current", } def action_mtg_scryfall_refresh_card(self): """Refresh the current MTG card from the Scryfall connector. Returns: dict: Window action for the created refresh run. """ self.ensure_one() self.env["mvd.tcg.mtg.scryfall.api"]._mtg_scryfall_check_manager_access() refresh_run = self.env["mvd.tcg.mtg.scryfall.import.run"].create_card_refresh_run( self ) return refresh_run.action_execute_import()