diff --git a/backend/app.py b/backend/app.py index 9473765..ed3fe66 100644 --- a/backend/app.py +++ b/backend/app.py @@ -27,6 +27,7 @@ RouteController, StudioController, UserCollectionImportController, + UserCollectionImportHelpController, UserController, ) from services import ( @@ -116,6 +117,7 @@ auth_guard, reset_job_coordinator=library_reset_job_coordinator, ) +user_collection_import_help_controller = UserCollectionImportHelpController(auth_guard) library_service_provider = LibraryServiceProvider() library_controller = LibraryController( auth_guard, @@ -159,6 +161,7 @@ collection_share_controller.register_routes(app) user_controller.register_routes(app) user_collection_import_controller.register_routes(app) +user_collection_import_help_controller.register_routes(app) collection_controller.register_routes(app) library_controller.register_routes(app) platform_controller.register_routes(app) diff --git a/backend/controllers/__init__.py b/backend/controllers/__init__.py index 1715a5f..4fe5d0c 100644 --- a/backend/controllers/__init__.py +++ b/backend/controllers/__init__.py @@ -21,6 +21,7 @@ from .route_controller import RouteController from .studio_controller import StudioController from .user_collection_import_controller import UserCollectionImportController +from .user_collection_import_help_controller import UserCollectionImportHelpController from .user_controller import UserController __all__ = [ @@ -35,4 +36,5 @@ "StudioController", "UserController", "UserCollectionImportController", + "UserCollectionImportHelpController", ] diff --git a/backend/controllers/user_collection_import_help_controller.py b/backend/controllers/user_collection_import_help_controller.py new file mode 100644 index 0000000..c143164 --- /dev/null +++ b/backend/controllers/user_collection_import_help_controller.py @@ -0,0 +1,75 @@ +# ____ _ _ ____ _ _ _ _ ___ +# / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ +# | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | +# | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | +# \____|_|\___/ \__,_|\__,_|\____\___|_|_|\___|\___|\__|_|\___/|_| |_|\___/| .__/| .__/ +# |_| |_| +# Projet : CloudCollectionApp +# Date de creation : 2026-08-23 +# Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien +# Licence : Apache 2.0 +# +# Description : controleur HTTP d'aide a la correction d'import. + +from flask import Flask, jsonify, request + +from services import AuthGuard, UserProfile +from services.collection.imports import CollectionImportInvalidValueHelpService + + +class UserCollectionImportHelpController: + """Enregistre les routes d'aide liees aux erreurs d'import utilisateur.""" + + def __init__( + self, + auth_guard: AuthGuard, + invalid_value_help_service_class=CollectionImportInvalidValueHelpService, + ): + """Initialise le controleur d'aide d'import. + + Args: + auth_guard (AuthGuard): Garde d'authentification et de profil. + invalid_value_help_service_class (type): Classe d'aide sur les valeurs refusees. + + Returns: + None: Le constructeur ne retourne aucune valeur. + """ + + self.auth_guard = auth_guard + self.invalid_value_help_service_class = invalid_value_help_service_class + + def register_routes(self, flask_app: Flask) -> None: + """Enregistre les routes d'aide de collection utilisateur dans Flask. + + Args: + flask_app (Flask): Application Flask cible. + + Returns: + None: La methode ne retourne aucune valeur. + """ + + flask_app.add_url_rule( + "/api/users/import/invalid-value-help", + endpoint="get_import_invalid_value_help", + view_func=self.auth_guard.require_profile(UserProfile.USER.value)( + self.get_import_invalid_value_help + ), + methods=["GET"], + ) + + def get_import_invalid_value_help(self): + """Retourne l'aide de correction d'une valeur d'import refusee. + + Args: + Aucun. + + Returns: + tuple[flask.Response, int] | flask.Response: Aide JSON ou erreur. + """ + + field = str(request.args.get("field") or "").strip() + value = str(request.args.get("value") or "").strip() + if not field: + return jsonify({"error": "Le parametre field est requis."}), 400 + help_result = self.invalid_value_help_service_class().get_help(field, value) + return jsonify(help_result.to_dict()), 200 diff --git a/backend/resources/collection_import_refusal_email_template.txt b/backend/resources/collection_import_refusal_email_template.txt index 0b9d82b..6c64ff2 100644 --- a/backend/resources/collection_import_refusal_email_template.txt +++ b/backend/resources/collection_import_refusal_email_template.txt @@ -19,6 +19,9 @@

Code raison: ${reason}

Jeux en erreur: ${invalid_games_count}/${total_games_count}

+

Compteurs d'erreur

+${error_counters} +

Resultat de l'import

+

Compteurs d'erreur

+${error_counters} + +

Plateformes à valider par l'admin

+${manual_platform_mappings} +

Studios importes

${imported_studio_match_reports} @@ -37,13 +43,8 @@ ${imported_game_match_reports}

Configuration d'import

-
+
 ${collection_file_description}
 
- -

Warnings

-
-${warnings}
-
diff --git a/backend/services/collection/imports/__init__.py b/backend/services/collection/imports/__init__.py index 8e408d2..cdac040 100644 --- a/backend/services/collection/imports/__init__.py +++ b/backend/services/collection/imports/__init__.py @@ -44,6 +44,10 @@ CollectionImportStudio, CollectionImportWarnings, ) +from .collection_import_invalid_value_help_service import ( + CollectionImportInvalidValueHelp, + CollectionImportInvalidValueHelpService, +) from .collection_import_refusal_policy import ( CollectionImportRefusal, CollectionImportRefusalPolicy, @@ -77,6 +81,8 @@ "CollectionImportFailureNotificationService", "CollectionImportField", "CollectionImportGame", + "CollectionImportInvalidValueHelp", + "CollectionImportInvalidValueHelpService", "CollectionImportPlatform", "CollectionImportRefusal", "CollectionImportRefusalAdminNotifier", diff --git a/backend/services/collection/imports/collection_import_invalid_value_help_service.py b/backend/services/collection/imports/collection_import_invalid_value_help_service.py new file mode 100644 index 0000000..df52e90 --- /dev/null +++ b/backend/services/collection/imports/collection_import_invalid_value_help_service.py @@ -0,0 +1,152 @@ +# ____ _ _ ____ _ _ _ _ ___ +# / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ +# | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | +# | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | +# \____|_|\___/ \__,_|\__,_|\____\___|_|_|\___|\___|\__|_|\___/|_| |_|\___/| .__/| .__/ +# |_| |_| +# Projet : CloudCollectionApp +# Date de creation : 2026-08-23 +# Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien +# Licence : Apache 2.0 +# +# Description : aide a la correction des valeurs d'import refusees. + +from dataclasses import dataclass + +from .collection_private_information_contract import ( + ALLOWED_REGIONS, + BOOLEAN_FALSE_LABELS, + BOOLEAN_TRUE_LABELS, + CONDITION_LABELS_BY_VALUE, + REGION_ALIASES_BY_VALUE, +) + + +@dataclass(frozen=True) +class CollectionImportInvalidValueHelp: + """Decrit pourquoi une valeur importee a ete refusee. + + Attributes: + field (str): Nom technique du champ refuse. + value (str): Valeur importee refusee. + reason (str): Explication lisible du refus. + possible_values (list[str]): Valeurs acceptees ou exemples utiles. + """ + + field: str + value: str + reason: str + possible_values: list[str] + + def to_dict(self) -> dict[str, object]: + """Convertit l'aide en dictionnaire serialisable. + + Args: + Aucun. + + Returns: + dict[str, object]: Aide exploitable par l'IHM. + """ + + return { + "field": self.field, + "value": self.value, + "reason": self.reason, + "possible_values": list(self.possible_values), + } + + +class CollectionImportInvalidValueHelpService: + """Construit les aides de correction pour les valeurs d'import refusees.""" + + FIELD_HELPS = { + "release_date": { + "reason": "La date ne respecte pas un format reconnu ou est trop ancienne.", + "possible_values": ["1994", "1994-11-24", "24/11/1994"], + }, + "buy_date": { + "reason": "La date d'achat ne respecte pas un format reconnu.", + "possible_values": ["2024-03-15", "15/03/2024"], + }, + "purchase_price": { + "reason": "Le prix doit etre un nombre positif avec au plus deux decimales utiles.", + "possible_values": ["12", "12,50", "12.50"], + }, + "grade": { + "reason": "La note doit etre numerique et compatible avec la base de notation choisie.", + "possible_values": ["8", "8/10", "82/100"], + }, + "condition": { + "reason": "La valeur ne correspond pas a un etat physique reconnu.", + "possible_values": [], + }, + "region": { + "reason": "La valeur ne correspond pas a une region ou version reconnue.", + "possible_values": [], + }, + "has_manual": { + "reason": "La valeur ne correspond pas a une reponse oui/non reconnue.", + "possible_values": [], + }, + "is_collector": { + "reason": "La valeur ne correspond pas a une reponse oui/non reconnue.", + "possible_values": [], + }, + "has_steelbook": { + "reason": "La valeur ne correspond pas a une reponse oui/non reconnue.", + "possible_values": [], + }, + "is_digital": { + "reason": "La valeur ne correspond pas a une reponse oui/non reconnue.", + "possible_values": [], + }, + } + + BOOLEAN_FIELDS = frozenset({"has_manual", "is_collector", "has_steelbook", "is_digital"}) + + def get_help(self, field: str, value: str) -> CollectionImportInvalidValueHelp: + """Retourne l'aide de correction d'une valeur refusee. + + Args: + field (str): Nom technique du champ refuse. + value (str): Valeur refusee telle qu'affichee dans le resume. + + Returns: + CollectionImportInvalidValueHelp: Aide de correction. + """ + + normalized_field = str(field or "").strip() + normalized_value = str(value or "").strip() + field_help = self.FIELD_HELPS.get(normalized_field) + if field_help is None: + return CollectionImportInvalidValueHelp( + normalized_field, + normalized_value, + "La valeur ne correspond pas au format attendu pour ce champ.", + [], + ) + return CollectionImportInvalidValueHelp( + normalized_field, + normalized_value, + str(field_help["reason"]), + self._possible_values(normalized_field, field_help), + ) + + def _possible_values(self, field: str, field_help: dict[str, object]) -> list[str]: + if field == "region": + aliases = [ + alias + for aliases_for_region in REGION_ALIASES_BY_VALUE.values() + for alias in aliases_for_region + ] + return sorted(ALLOWED_REGIONS) + sorted(aliases) + if field == "condition": + values = [ + label + for labels in CONDITION_LABELS_BY_VALUE.values() + for label in labels + ] + return sorted(values) + if field in self.BOOLEAN_FIELDS: + return sorted(BOOLEAN_TRUE_LABELS) + sorted(BOOLEAN_FALSE_LABELS) + return list(field_help.get("possible_values") or []) diff --git a/backend/services/collection/imports/collection_import_models.py b/backend/services/collection/imports/collection_import_models.py index 8a29235..f1fbef1 100644 --- a/backend/services/collection/imports/collection_import_models.py +++ b/backend/services/collection/imports/collection_import_models.py @@ -81,18 +81,24 @@ class CollectionImportWarnings: invalid_wishlist (int): Nombre de lignes ignorees pour valeur wishlist invalide. invalid_wishlist_values_found (list[str]): Valeurs wishlist invalides distinctes. invalid_games (list[dict]): Jeux importes avec une information invalide ignoree. + skipped_mandatory_games (int): Lignes ignorees pour nom ou plateforme manquante. platform_mappings (list[dict]): Synthese des plateformes lues et rattachees. platform_matches (list[dict]): Plateformes rattachees avec verification manuelle. skipped_games (list[dict]): Jeux ignores pendant l'import. + user_platform_matches (list[dict]): Messages simplifiés pour les plateformes a verifier. + user_skipped_games (list[dict]): Messages simplifiés pour les jeux ignores. total_import_duration_seconds (float): Duree totale de l'import en secondes. """ invalid_wishlist: int = 0 invalid_wishlist_values_found: Optional[list[str]] = None invalid_games: Optional[list[dict]] = None + skipped_mandatory_games: int = 0 platform_mappings: Optional[list[dict]] = None platform_matches: Optional[list[dict]] = None skipped_games: Optional[list[dict]] = None + user_platform_matches: Optional[list[dict]] = None + user_skipped_games: Optional[list[dict]] = None total_import_duration_seconds: float = 0.0 def __post_init__(self): @@ -115,6 +121,10 @@ def __post_init__(self): object.__setattr__(self, "platform_matches", []) if self.skipped_games is None: object.__setattr__(self, "skipped_games", []) + if self.user_platform_matches is None: + object.__setattr__(self, "user_platform_matches", []) + if self.user_skipped_games is None: + object.__setattr__(self, "user_skipped_games", []) def to_dict(self) -> dict[str, object]: """Convertit les warnings en dictionnaire serialisable. @@ -130,9 +140,12 @@ def to_dict(self) -> dict[str, object]: "invalid_wishlist": self.invalid_wishlist, "invalid_wishlist_values_found": list(self.invalid_wishlist_values_found), "invalid_games": list(self.invalid_games), + "skipped_mandatory_games": self.skipped_mandatory_games, "platform_mappings": list(self.platform_mappings), "platform_matches": list(self.platform_matches), "skipped_games": list(self.skipped_games), + "user_platform_matches": list(self.user_platform_matches), + "user_skipped_games": list(self.user_skipped_games), "total_import_duration_seconds": self.total_import_duration_seconds, } diff --git a/backend/services/collection/imports/collection_import_refusal_admin_notifier.py b/backend/services/collection/imports/collection_import_refusal_admin_notifier.py index e344567..b0bada2 100644 --- a/backend/services/collection/imports/collection_import_refusal_admin_notifier.py +++ b/backend/services/collection/imports/collection_import_refusal_admin_notifier.py @@ -112,31 +112,77 @@ def _build_email_body(self, context: CollectionImportRefusalContext) -> str: "message": escape(str(context.refusal.get("message") or "")), "invalid_games_count": int(context.refusal.get("invalid_games_count") or 0), "total_games_count": int(context.refusal.get("total_games_count") or 0), + "error_counters": self._error_counters_html(context), "linked_platforms": 0, "created_studios": 0, "created_games": 0, "associated_games": 0, "wishlisted_games": 0, - "warnings": escape(self._warnings_text(warnings)), "invalid_games": self._invalid_games_html(warnings.invalid_games), + "manual_platform_mappings": self._manual_platform_mappings_html( + warnings.platform_matches + ), }, ) def _requester_user_id(self, requester_user_id: int | None) -> str: return escape(str(requester_user_id)) if requester_user_id is not None else "inconnu" - def _warnings_text(self, warnings: object) -> str: - lines = [ - "Warnings:", - f"- Wishlist invalide: {int(getattr(warnings, 'invalid_wishlist', 0) or 0)}", - f"- Jeux invalides: {len(getattr(warnings, 'invalid_games', []) or [])}", - f"- Jeux ignores: {len(getattr(warnings, 'skipped_games', []) or [])}", - f"- Plateformes a verifier: {len(getattr(warnings, 'platform_matches', []) or [])}", + def _error_counters_html(self, context: CollectionImportRefusalContext) -> str: + warnings = context.import_data.warnings + counters = [ + ( + "Jeux avec erreur bloquante", + int(context.refusal.get("invalid_games_count") or 0), + "Total utilise pour refuser le fichier.", + ), + ( + "Jeux lus dans le fichier", + int(context.refusal.get("total_games_count") or 0), + "Base de calcul du seuil de refus.", + ), + ( + "Jeux avec information invalide", + len(getattr(warnings, "invalid_games", []) or []), + "Au moins une valeur refusee dans un champ importe.", + ), + ( + "Jeux refuses ou ignores", + len(getattr(warnings, "skipped_games", []) or []), + "Jeux non importes, par exemple plateforme non reconnue.", + ), + ( + "Lignes sans nom ou plateforme obligatoire", + int(getattr(warnings, "skipped_mandatory_games", 0) or 0), + "Lignes non importables car une information obligatoire manque.", + ), + ( + "Jeux avec plateforme a valider", + len(getattr(warnings, "platform_matches", []) or []), + "Non bloquant: validation admin attendue.", + ), + ( + "Lignes wishlist ignorees", + int(getattr(warnings, "invalid_wishlist", 0) or 0), + "Valeur wishlist invalide ou inexploitable.", + ), ] - invalid_values = list(getattr(warnings, "invalid_wishlist_values_found", []) or []) - if invalid_values: - lines.append("- Valeurs wishlist invalides: " + ", ".join(map(str, invalid_values))) - return "\n".join(lines) + rows = [] + for label, count, description in counters: + rows.append( + "" + f"{escape(label)}" + f"{count}" + f"{escape(description)}" + "" + ) + return ( + '' + "" + "" + + "".join(rows) + + "
CompteurValeurExplication
" + ) def _invalid_games_html(self, invalid_games: list[dict]) -> str: if not invalid_games: @@ -156,6 +202,45 @@ def _invalid_games_html(self, invalid_games: list[dict]) -> str: + "" ) + def _manual_platform_mappings_html(self, manual_matches: list[dict]) -> str: + if not manual_matches: + return "

Aucune plateforme en attente de validation admin.

" + rows = [] + for mapping in self._manual_platform_mappings(manual_matches): + rows.append( + "" + f"{escape(mapping['imported_platform'])}" + f"{escape(mapping['matched_platform'])}" + f"{mapping['games_count']}" + f"{escape(', '.join(mapping['game_names']))}" + "En attente de validation" + "" + ) + return ( + '' + "" + "" + + "".join(rows) + + "
Valeur dans le fichierPlateforme proposéeJeuxListe des jeuxStatut
" + ) + + def _manual_platform_mappings(self, manual_matches: list[dict]) -> list[dict]: + mappings_by_key = {} + for match in manual_matches: + imported_platform = str(match.get("imported_platform") or "").strip() + matched_platform = str(match.get("matched_platform") or "").strip() + key = (imported_platform, matched_platform) + mapping = mappings_by_key.get(key) or { + "imported_platform": imported_platform or "-", + "matched_platform": matched_platform or "-", + "games_count": 0, + "game_names": [], + } + mapping["games_count"] += 1 + mapping["game_names"].append(str(match.get("game_name") or "-")) + mappings_by_key[key] = mapping + return list(mappings_by_key.values()) + def _invalid_fields_text(self, invalid_fields: list[dict]) -> str: values = [] for invalid_field in invalid_fields: diff --git a/backend/services/collection/imports/collection_import_refusal_policy.py b/backend/services/collection/imports/collection_import_refusal_policy.py index 3b1b2e2..431369d 100644 --- a/backend/services/collection/imports/collection_import_refusal_policy.py +++ b/backend/services/collection/imports/collection_import_refusal_policy.py @@ -68,8 +68,8 @@ def evaluate(self, import_data: CollectionImportData) -> CollectionImportRefusal CollectionImportRefusal: Decision de refus global. """ - total_games_count = len(import_data.games) - invalid_games_count = len(import_data.warnings.invalid_games) + invalid_games_count = self._invalid_games_count(import_data) + total_games_count = self._total_games_count(import_data) if total_games_count > 0 and invalid_games_count * 2 > total_games_count: return CollectionImportRefusal( refused=True, @@ -86,3 +86,17 @@ def evaluate(self, import_data: CollectionImportData) -> CollectionImportRefusal invalid_games_count=invalid_games_count, total_games_count=total_games_count, ) + + def _invalid_games_count(self, import_data: CollectionImportData) -> int: + return ( + len(import_data.warnings.invalid_games) + + len(import_data.warnings.skipped_games) + + int(import_data.warnings.skipped_mandatory_games or 0) + ) + + def _total_games_count(self, import_data: CollectionImportData) -> int: + return ( + len(import_data.games) + + len(import_data.warnings.skipped_games) + + int(import_data.warnings.skipped_mandatory_games or 0) + ) diff --git a/backend/services/csv/csv_collection_import_reader.py b/backend/services/csv/csv_collection_import_reader.py index 6c141c2..e80ce46 100644 --- a/backend/services/csv/csv_collection_import_reader.py +++ b/backend/services/csv/csv_collection_import_reader.py @@ -203,7 +203,12 @@ def _build_games( ) -> tuple[list[CollectionImportGame], CollectionImportWarnings]: games: list[CollectionImportGame] = [] game_indexes_by_key: dict[tuple[str, str, str], int] = {} - warnings = {"invalid_wishlist": 0, "invalid_values": [], "invalid_games": []} + warnings = { + "invalid_wishlist": 0, + "invalid_values": [], + "invalid_games": [], + "skipped_mandatory_games": 0, + } for row_index, row in enumerate(rows, start=2): game = self._build_game(row, row_index, description, warnings) if game is not None: @@ -212,6 +217,7 @@ def _build_games( invalid_wishlist=warnings["invalid_wishlist"], invalid_wishlist_values_found=warnings["invalid_values"], invalid_games=warnings["invalid_games"], + skipped_mandatory_games=warnings["skipped_mandatory_games"], ) def _build_game( @@ -226,11 +232,13 @@ def _build_game( self._field_value(row, column_information, CollectionImportField.NAME) ) if not game_name or self.value_mapper.comparison_key(game_name) is None: + warnings["skipped_mandatory_games"] += 1 return None platform_name = self.value_mapper.map_name( self._field_value(row, column_information, CollectionImportField.PLATFORM) ) if platform_name is None: + warnings["skipped_mandatory_games"] += 1 return None wishlist = self.value_mapper.map_wishlist( self._field_value(row, column_information, CollectionImportField.WISHLIST), diff --git a/backend/services/database/__init__.py b/backend/services/database/__init__.py index 4dfdfee..9090e14 100644 --- a/backend/services/database/__init__.py +++ b/backend/services/database/__init__.py @@ -38,7 +38,6 @@ from .platform_catalog_entry import PlatformCatalogEntry from .platform_catalog_seed_service import PlatformCatalogSeedResult, PlatformCatalogSeedService from .platform_catalog_update_service import PlatformCatalogUpdateService -from .platform_matching_admin_notifier import PlatformMatchingAdminNotifier from .platform_matching_configuration import PlatformMatchingConfiguration from .platform_matching_service import PlatformMatchingService from .platform_image import PlatformImage @@ -99,7 +98,6 @@ "PlatformCatalogSeedResult", "PlatformCatalogSeedService", "PlatformCatalogUpdateService", - "PlatformMatchingAdminNotifier", "PlatformMatchingConfiguration", "PlatformMatchingService", "SqlAlchemyPlatformRepository", diff --git a/backend/services/database/platform_matching_admin_notifier.py b/backend/services/database/platform_matching_admin_notifier.py deleted file mode 100644 index 89b45fa..0000000 --- a/backend/services/database/platform_matching_admin_notifier.py +++ /dev/null @@ -1,202 +0,0 @@ -# ____ _ _ ____ _ _ _ _ ___ -# / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ -# | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | -# | |___| | (_) | |_| | (_| | |__| (_) | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | -# \____|_|\___/ \__,_|\__,_|\____\___|_|_|\___|\___|\__|_|\___/|_| |_|\___/| .__/| .__/ -# |_| |_| -# Projet : CloudCollectionApp -# Date de creation : 2026-06-14 -# Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien -# Licence : Apache 2.0 -# -# Description : notification administrateur des matchings plateformes. - -import os - -from services.email import EmailConfiguration, EmailSenderFactory - - -class PlatformMatchingAdminNotifier: - """Notifie l'administrateur du rapport de matching des plateformes.""" - - def __init__(self, email_sender=None, admin_notification_email: str | None = None): - """Initialise la notification de matching plateformes. - - Args: - email_sender (object | None): Expediteur email injectable. - admin_notification_email (str): Adresse administrateur destinataire. - - Returns: - None: Le constructeur ne retourne aucune valeur. - """ - - self.email_sender = email_sender - if admin_notification_email is None: - admin_notification_email = os.getenv("ADMIN_NOTIFICATION_EMAIL", "") - self.admin_notification_email = str(admin_notification_email or "").strip() - - @classmethod - def from_environment(cls) -> "PlatformMatchingAdminNotifier": - """Construit le notifier depuis l'environnement. - - Args: - Aucun. - - Returns: - PlatformMatchingAdminNotifier: Notifier configure. - - Raises: - ValueError: Si la configuration email est invalide. - """ - - return cls(admin_notification_email=os.getenv("ADMIN_NOTIFICATION_EMAIL", "")) - - def notify_import_report(self, warnings: object) -> None: - """Envoie le rapport admin des mappings et warnings de plateformes. - - Args: - warnings (object): Warnings d'import contenant les mappings plateformes. - - Returns: - None: La methode ne retourne aucune valeur. - """ - - if not self.admin_notification_email: - return - platform_mappings = list(getattr(warnings, "platform_mappings", []) or []) - manual_matches = list(getattr(warnings, "platform_matches", []) or []) - skipped_games = list(getattr(warnings, "skipped_games", []) or []) - invalid_games = list(getattr(warnings, "invalid_games", []) or []) - invalid_wishlist = int(getattr(warnings, "invalid_wishlist", 0) or 0) - total_import_duration_seconds = float( - getattr(warnings, "total_import_duration_seconds", 0.0) or 0.0 - ) - invalid_wishlist_values = list( - getattr(warnings, "invalid_wishlist_values_found", []) or [] - ) - if not any( - [ - platform_mappings, - manual_matches, - skipped_games, - invalid_games, - invalid_wishlist, - ] - ): - return - - lines = [ - "Rapport de mapping des plateformes importees.", - "Duree totale de l'import: {duration:.3f} seconde(s).".format( - duration=total_import_duration_seconds, - ), - "", - ] - self._append_platform_mappings(lines, platform_mappings) - self._append_manual_matches(lines, manual_matches) - self._append_skipped_games(lines, skipped_games) - self._append_invalid_games(lines, invalid_games) - self._append_invalid_wishlist(lines, invalid_wishlist, invalid_wishlist_values) - email_sender = self.email_sender or EmailSenderFactory.create( - EmailConfiguration.from_environment() - ) - email_sender.send_email( - recipient_email=self.admin_notification_email, - subject="Rapport de mapping des plateformes importees", - body="\n".join(lines), - ) - - def notify_manual_matches(self, manual_matches: list[dict]) -> None: - """Envoie un email de compatibilite pour les matchings faibles. - - Args: - manual_matches (list[dict]): Warnings de plateformes a verifier. - - Returns: - None: La methode ne retourne aucune valeur. - """ - - warnings = type( - "PlatformMatchingWarnings", - (), - { - "platform_mappings": [], - "platform_matches": manual_matches, - "skipped_games": [], - "invalid_games": [], - "invalid_wishlist": 0, - "invalid_wishlist_values_found": [], - }, - )() - self.notify_import_report(warnings) - - def _append_platform_mappings( - self, - lines: list[str], - platform_mappings: list[dict], - ) -> None: - if not platform_mappings: - return - lines.append("Mappings plateformes:") - for mapping in platform_mappings: - alias_text = "oui" if mapping.get("matched_by_alias") else "non" - matched_alias = str(mapping.get("matched_alias") or "") - if matched_alias: - alias_text = f"{alias_text} ({matched_alias})" - lines.append( - "- Plateforme lue: {imported_platform} | Plateforme rattachee: " - "{matched_platform} | Score: {score} | Jeux: {games_count} | " - "Alias: {alias_text}".format( - alias_text=alias_text, - **mapping, - ) - ) - lines.append("") - - def _append_manual_matches(self, lines: list[str], manual_matches: list[dict]) -> None: - if not manual_matches: - return - lines.append("Warnings de verification manuelle:") - for match in manual_matches: - lines.append( - "- Jeu: {game_name} | Plateforme importee: {imported_platform} | " - "Plateforme rattachee: {matched_platform} | Score: {score}".format( - **match - ) - ) - lines.append("") - - def _append_skipped_games(self, lines: list[str], skipped_games: list[dict]) -> None: - if not skipped_games: - return - lines.append("Jeux ignores:") - for skipped_game in skipped_games: - lines.append( - "- Jeu: {game_name} | Plateforme importee: {imported_platform} | " - "Score: {score} | Raison: {reason}".format(**skipped_game) - ) - lines.append("") - - def _append_invalid_games(self, lines: list[str], invalid_games: list[dict]) -> None: - if not invalid_games: - return - lines.append("Jeux importes avec informations invalides ignorees:") - for invalid_game in invalid_games: - lines.append("- Jeu: {name}".format(**invalid_game)) - lines.append("") - - def _append_invalid_wishlist( - self, - lines: list[str], - invalid_wishlist: int, - invalid_wishlist_values: list[str], - ) -> None: - if invalid_wishlist <= 0: - return - lines.append( - "Wishlist invalide: {count} ligne(s) ignoree(s).".format( - count=invalid_wishlist, - ) - ) - if invalid_wishlist_values: - lines.append("Valeurs detectees: " + ", ".join(invalid_wishlist_values)) diff --git a/backend/services/database/platform_matching_service.py b/backend/services/database/platform_matching_service.py index 4841b00..b85e7ad 100644 --- a/backend/services/database/platform_matching_service.py +++ b/backend/services/database/platform_matching_service.py @@ -73,10 +73,12 @@ def match_import_data( match = matches_by_key.get(platform_key) if match is None or not match["accepted"]: warnings.skipped_games.append(self._skipped_game_warning(game, match)) + warnings.user_skipped_games.append(self._user_platform_warning(game)) continue matched_games.append(self._matched_game(game, str(match["matched_name"]))) if match["manual_check"]: warnings.platform_matches.append(self._platform_match_warning(game, match)) + warnings.user_platform_matches.append(self._user_platform_match_warning(game, match)) matched_platform_names = self._matched_platform_names(matched_games) return CollectionImportData( platforms=[CollectionImportPlatform(name) for name in matched_platform_names], @@ -226,9 +228,13 @@ def _copy_warnings(self, warnings: CollectionImportWarnings) -> CollectionImport invalid_wishlist=warnings.invalid_wishlist, invalid_wishlist_values_found=list(warnings.invalid_wishlist_values_found), invalid_games=list(warnings.invalid_games), + skipped_mandatory_games=warnings.skipped_mandatory_games, platform_mappings=list(warnings.platform_mappings), platform_matches=list(warnings.platform_matches), skipped_games=list(warnings.skipped_games), + user_platform_matches=list(warnings.user_platform_matches), + user_skipped_games=list(warnings.user_skipped_games), + total_import_duration_seconds=warnings.total_import_duration_seconds, ) def _matched_game(self, game: CollectionImportGame, platform_name: str) -> CollectionImportGame: @@ -277,7 +283,25 @@ def _skipped_game_warning( "game_name": game.name, "imported_platform": game.platform_name, "score": 0 if match is None else match["score"], - "reason": "no_match" if match is None else match["reason"], + "reason": self._platform_rejection_reason(match), + } + + def _user_platform_warning(self, game: CollectionImportGame) -> dict[str, object]: + return { + "game_name": game.name, + "imported_platform": game.platform_name, + "message": "Ne correspond a aucune plateforme existante", + } + + def _user_platform_match_warning( + self, + game: CollectionImportGame, + match: dict[str, object], + ) -> dict[str, object]: + return { + "game_name": game.name, + "imported_platform": game.platform_name, + "message": f"Correspondance a verifier avec {match['matched_name']}", } def _platform_mapping_warnings( @@ -304,11 +328,25 @@ def _platform_mapping_warnings( "matched_alias": "" if match is None else match["matched_alias"], "accepted": False if match is None else match["accepted"], "manual_check": False if match is None else match["manual_check"], - "reason": "no_match" if match is None else match["reason"], + "reason": "" if match is not None and match["accepted"] + else self._platform_rejection_reason(match), } ) return platform_mappings + def _platform_rejection_reason(self, match: dict[str, object] | None) -> str: + if match is None or str(match.get("reason") or "") == "no_match": + return "Plateforme invalide (aucune plateforme proche détectée)." + matched_name = str(match.get("matched_name") or "").strip() + if str(match.get("reason") or "") == "ambiguous": + return "Plateforme invalide (plusieurs plateformes proches détectées)." + if matched_name: + return ( + "Plateforme invalide (plateforme la plus proche détectée : " + f"\"{matched_name}\")." + ) + return "Plateforme invalide." + def _game_counts_by_imported_platform( self, games: list[CollectionImportGame], diff --git a/backend/services/database/user_collection_import_repository.py b/backend/services/database/user_collection_import_repository.py index 0befc70..56ef047 100644 --- a/backend/services/database/user_collection_import_repository.py +++ b/backend/services/database/user_collection_import_repository.py @@ -148,14 +148,10 @@ def find_import_configuration(self, user_id: int) -> dict | None: with self.engine.connect() as connection: return self.user_file_repository.find_collection_file_description(connection, user_id) - def import_collection( - self, - user_id: int, - collection_file_path: str, - import_data: CollectionImportData, - collection_file_description: dict, - initial_game_validation_status: str = GAME_STATUS_WAITING_VALIDATION, - ) -> UserCollectionImportPersistenceResult: + def import_collection(self, user_id: int, collection_file_path: str, + import_data: CollectionImportData, collection_file_description: dict, + initial_game_validation_status: str = GAME_STATUS_WAITING_VALIDATION + ) -> UserCollectionImportPersistenceResult: """Importe les donnees de collection dans une transaction SQL. Args: @@ -237,6 +233,16 @@ def import_collection( ), ) + def prepare_import_data_for_policy(self, import_data: CollectionImportData) -> CollectionImportData: + """Prepare les donnees importees avant la politique de refus. + + Args: import_data (CollectionImportData): Donnees lues depuis le fichier. + Returns: CollectionImportData: Donnees avec plateformes rattachees ou refusees. + """ + + with self.engine.connect() as connection: + return self._match_platforms(connection, import_data) + def _lock_global_game_import_state(self, connection: Connection) -> None: """Serialise le matching et la creation des jeux globaux pendant l'import. @@ -252,11 +258,10 @@ def _lock_global_game_import_state(self, connection: Connection) -> None: {"lock_key": self.GLOBAL_GAME_IMPORT_LOCK_KEY}, ) - def _match_platforms( - self, - connection: Connection, - import_data: CollectionImportData, - ) -> CollectionImportData: + def _match_platforms(self, connection: Connection, + import_data: CollectionImportData) -> CollectionImportData: + if import_data.warnings.platform_mappings: + return import_data platform_rows = self.platform_repository.load_catalog_rows(connection) matched_import_data = self.platform_matching_service.match_import_data( import_data, @@ -301,11 +306,8 @@ def reinitialize_collection(self, user_id: int) -> None: self.user_file_repository.clear_collection_file(connection, user_id) self.collection_file_remover.delete_collection_file(collection_file_path) - def _ensure_platforms( - self, - connection: Connection, - import_data: CollectionImportData, - ) -> tuple[dict[str, int], int]: + def _ensure_platforms(self, connection: Connection, + import_data: CollectionImportData) -> tuple[dict[str, int], int]: """Retourne les plateformes du referentiel liees a l'import. Args: @@ -324,11 +326,8 @@ def _ensure_platforms( } return platform_ids, len(linked_keys.intersection(platform_ids)) - def _ensure_studios( - self, - connection: Connection, - import_data: CollectionImportData, - ) -> tuple[dict[str, int], int, list[ImportedStudioMatchReport]]: + def _ensure_studios(self, connection: Connection, import_data: CollectionImportData + ) -> tuple[dict[str, int], int, list[ImportedStudioMatchReport]]: """Cree les studios absents et retourne leurs identifiants. Args: diff --git a/backend/services/excel/excel_collection_import_reader.py b/backend/services/excel/excel_collection_import_reader.py index 56e748d..7956f84 100644 --- a/backend/services/excel/excel_collection_import_reader.py +++ b/backend/services/excel/excel_collection_import_reader.py @@ -232,6 +232,7 @@ def _read_configured_games( invalid_wishlist=warnings["invalid_wishlist"], invalid_wishlist_values_found=warnings["invalid_values"], invalid_games=warnings["invalid_games"], + skipped_mandatory_games=int(warnings.get("skipped_mandatory_games") or 0), ) def _read_multiple_sheets_games( diff --git a/backend/services/library/admin_library_import_service.py b/backend/services/library/admin_library_import_service.py index 3676fca..3f36566 100644 --- a/backend/services/library/admin_library_import_service.py +++ b/backend/services/library/admin_library_import_service.py @@ -246,6 +246,7 @@ def import_csv_file( except Exception as exc: self._notify_import_failure(exc, csv_file_path, original_filename, requester_email) raise + refusal = self.refusal_policy.evaluate(import_data) return AdminLibraryImportResult( linked_platforms=persistence_result.linked_platforms, created_studios=persistence_result.created_studios, @@ -254,8 +255,8 @@ def import_csv_file( refusal={ "refused": False, "reason": "", - "invalid_games_count": len(import_data.warnings.invalid_games), - "total_games_count": len(import_data.games), + "invalid_games_count": refusal.invalid_games_count, + "total_games_count": refusal.total_games_count, "message": "", }, ) diff --git a/backend/services/ods/ods_collection_import_game_builder.py b/backend/services/ods/ods_collection_import_game_builder.py index e0277ab..a843088 100644 --- a/backend/services/ods/ods_collection_import_game_builder.py +++ b/backend/services/ods/ods_collection_import_game_builder.py @@ -202,6 +202,9 @@ def _build_game( ) game_key = self.value_mapper.comparison_key(game_name) if not game_name or game_key is None: + warnings["skipped_mandatory_games"] = ( + int(warnings.get("skipped_mandatory_games") or 0) + 1 + ) return None platform_name = self._normalized_field_value( row, @@ -211,6 +214,9 @@ def _build_game( sheet_name, ) if platform_name is None: + warnings["skipped_mandatory_games"] = ( + int(warnings.get("skipped_mandatory_games") or 0) + 1 + ) return None wishlist = self.value_mapper.map_wishlist( self._field_value(row, column_positions, CollectionImportField.WISHLIST), diff --git a/backend/services/ods/ods_collection_import_reader.py b/backend/services/ods/ods_collection_import_reader.py index f4d1478..c3ce50d 100644 --- a/backend/services/ods/ods_collection_import_reader.py +++ b/backend/services/ods/ods_collection_import_reader.py @@ -231,6 +231,7 @@ def _read_configured_games( invalid_wishlist=warnings["invalid_wishlist"], invalid_wishlist_values_found=warnings["invalid_values"], invalid_games=warnings["invalid_games"], + skipped_mandatory_games=int(warnings.get("skipped_mandatory_games") or 0), ) def _read_multiple_sheets_games( diff --git a/backend/services/users/user_collection_import_admin_notifier.py b/backend/services/users/user_collection_import_admin_notifier.py index 5fde8f8..32ff374 100644 --- a/backend/services/users/user_collection_import_admin_notifier.py +++ b/backend/services/users/user_collection_import_admin_notifier.py @@ -13,6 +13,7 @@ import json import os +import re from html import escape from pathlib import Path @@ -114,8 +115,6 @@ def is_enabled(self) -> bool: return bool(self.admin_notification_email) def _build_email_body(self, context: UserCollectionImportReportContext) -> str: - warnings_lines = [] - self._append_warnings(warnings_lines, context.warnings) return self.template_renderer.render( self.template_path, { @@ -130,6 +129,10 @@ def _build_email_body(self, context: UserCollectionImportReportContext) -> str: "created_games": context.created_games, "associated_games": context.associated_games, "wishlisted_games": context.wishlisted_games, + "error_counters": self._error_counters_html(context), + "manual_platform_mappings": self._manual_platform_mappings_html( + list(getattr(context.warnings, "platform_matches", []) or []), + ), "imported_game_match_reports": self._imported_game_match_reports_html( context.imported_game_match_reports, ), @@ -151,43 +154,189 @@ def _build_email_body(self, context: UserCollectionImportReportContext) -> str: "database_query_duration_seconds": self._format_duration( context.database_query_duration_seconds ), - "collection_file_description": escape( - json.dumps( - context.collection_file_description, - ensure_ascii=False, - sort_keys=True, - ) + "collection_file_description": self._collection_file_description_html( + context.collection_file_description ), - "warnings": escape("\n".join(warnings_lines)), }, ) def _format_duration(self, duration_seconds: object) -> str: return "{duration:.3f}".format(duration=float(duration_seconds or 0.0)) + def _collection_file_description_html(self, collection_file_description: dict) -> str: + json_text = json.dumps( + collection_file_description, + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + token_pattern = re.compile( + r'("(?:\\.|[^"\\])*")(\s*:)?' + r"|\b(true|false|null)\b" + r"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?" + ) + html_parts = [] + last_index = 0 + for match in token_pattern.finditer(json_text): + html_parts.append(escape(json_text[last_index : match.start()])) + token = match.group(0) + if match.group(1) and match.group(2): + html_parts.append(self._json_span(token, "#0f5e9c", "600")) + elif match.group(1): + html_parts.append(self._json_span(token, "#277a3f", "400")) + elif match.group(3): + html_parts.append(self._json_span(token, "#8a4f00", "600")) + else: + html_parts.append(self._json_span(token, "#7c3aed", "600")) + last_index = match.end() + html_parts.append(escape(json_text[last_index:])) + return "".join(html_parts) + + def _json_span(self, token: str, color: str, font_weight: str) -> str: + return ( + f'' + f"{escape(token)}" + ) + + def _table_html(self, headers: list[str], rows: list[str]) -> str: + return ( + '' + + self._table_header_html(headers) + + "" + + "".join(rows) + + "
" + ) + + def _table_header_html(self, headers: list[str]) -> str: + cells = [] + for header in headers: + cells.append( + '' + f"{escape(header)}" + ) + return "" + "".join(cells) + "" + + def _table_row_html(self, cells: list[str], background_color: str = "") -> str: + style = f' style="background:{background_color};"' if background_color else "" + return f"" + "".join(cells) + "" + + def _table_cell_html(self, html_value: str, extra_style: str = "") -> str: + style = "border:1px solid #cbd5e1;vertical-align:top;" + if extra_style: + style += extra_style + return f'{html_value}' + + def _created_status_cell_html(self, created: bool) -> str: + if created: + return self._table_cell_html( + 'Oui', + "background:#dcfce7;", + ) + return self._table_cell_html('Non') + + def _error_counters_html(self, context: UserCollectionImportReportContext) -> str: + warnings = context.warnings + skipped_games_count = len(getattr(warnings, "skipped_games", []) or []) + skipped_mandatory_games_count = int( + getattr(warnings, "skipped_mandatory_games", 0) or 0 + ) + invalid_games_count = len(getattr(warnings, "invalid_games", []) or []) + blocking_errors_count = ( + invalid_games_count + skipped_games_count + skipped_mandatory_games_count + ) + total_games_count = ( + int(context.associated_games or 0) + + skipped_games_count + + skipped_mandatory_games_count + ) + counters = [ + ( + "Jeux avec erreur bloquante", + blocking_errors_count, + "Total qui aurait ete utilise pour refuser le fichier.", + ), + ( + "Jeux lus dans le fichier", + total_games_count, + "Base de calcul du seuil de refus.", + ), + ( + "Jeux avec information invalide", + invalid_games_count, + "Au moins une valeur refusee dans un champ importe.", + ), + ( + "Jeux refuses ou ignores", + skipped_games_count, + "Jeux non importes, par exemple plateforme non reconnue.", + ), + ( + "Lignes sans nom ou plateforme obligatoire", + skipped_mandatory_games_count, + "Lignes non importables car une information obligatoire manque.", + ), + ( + "Jeux avec plateforme a valider", + len(getattr(warnings, "platform_matches", []) or []), + "Non bloquant: validation admin attendue.", + ), + ( + "Lignes wishlist ignorees", + int(getattr(warnings, "invalid_wishlist", 0) or 0), + "Valeur wishlist invalide ou inexploitable.", + ), + ] + rows = [] + for label, count, description in counters: + background_color = self._error_counter_background(label, int(count)) + rows.append( + self._table_row_html( + [ + self._table_cell_html(escape(label)), + self._table_cell_html(str(count), "font-weight:600;"), + self._table_cell_html(escape(description)), + ], + background_color, + ) + ) + return self._table_html(["Compteur", "Valeur", "Explication"], rows) + + def _error_counter_background(self, label: str, count: int) -> str: + if count <= 0: + return "" + if "plateforme a valider" in label: + return "#fff7ed" + if "erreur" in label or "refuses" in label or "obligatoire" in label: + return "#fef2f2" + return "#f8fafc" + def _imported_studio_match_reports_html(self, imported_studio_match_reports: tuple) -> str: if not imported_studio_match_reports: return "

Aucun studio importe.

" rows = [] for report in imported_studio_match_reports: + created = bool(getattr(report, "created", False)) rows.append( - "" - f"{self._html_value(getattr(report, 'imported_studio_name', ''))}" - f"{'Oui' if getattr(report, 'created', False) else 'Non'}" - f"{self._html_value(getattr(report, 'associated_studio_name', ''))}" - f"{int(getattr(report, 'score', 0) or 0)}" - "" + self._table_row_html( + [ + self._table_cell_html( + self._html_value(getattr(report, "imported_studio_name", "")) + ), + self._created_status_cell_html(created), + self._table_cell_html( + self._html_value(getattr(report, "associated_studio_name", "")) + ), + self._table_cell_html(str(int(getattr(report, "score", 0) or 0))), + ], + "#ecfdf3" if created else "", + ) ) - return ( - '' - "" - "" - "" - "" - "" - "" - + "".join(rows) - + "
Nom du studio importéCrééNom du Studio associéScore de matching
" + return self._table_html( + ["Nom du studio importé", "Créé", "Nom du Studio associé", "Score de matching"], + rows, ) def _imported_game_match_reports_html(self, imported_game_match_reports: tuple) -> str: @@ -195,119 +344,105 @@ def _imported_game_match_reports_html(self, imported_game_match_reports: tuple) return "

Aucun jeu importe.

" rows = [] for report in imported_game_match_reports: + created = bool(getattr(report, "created", False)) + decision = str(getattr(report, "decision", "") or "") + rejected_decision = self._is_rejected_game_decision(decision) rows.append( - "" - f"{self._html_value(getattr(report, 'imported_game_name', ''))}" - f"{'Oui' if getattr(report, 'created', False) else 'Non'}" - f"{self._html_value(getattr(report, 'associated_game_name', ''))}" - f"{int(getattr(report, 'score', 0) or 0)}" - f"{self._html_value(getattr(report, 'decision', ''))}" - f"{self._html_value(getattr(report, 'rule', ''))}" - f"{self._html_value(getattr(report, 'reason', ''))}" - "" + self._table_row_html( + [ + self._table_cell_html( + self._html_value(getattr(report, "imported_game_name", "")) + ), + self._created_status_cell_html(created), + self._table_cell_html( + self._html_value(getattr(report, "associated_game_name", "")) + ), + self._table_cell_html(str(int(getattr(report, "score", 0) or 0))), + self._game_decision_cell_html(decision, rejected_decision), + self._table_cell_html(self._html_value(getattr(report, "rule", ""))), + self._table_cell_html(self._html_value(getattr(report, "reason", ""))), + ], + self._imported_game_row_background(created, rejected_decision), + ) ) - return ( - '' - "" - "" - "" - "" - "" - "" - "" - "" - "" - + "".join(rows) - + "
NomCrééJeu associéScoreDecisionRuleRaison
" + return self._table_html( + ["Nom", "Créé", "Jeu associé", "Score", "Decision", "Rule", "Raison"], + rows, + ) + + def _imported_game_row_background(self, created: bool, rejected_decision: bool) -> str: + if rejected_decision: + return "#fef2f2" + return "#ecfdf3" if created else "" + + def _is_rejected_game_decision(self, decision: str) -> bool: + normalized_decision = ( + decision.lower() + .replace("é", "e") + .replace("è", "e") + .replace("ê", "e") + .replace("à", "a") + ) + rejected_markers = ( + "refus", + "reject", + "rejected", + "valeur a verifier", + "a verifier", + "to_check", + "manual_check", + ) + return any(marker in normalized_decision for marker in rejected_markers) + + def _game_decision_cell_html(self, decision: str, rejected_decision: bool) -> str: + if not rejected_decision: + return self._table_cell_html(self._html_value(decision)) + return self._table_cell_html( + f'{self._html_value(decision)}', + "background:#fee2e2;", ) def _html_value(self, value) -> str: text = str(value or "") return escape(text) if text else " " - def _append_warnings(self, lines: list[str], warnings: object) -> None: - platform_mappings = list(getattr(warnings, "platform_mappings", []) or []) - manual_matches = list(getattr(warnings, "platform_matches", []) or []) - skipped_games = list(getattr(warnings, "skipped_games", []) or []) - invalid_games = list(getattr(warnings, "invalid_games", []) or []) - invalid_wishlist = int(getattr(warnings, "invalid_wishlist", 0) or 0) - invalid_wishlist_values = list( - getattr(warnings, "invalid_wishlist_values_found", []) or [] - ) - if not any( - [ - platform_mappings, - manual_matches, - skipped_games, - invalid_games, - invalid_wishlist, - ] - ): - lines.append("Warnings: aucun warning detecte.") - return - lines.append("Warnings:") - self._append_platform_mappings(lines, platform_mappings) - self._append_manual_matches(lines, manual_matches) - self._append_skipped_games(lines, skipped_games) - self._append_invalid_games(lines, invalid_games) - self._append_invalid_wishlist(lines, invalid_wishlist, invalid_wishlist_values) - - def _append_platform_mappings(self, lines: list[str], platform_mappings: list[dict]) -> None: - if not platform_mappings: - return - lines.append("Mappings plateformes:") - for mapping in platform_mappings: - alias_text = "oui" if mapping.get("matched_by_alias") else "non" - matched_alias = str(mapping.get("matched_alias") or "") - if matched_alias: - alias_text = f"{alias_text} ({matched_alias})" - lines.append( - "- Plateforme lue: {imported_platform} | Plateforme rattachee: " - "{matched_platform} | Score: {score} | Jeux: {games_count} | " - "Alias: {alias_text}".format(alias_text=alias_text, **mapping) - ) - - def _append_manual_matches(self, lines: list[str], manual_matches: list[dict]) -> None: + def _manual_platform_mappings_html(self, manual_matches: list[dict]) -> str: if not manual_matches: - return - lines.append("Warnings de verification manuelle:") - for match in manual_matches: - lines.append( - "- Jeu: {game_name} | Plateforme importee: {imported_platform} | " - "Plateforme rattachee: {matched_platform} | Score: {score}".format( - **match + return "

Aucune plateforme en attente de validation admin.

" + rows = [] + for mapping in self._manual_platform_mappings(manual_matches): + rows.append( + self._table_row_html( + [ + self._table_cell_html(escape(mapping["imported_platform"])), + self._table_cell_html(escape(mapping["matched_platform"])), + self._table_cell_html(str(mapping["games_count"]), "font-weight:600;"), + self._table_cell_html(escape(", ".join(mapping["game_names"]))), + self._table_cell_html( + 'En attente de validation', + ), + ], + "#fff7ed", ) ) - - def _append_skipped_games(self, lines: list[str], skipped_games: list[dict]) -> None: - if not skipped_games: - return - lines.append("Jeux ignores:") - for skipped_game in skipped_games: - lines.append( - "- Jeu: {game_name} | Plateforme importee: {imported_platform} | " - "Score: {score} | Raison: {reason}".format(**skipped_game) - ) - - def _append_invalid_games(self, lines: list[str], invalid_games: list[dict]) -> None: - if not invalid_games: - return - lines.append("Jeux importes avec informations invalides ignorees:") - for invalid_game in invalid_games: - lines.append("- Jeu: {name}".format(**invalid_game)) - - def _append_invalid_wishlist( - self, - lines: list[str], - invalid_wishlist: int, - invalid_wishlist_values: list[str], - ) -> None: - if invalid_wishlist <= 0: - return - lines.append( - "Wishlist invalide: {count} ligne(s) ignoree(s).".format( - count=invalid_wishlist, - ) + return self._table_html( + ["Valeur dans le fichier", "Plateforme proposée", "Jeux", "Liste des jeux", "Statut"], + rows, ) - if invalid_wishlist_values: - lines.append("Valeurs detectees: " + ", ".join(invalid_wishlist_values)) + + def _manual_platform_mappings(self, manual_matches: list[dict]) -> list[dict]: + mappings_by_key = {} + for match in manual_matches: + imported_platform = str(match.get("imported_platform") or "").strip() + matched_platform = str(match.get("matched_platform") or "").strip() + key = (imported_platform, matched_platform) + mapping = mappings_by_key.get(key) or { + "imported_platform": imported_platform or "-", + "matched_platform": matched_platform or "-", + "games_count": 0, + "game_names": [], + } + mapping["games_count"] += 1 + mapping["game_names"].append(str(match.get("game_name") or "-")) + mappings_by_key[key] = mapping + return list(mappings_by_key.values()) diff --git a/backend/services/users/user_collection_import_association_validator.py b/backend/services/users/user_collection_import_association_validator.py new file mode 100644 index 0000000..521fec8 --- /dev/null +++ b/backend/services/users/user_collection_import_association_validator.py @@ -0,0 +1,90 @@ +# ____ _ _ ____ _ _ _ _ ___ +# / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ +# | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | +# | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | +# \____|_|\___/ \__,_|\__,_|\____\___|_|_|\___|\___|\__|_|\___/|_| |_|\___/|_| |_|\___/ +# Projet : CloudCollectionApp +# Date de creation : 2026-08-20 +# Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien +# Licence : Apache 2.0 +# +# Description : validation metier des associations creees pendant un import utilisateur. + +from services.collection.imports import CollectionImportData +from services.database.user_collection_import_persistence_result import ( + UserCollectionImportPersistenceResult, +) + +from .user_collection_import_errors import UserCollectionImportInvalidFileError + + +class UserCollectionImportAssociationValidator: + """Valide que les donnees lues produisent au moins une association utilisateur.""" + + def ensure_games_read(self, import_data: CollectionImportData) -> None: + """Refuse un import dont aucun jeu importable n'a ete lu. + + Args: + import_data (CollectionImportData): Donnees lues depuis le fichier. + + Returns: + None: Ne retourne rien si au moins un jeu est lisible. + + Raises: + UserCollectionImportInvalidFileError: Si aucun jeu importable n'est trouve. + """ + + if import_data.games: + return + raise UserCollectionImportInvalidFileError( + "Fichier de collection invalide.", + [ + "Aucun jeu importable n'a ete trouve dans le fichier. Verifiez que les " + "colonnes obligatoires Nom du jeu et Plateforme sont correctement configurees." + ], + ) + + def validate( + self, + import_data: CollectionImportData, + persistence_result: UserCollectionImportPersistenceResult, + ) -> None: + """Refuse un import dont aucun jeu lu ne peut etre associe. + + Args: + import_data (CollectionImportData): Donnees lues depuis le fichier. + persistence_result (UserCollectionImportPersistenceResult): Compteurs SQL. + + Returns: + None: Ne retourne rien si au moins un jeu est associe. + + Raises: + UserCollectionImportInvalidFileError: Si les jeux lus sont tous ecartes. + """ + + self.ensure_games_read(import_data) + if persistence_result.associated_games > 0: + return + + detail = ( + "Aucun jeu n'a ete associe a la collection. Verifiez que les colonnes " + "obligatoires Nom du jeu et Plateforme sont correctement configurees." + ) + skipped_platforms = self._skipped_platforms(import_data) + if skipped_platforms: + detail = ( + f"{detail} Plateformes lues mais non reconnues: " + f"{', '.join(skipped_platforms)}." + ) + raise UserCollectionImportInvalidFileError( + "Fichier de collection invalide.", + [detail], + ) + + def _skipped_platforms(self, import_data: CollectionImportData) -> list[str]: + platforms = { + str(skipped_game.get("imported_platform") or "").strip() + for skipped_game in import_data.warnings.skipped_games + if str(skipped_game.get("imported_platform") or "").strip() + } + return sorted(platforms) diff --git a/backend/services/users/user_collection_import_repository_protocol.py b/backend/services/users/user_collection_import_repository_protocol.py index 66c8c0b..e64f71d 100644 --- a/backend/services/users/user_collection_import_repository_protocol.py +++ b/backend/services/users/user_collection_import_repository_protocol.py @@ -53,6 +53,19 @@ def import_collection( UserCollectionImportPersistenceResult: Compteurs de persistance. """ + def prepare_import_data_for_policy( + self, + import_data: CollectionImportData, + ) -> CollectionImportData: + """Prepare les donnees avant la politique de refus global. + + Args: + import_data (CollectionImportData): Donnees lues depuis le fichier. + + Returns: + CollectionImportData: Donnees enrichies des refus de plateforme. + """ + def reinitialize_collection(self, user_id: int) -> None: """Reinitialise la collection persistante d'un utilisateur. diff --git a/backend/services/users/user_collection_import_result.py b/backend/services/users/user_collection_import_result.py index 8438717..8e8586a 100644 --- a/backend/services/users/user_collection_import_result.py +++ b/backend/services/users/user_collection_import_result.py @@ -56,9 +56,12 @@ def to_dict(self) -> dict[str, int | dict]: "invalid_wishlist": 0, "invalid_wishlist_values_found": [], "invalid_games": [], + "skipped_mandatory_games": 0, "platform_mappings": [], "platform_matches": [], "skipped_games": [], + "user_platform_matches": [], + "user_skipped_games": [], "total_import_duration_seconds": 0.0, }, "refusal": self.refusal or { diff --git a/backend/services/users/user_collection_import_service.py b/backend/services/users/user_collection_import_service.py index 52a5fe5..6c94ab0 100644 --- a/backend/services/users/user_collection_import_service.py +++ b/backend/services/users/user_collection_import_service.py @@ -50,6 +50,7 @@ UserCollectionImportUnexpectedError, ) from .user_collection_import_file_manager import UserCollectionImportFileManager +from .user_collection_import_association_validator import UserCollectionImportAssociationValidator from .user_collection_import_report_notifier import UserCollectionImportReportNotifier from .user_collection_import_repository_protocol import UserCollectionImportRepository from .user_collection_import_report_context import UserCollectionImportReportContext @@ -103,6 +104,7 @@ def __init__( self.refusal_notification_service = CollectionImportRefusalNotificationService() self.report_policy = UserCollectionImportReportPolicy() self.refusal_policy = CollectionImportRefusalPolicy() + self.association_validator = UserCollectionImportAssociationValidator() self.logger = logger or logging.getLogger(__name__) def upload_import_file( @@ -327,6 +329,7 @@ def _import_collection_file( file_read_started_at ) import_data = self.date_validator.validate(import_data) + import_data = self.repository.prepare_import_data_for_policy(import_data) self._set_total_import_duration(import_data, import_started_at) refusal = self.refusal_policy.evaluate(import_data) if refusal.refused: @@ -344,6 +347,7 @@ def _import_collection_file( import_data, ) return self._map_refused_result(import_data, refusal_payload) + self.association_validator.ensure_games_read(import_data) persistence_result = self.repository.import_collection( user_id, str(import_file_path), @@ -351,6 +355,7 @@ def _import_collection_file( file_description.to_dict(), initial_game_validation_status, ) + self.association_validator.validate(import_data, persistence_result) result = self._map_result(persistence_result, import_data) if self.report_policy.is_enabled(self.report_notifier): self._notify_import_report( @@ -375,6 +380,9 @@ def _import_collection_file( "Fichier de collection invalide.", self._import_invalid_file_details(exc), ) from exc + except UserCollectionImportInvalidFileError: + self.file_manager.delete_copied_file(copied_file_path) + raise except UserCollectionImportUserNotFoundError as exc: self.file_manager.delete_copied_file(copied_file_path) raise UserCollectionImportUnexpectedError("Utilisateur introuvable.") from exc @@ -391,11 +399,8 @@ def _import_invalid_file_details(self, error: Exception) -> list[str]: messages.append(cause_message) return [message for message in messages if message] - def _map_result( - self, - persistence_result: UserCollectionImportPersistenceResult, - import_data: CollectionImportData, - ) -> UserCollectionImportResult: + def _map_result(self, persistence_result: UserCollectionImportPersistenceResult, + import_data: CollectionImportData) -> UserCollectionImportResult: return UserCollectionImportResult( linked_platforms=persistence_result.linked_platforms, created_studios=persistence_result.created_studios, @@ -406,8 +411,10 @@ def _map_result( refusal={ "refused": False, "reason": "", - "invalid_games_count": len(import_data.warnings.invalid_games), - "total_games_count": len(import_data.games), + "invalid_games_count": ( + refusal := self.refusal_policy.evaluate(import_data) + ).invalid_games_count, + "total_games_count": refusal.total_games_count, "message": "", }, ) diff --git a/backend/tests/by_module/controllers/test_user_collection_import_sheet_information_none_route.py b/backend/tests/by_module/controllers/test_user_collection_import_sheet_information_none_route.py new file mode 100644 index 0000000..c09709d --- /dev/null +++ b/backend/tests/by_module/controllers/test_user_collection_import_sheet_information_none_route.py @@ -0,0 +1,66 @@ +# ____ _ _ ____ _ _ _ _ ___ +# / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ +# | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | +# | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | +# \____|_|\___/ \__,_|\__,_|\____\___|_|_|\___|\___|\__|_|\___/|_| |_|\___/| .__/| .__/ +# |_| |_| +# Projet : CloudCollectionApp +# Date de creation : 2026-08-20 +# Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien +# Licence : Apache 2.0 +# +# Description : test route du mode d'import multi-onglets sans information d'onglet. + +try: + from tests.support.route_test_support import ( + BaseAppRoutesTest, + FakeUserCollectionImportService, + ) +except ModuleNotFoundError: + from tests.support.route_test_support import ( + BaseAppRoutesTest, + FakeUserCollectionImportService, + ) + + +class UserCollectionImportSheetInformationNoneRouteTest(BaseAppRoutesTest): + """Valide le contrat HTTP du mode sans information portee par l'onglet.""" + + def test_import_endpoint_accepts_missing_sheet_information_with_platform_column(self): + """Verifie que le endpoint accepte la plateforme portee par une colonne. + + Args: + Aucun. + + Returns: + None: Les assertions valident le statut et la description transmise. + """ + + response = self.client.post( + "/api/users/import", + headers=self.get_user_auth_headers(), + json={ + "file_type": "libreoffice_ods", + "wishlist": {"mode": "none"}, + "multiple_sheets_conf": { + "shared_layout": { + "included_sheets": ["Janvier", "Fevrier"], + "data_range": "A1:C200", + "header_row": 1, + "column_information": { + "name": "A", + "platform": "B", + "studio": "C", + }, + }, + }, + }, + ) + + self.assertEqual(201, response.status_code) + file_description = FakeUserCollectionImportService.last_call[1].to_dict() + self.assertNotIn("sheet_information", file_description["multiple_sheets_conf"]) + self.assertEqual( + "B", + file_description["multiple_sheets_conf"]["shared_layout"]["column_information"]["platform"], + ) diff --git a/backend/tests/by_module/controllers/test_user_collection_routes.py b/backend/tests/by_module/controllers/test_user_collection_routes.py index 5c41564..5b7892f 100644 --- a/backend/tests/by_module/controllers/test_user_collection_routes.py +++ b/backend/tests/by_module/controllers/test_user_collection_routes.py @@ -124,6 +124,61 @@ def test_current_user_import_configuration_returns_not_found_without_configurati self.assertEqual(404, response.status_code) self.assertEqual({"error": "Configuration d'import introuvable."}, response.get_json()) + def test_import_invalid_value_help_requires_authentication(self): + """Verifie que l'aide des valeurs refusees exige un token. + + Args: + Aucun. + + Returns: + None: Les assertions valident 403. + """ + + self.assertEqual( + 403, + self.client.get("/api/users/import/invalid-value-help?field=region").status_code, + ) + + def test_import_invalid_value_help_requires_field(self): + """Verifie que le champ refuse doit etre fourni. + + Args: + Aucun. + + Returns: + None: Les assertions valident 400. + """ + + response = self.client.get( + "/api/users/import/invalid-value-help", + headers=self.get_user_auth_headers(), + ) + + self.assertEqual(400, response.status_code) + self.assertEqual({"error": "Le parametre field est requis."}, response.get_json()) + + def test_import_invalid_value_help_returns_reason_and_possible_values(self): + """Verifie le payload d'aide pour une valeur refusee. + + Args: + Aucun. + + Returns: + None: Les assertions valident la reponse JSON. + """ + + response = self.client.get( + "/api/users/import/invalid-value-help?field=region&value=Ici%20ou%20parla", + headers=self.get_user_auth_headers(), + ) + + self.assertEqual(200, response.status_code) + payload = response.get_json() + self.assertEqual("region", payload["field"]) + self.assertEqual("Ici ou parla", payload["value"]) + self.assertIn("region", payload["reason"]) + self.assertIn("EU-FR", payload["possible_values"]) + def test_upload_current_user_collection_import_file_returns_created(self): """Verifie le depot temporaire nominal d'une collection. @@ -192,9 +247,12 @@ def test_import_current_user_collection_returns_counts(self): "invalid_wishlist": 0, "invalid_wishlist_values_found": [], "invalid_games": [], + "skipped_mandatory_games": 0, "platform_mappings": [], "platform_matches": [], "skipped_games": [], + "user_platform_matches": [], + "user_skipped_games": [], "total_import_duration_seconds": 0.0, }, payload["warnings"], diff --git a/backend/tests/by_module/services/collection/imports/test_collection_import_invalid_value_help_service.py b/backend/tests/by_module/services/collection/imports/test_collection_import_invalid_value_help_service.py new file mode 100644 index 0000000..2f279f6 --- /dev/null +++ b/backend/tests/by_module/services/collection/imports/test_collection_import_invalid_value_help_service.py @@ -0,0 +1,65 @@ +# ____ _ _ ____ _ _ _ _ ___ +# / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ +# | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | +# | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | +# \____|_|\___/ \__,_|\__,_|\____\___|_|_|\___|\___|\__|_|\___/|_| |_|\___/| .__/| .__/ +# |_| |_| +# Projet : CloudCollectionApp +# Date de creation : 2026-08-23 +# Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien +# Licence : Apache 2.0 +# +# Description : tests de l'aide aux valeurs d'import refusees. + +import unittest + +from services.collection.imports import CollectionImportInvalidValueHelpService + + +class CollectionImportInvalidValueHelpServiceTest(unittest.TestCase): + """Valide les aides de correction des valeurs refusees.""" + + def test_get_help_returns_region_reason_and_possible_values(self): + """Verifie l'aide d'une region invalide. + + Args: + Aucun. + + Returns: + None: Les assertions valident la raison et les valeurs possibles. + """ + + help_result = CollectionImportInvalidValueHelpService().get_help( + "region", + "Ici ou parla", + ) + + self.assertEqual("region", help_result.field) + self.assertEqual("Ici ou parla", help_result.value) + self.assertIn("region", help_result.reason) + self.assertIn("EU-FR", help_result.possible_values) + self.assertIn("PAL - UK", help_result.possible_values) + + def test_get_help_returns_generic_reason_for_unknown_field(self): + """Verifie le repli pour un champ inconnu. + + Args: + Aucun. + + Returns: + None: Les assertions valident le repli. + """ + + help_result = CollectionImportInvalidValueHelpService().get_help( + "custom", + "x", + ) + + self.assertEqual("custom", help_result.field) + self.assertEqual("x", help_result.value) + self.assertEqual([], help_result.possible_values) + self.assertIn("format attendu", help_result.reason) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/by_module/services/collection/imports/test_collection_import_refusal_admin_notifier.py b/backend/tests/by_module/services/collection/imports/test_collection_import_refusal_admin_notifier.py index 97e4e5b..082e22f 100644 --- a/backend/tests/by_module/services/collection/imports/test_collection_import_refusal_admin_notifier.py +++ b/backend/tests/by_module/services/collection/imports/test_collection_import_refusal_admin_notifier.py @@ -77,6 +77,24 @@ def test_notify_import_refusal_uses_backend_resource_template(self): }, {"name": "Mario", "invalid_fields": [{"field": "condition"}]}, ], + platform_matches=[ + { + "game_name": "Legend of dragoon", + "imported_platform": "La Playstation de la mort", + "matched_platform": "PlayStation Portable", + "score": 50, + }, + ], + skipped_games=[ + { + "game_name": "Unknown Game", + "imported_platform": "Unknown", + "score": 0, + "reason": "no_match", + }, + ], + skipped_mandatory_games=1, + invalid_wishlist=1, ), ), ) @@ -91,8 +109,23 @@ def test_notify_import_refusal_uses_backend_resource_template(self): self.assertIn("collection <bad>.csv", email["body"]) self.assertIn("too_many_invalid_games", email["body"]) self.assertIn("2/3", email["body"]) + self.assertIn("

Compteurs d'erreur

", email["body"]) + self.assertIn("Jeux avec erreur bloquante", email["body"]) + self.assertIn("Jeux avec information invalide", email["body"]) + self.assertIn("Jeux refuses ou ignores", email["body"]) + self.assertIn("Lignes sans nom ou plateforme obligatoire", email["body"]) + self.assertIn("Jeux avec plateforme a valider", email["body"]) + self.assertIn("Lignes wishlist ignorees", email["body"]) + self.assertIn("Total utilise pour refuser le fichier.", email["body"]) self.assertIn("Zelda <DX>", email["body"]) self.assertIn("release_date: 1900-01-01", email["body"]) + self.assertIn("Plateformes à valider par l'admin", email["body"]) + self.assertIn("La Playstation de la mort", email["body"]) + self.assertIn("PlayStation Portable", email["body"]) + self.assertIn("Legend of dragoon", email["body"]) + self.assertIn("En attente de validation", email["body"]) + self.assertNotIn("

Warnings

", email["body"]) + self.assertNotIn("- Jeux invalides:", email["body"]) def test_default_template_path_targets_backend_resource(self): """Verifie le nom du template par defaut. diff --git a/backend/tests/by_module/services/collection/imports/test_collection_import_refusal_policy.py b/backend/tests/by_module/services/collection/imports/test_collection_import_refusal_policy.py new file mode 100644 index 0000000..1abd4c1 --- /dev/null +++ b/backend/tests/by_module/services/collection/imports/test_collection_import_refusal_policy.py @@ -0,0 +1,61 @@ +# ____ _ _ ____ _ _ _ _ ___ +# / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ +# | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | +# | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | +# \____|_|\___/ \__,_|\__,_|\____\___|_|_|\___|\___|\__|_|\___/|_| |_|\___/| .__/| .__/ +# |_| |_| +# Projet : CloudCollectionApp +# Date de creation : 2026-08-23 +# Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien +# Licence : Apache 2.0 +# +# Description : tests de la politique de refus global d'import. + +import unittest + +from services.collection.imports import ( + CollectionImportData, + CollectionImportGame, + CollectionImportRefusalPolicy, + CollectionImportWarnings, +) + + +class CollectionImportRefusalPolicyTest(unittest.TestCase): + """Valide le calcul des jeux en erreur utilise pour refuser un fichier.""" + + def test_evaluate_counts_invalid_platform_and_missing_mandatory_games(self): + """Verifie le compteur global des jeux en erreur. + + Args: + Aucun. + + Returns: + None: Les assertions valident le refus et les compteurs. + """ + + import_data = CollectionImportData( + platforms=[], + studios=[], + games=[CollectionImportGame("Zelda", "Switch", None, None)], + warnings=CollectionImportWarnings( + invalid_games=[ + {"name": "Zelda", "invalid_fields": [{"field": "region"}]}, + ], + skipped_games=[ + {"game_name": "Loaded", "imported_platform": "TrouDuc"}, + ], + skipped_mandatory_games=1, + ), + ) + + refusal = CollectionImportRefusalPolicy().evaluate(import_data) + + self.assertTrue(refusal.refused) + self.assertEqual(3, refusal.invalid_games_count) + self.assertEqual(3, refusal.total_games_count) + self.assertIn("3/3", refusal.message) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/by_module/services/collection/imports/test_collection_import_refusal_service_notifications.py b/backend/tests/by_module/services/collection/imports/test_collection_import_refusal_service_notifications.py index 07c1170..5f7f787 100644 --- a/backend/tests/by_module/services/collection/imports/test_collection_import_refusal_service_notifications.py +++ b/backend/tests/by_module/services/collection/imports/test_collection_import_refusal_service_notifications.py @@ -111,6 +111,11 @@ def import_collection( self.import_calls.append(import_data) return UserCollectionImportPersistenceResult(1, 1, 1, 1) + def prepare_import_data_for_policy(self, import_data): + """Retourne les donnees sans preparation supplementaire.""" + + return import_data + class FakeAdminRepository: """Capture les appels de persistance admin.""" diff --git a/backend/tests/by_module/services/collection/imports/test_collection_sheet_information_none.py b/backend/tests/by_module/services/collection/imports/test_collection_sheet_information_none.py new file mode 100644 index 0000000..24a6e67 --- /dev/null +++ b/backend/tests/by_module/services/collection/imports/test_collection_sheet_information_none.py @@ -0,0 +1,128 @@ +# ____ _ _ ____ _ _ _ _ ___ +# / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ +# | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | +# | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | +# \____|_|\___/ \__,_|\__,_|\____\___|_|_|\___|\___|\__|_|\___/|_| |_|\___/| .__/| .__/ +# |_| |_| +# Projet : CloudCollectionApp +# Date de creation : 2026-08-20 +# Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien +# Licence : Apache 2.0 +# +# Description : tests du contrat multi-onglets sans information portee par l'onglet. + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(next(parent for parent in Path(__file__).resolve().parents if (parent / "app.py").exists()))) + +from services.collection.imports import ( # noqa: E402 + CollectionFileDescriptionValidationError, + CollectionFileDescriptionValidator, + CollectionImportField, +) + + +class CollectionSheetInformationNoneTest(unittest.TestCase): + """Valide le mode multi-onglets sans champ porte par le nom d'onglet.""" + + def setUp(self): + """Prepare le validateur teste. + + Args: + Aucun. + + Returns: + None: Le validateur est initialise. + """ + + self.validator = CollectionFileDescriptionValidator() + + def test_accepts_shared_layout_without_sheet_information(self): + """Verifie que la plateforme peut venir d'une colonne en layout partage. + + Args: + Aucun. + + Returns: + None: Les assertions valident la configuration. + """ + + description = self.validator.validate({ + "file_type": "libreoffice_ods", + "wishlist": {"mode": "none"}, + "multiple_sheets_conf": { + "shared_layout": { + "data_range": "A1:C200", + "header_row": 1, + "column_information": {"name": "A", "platform": "B", "studio": "C"}, + }, + }, + }) + + self.assertIsNone(description.multiple_sheets_conf.sheet_information) + self.assertEqual( + "B", + description.multiple_sheets_conf.shared_layout.column_information[ + CollectionImportField.PLATFORM + ], + ) + self.assertNotIn("sheet_information", description.to_dict()["multiple_sheets_conf"]) + + def test_rejects_shared_layout_without_platform_column(self): + """Verifie que la colonne plateforme reste obligatoire dans ce mode. + + Args: + Aucun. + + Returns: + None: Les assertions valident l'erreur. + """ + + with self.assertRaises(CollectionFileDescriptionValidationError) as context: + self.validator.validate({ + "file_type": "libreoffice_ods", + "wishlist": {"mode": "none"}, + "multiple_sheets_conf": { + "shared_layout": { + "data_range": "A1:C200", + "header_row": 1, + "column_information": {"name": "A", "studio": "C"}, + }, + }, + }) + + self.assertIn("colonne obligatoire manquante: platform.", context.exception.details) + + def test_accepts_per_sheet_layout_without_sheet_information(self): + """Verifie le meme contrat pour une configuration par onglet. + + Args: + Aucun. + + Returns: + None: Les assertions valident la configuration. + """ + + description = self.validator.validate({ + "file_type": "libreoffice_ods", + "wishlist": {"mode": "none"}, + "multiple_sheets_conf": { + "sheets": [{ + "sheet_name": "Jeux 2026", + "data_range": "A1:C200", + "header_row": 1, + "column_information": {"name": "A", "platform": "B", "studio": "C"}, + }], + }, + }) + + sheet = description.multiple_sheets_conf.sheets[0] + self.assertIsNone(sheet.sheet_information) + self.assertEqual("B", sheet.layout.column_information[CollectionImportField.PLATFORM]) + self.assertNotIn("sheet_information", description.to_dict()["multiple_sheets_conf"]["sheets"][0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/by_module/services/csv/test_csv_collection_import_reader.py b/backend/tests/by_module/services/csv/test_csv_collection_import_reader.py index 55213e8..3ed3fe6 100644 --- a/backend/tests/by_module/services/csv/test_csv_collection_import_reader.py +++ b/backend/tests/by_module/services/csv/test_csv_collection_import_reader.py @@ -107,6 +107,7 @@ def test_read_ignores_empty_game_names_and_empty_optionals(self): self.assertEqual(["Mario"], [game.name for game in import_data.games]) self.assertEqual([], import_data.warnings.invalid_games) + self.assertEqual(1, import_data.warnings.skipped_mandatory_games) self.assertEqual(0, import_data.warnings.invalid_wishlist) def test_read_reports_invalid_optional_values(self): diff --git a/backend/tests/by_module/services/database/test_platform_matching_admin_notifier.py b/backend/tests/by_module/services/database/test_platform_matching_admin_notifier.py deleted file mode 100644 index 2213b00..0000000 --- a/backend/tests/by_module/services/database/test_platform_matching_admin_notifier.py +++ /dev/null @@ -1,107 +0,0 @@ -# ____ _ _ ____ _ _ _ _ ___ -# / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ -# | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | -# | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | -# \____|_|\___/ \__,_|\__,_|\____\___|_|_|\___|\___|\__|_|\___/|_| |_|\___/| .__/| .__/ -# |_| |_| -# Projet : CloudCollectionApp -# Date de creation : 2026-06-14 -# Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien -# Licence : Apache 2.0 -# -# Description : tests de notification admin du matching plateformes. - -import unittest - -from services.collection.imports import CollectionImportWarnings -from services.database import PlatformMatchingAdminNotifier -from tests.support.fake_platform_matching_email_sender import FakePlatformMatchingEmailSender - - -class PlatformMatchingAdminNotifierTest(unittest.TestCase): - """Valide l'email de verification manuelle des plateformes.""" - - def test_notify_manual_matches_sends_email_only_when_needed(self): - """Verifie l'envoi conditionnel de l'email administrateur. - - Args: - Aucun. - - Returns: - None: Les assertions valident l'email. - """ - - sender = FakePlatformMatchingEmailSender() - notifier = PlatformMatchingAdminNotifier(sender, "admin@example.com") - - notifier.notify_manual_matches([]) - notifier.notify_manual_matches([ - { - "game_name": "Sports", - "imported_platform": "Wii", - "matched_platform": "Switch", - "score": 33, - } - ]) - - self.assertEqual(1, len(sender.sent_emails)) - self.assertEqual("admin@example.com", sender.sent_emails[0]["recipient_email"]) - self.assertIn("Sports", sender.sent_emails[0]["body"]) - self.assertIn("Switch", sender.sent_emails[0]["body"]) - - def test_notify_import_report_sends_platform_mappings_and_warnings(self): - """Verifie le rapport complet de fin d'import. - - Args: - Aucun. - - Returns: - None: Les assertions valident le contenu du mail. - """ - - sender = FakePlatformMatchingEmailSender() - notifier = PlatformMatchingAdminNotifier(sender, "admin@example.com") - warnings = CollectionImportWarnings( - invalid_wishlist=1, - invalid_wishlist_values_found=["Peut etre"], - invalid_games=[{"name": "Chrono"}], - total_import_duration_seconds=1.234, - platform_mappings=[ - { - "imported_platform": "Super Famicom", - "matched_platform": "Super Nintendo", - "score": 100, - "games_count": 3, - "matched_by_alias": True, - "matched_alias": "Super Famicom", - "accepted": True, - "manual_check": False, - "reason": "", - } - ], - platform_matches=[], - skipped_games=[ - { - "game_name": "Unknown Game", - "imported_platform": "Unknown", - "score": 0, - "reason": "no_match", - } - ], - ) - - notifier.notify_import_report(warnings) - - self.assertEqual(1, len(sender.sent_emails)) - body = sender.sent_emails[0]["body"] - self.assertIn("Super Nintendo", body) - self.assertIn("Jeux: 3", body) - self.assertIn("Duree totale de l'import: 1.234 seconde(s).", body) - self.assertIn("Alias: oui (Super Famicom)", body) - self.assertIn("Unknown Game", body) - self.assertIn("Chrono", body) - self.assertIn("Peut etre", body) - - -if __name__ == "__main__": - unittest.main() diff --git a/backend/tests/by_module/services/database/test_platform_matching_service.py b/backend/tests/by_module/services/database/test_platform_matching_service.py index 37442b7..6e5921f 100644 --- a/backend/tests/by_module/services/database/test_platform_matching_service.py +++ b/backend/tests/by_module/services/database/test_platform_matching_service.py @@ -59,6 +59,8 @@ def test_match_import_data_accepts_exact_case_accent_space_and_minor_typo(self): ]) self.assertEqual([], matched_data.warnings.platform_matches) self.assertEqual([], matched_data.warnings.skipped_games) + self.assertEqual([], matched_data.warnings.user_platform_matches) + self.assertEqual([], matched_data.warnings.user_skipped_games) self.assertEqual(59, matched_data.games[0].purchase_price) self.assertEqual("EUR", matched_data.games[0].price_unit) self.assertEqual("EU-FR", matched_data.games[0].region) @@ -84,6 +86,14 @@ def test_match_import_data_accepts_low_score_with_warning(self): self.assertEqual(["Switch"], [game.platform_name for game in matched_data.games]) self.assertEqual("Sports", matched_data.warnings.platform_matches[0]["game_name"]) + self.assertEqual( + { + "game_name": "Sports", + "imported_platform": "Wii", + "message": "Correspondance a verifier avec Switch", + }, + matched_data.warnings.user_platform_matches[0], + ) self.assertEqual( [ { @@ -101,6 +111,7 @@ def test_match_import_data_accepts_low_score_with_warning(self): matched_data.warnings.platform_mappings, ) self.assertEqual([], matched_data.warnings.skipped_games) + self.assertEqual([], matched_data.warnings.user_skipped_games) def test_match_import_data_uses_alias_when_direct_score_is_not_high(self): """Verifie le recours aux alias quand le score direct est sous le seuil haut. @@ -142,6 +153,8 @@ def test_match_import_data_uses_alias_when_direct_score_is_not_high(self): self.assertEqual(1, matched_data.warnings.platform_mappings[0]["games_count"]) self.assertEqual([], matched_data.warnings.platform_matches) self.assertEqual([], matched_data.warnings.skipped_games) + self.assertEqual([], matched_data.warnings.user_platform_matches) + self.assertEqual([], matched_data.warnings.user_skipped_games) def test_match_import_data_maps_pc_store_aliases_to_pc_platform(self): """Verifie que les boutiques PC importees sont rattachees a PC. @@ -191,6 +204,8 @@ def test_match_import_data_maps_pc_store_aliases_to_pc_platform(self): ]) self.assertEqual([], matched_data.warnings.platform_matches) self.assertEqual([], matched_data.warnings.skipped_games) + self.assertEqual([], matched_data.warnings.user_platform_matches) + self.assertEqual([], matched_data.warnings.user_skipped_games) self.assertTrue( all(mapping["matched_by_alias"] for mapping in matched_data.warnings.platform_mappings) ) @@ -225,9 +240,32 @@ def test_match_import_data_rejects_too_low_score_zero_and_ambiguity(self): self.assertEqual([], matched_data.games) reasons = [warning["reason"] for warning in matched_data.warnings.skipped_games] - self.assertIn("low_score", reasons) - self.assertIn("no_match", reasons) - self.assertIn("ambiguous", reasons) + self.assertIn( + "Plateforme invalide (plateforme la plus proche détectée : \"Switch\").", + reasons, + ) + self.assertIn("Plateforme invalide (aucune plateforme proche détectée).", reasons) + self.assertIn("Plateforme invalide (plusieurs plateformes proches détectées).", reasons) + self.assertEqual( + [ + { + "game_name": "Low", + "imported_platform": "Unknown", + "message": "Ne correspond a aucune plateforme existante", + }, + { + "game_name": "Zero", + "imported_platform": "qqq", + "message": "Ne correspond a aucune plateforme existante", + }, + { + "game_name": "Ambiguous", + "imported_platform": "abc", + "message": "Ne correspond a aucune plateforme existante", + }, + ], + matched_data.warnings.user_skipped_games, + ) def _service(self): return PlatformMatchingService( diff --git a/backend/tests/by_module/services/database/test_user_collection_import_platform_matching_repository.py b/backend/tests/by_module/services/database/test_user_collection_import_platform_matching_repository.py index ead4485..03752b5 100644 --- a/backend/tests/by_module/services/database/test_user_collection_import_platform_matching_repository.py +++ b/backend/tests/by_module/services/database/test_user_collection_import_platform_matching_repository.py @@ -246,6 +246,10 @@ def test_import_collection_exposes_platform_matching_warnings_to_caller(self): self.assertEqual(1, result.linked_platforms) self.assertEqual(["Switch"], [game.platform_name for game in import_data.games]) self.assertEqual("Sports", import_data.warnings.platform_matches[0]["game_name"]) + self.assertEqual( + "Correspondance a verifier avec Switch", + import_data.warnings.user_platform_matches[0]["message"], + ) self.assertEqual("Switch", import_data.warnings.platform_mappings[0]["matched_platform"]) def test_ensure_games_reuses_existing_game_with_high_unique_score(self): diff --git a/backend/tests/by_module/services/ods/test_ods_sheet_information_none.py b/backend/tests/by_module/services/ods/test_ods_sheet_information_none.py new file mode 100644 index 0000000..bf1c956 --- /dev/null +++ b/backend/tests/by_module/services/ods/test_ods_sheet_information_none.py @@ -0,0 +1,84 @@ +# ____ _ _ ____ _ _ _ _ ___ +# / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ +# | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | +# | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | +# \____|_|\___/ \__,_|\__,_|\____\___|_|_|\___|\___|\__|_|\___/|_| |_|\___/| .__/| .__/ +# |_| |_| +# Projet : CloudCollectionApp +# Date de creation : 2026-08-20 +# Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien +# Licence : Apache 2.0 +# +# Description : tests ODS du mode multi-onglets sans information d'onglet. + +import sys +import unittest +from pathlib import Path + +import pandas as pd + +sys.path.insert(0, str(next(parent for parent in Path(__file__).resolve().parents if (parent / "app.py").exists()))) + +from services.collection.imports import CollectionFileDescriptionValidator # noqa: E402 +from services.ods import OdsCollectionImportReader # noqa: E402 + +try: + from tests.by_module.services.ods.test_ods_collection_import_reader import FakeOdsReader +except ModuleNotFoundError: + from tests.by_module.services.ods.test_ods_collection_import_reader import FakeOdsReader + + +class OdsSheetInformationNoneTest(unittest.TestCase): + """Valide la lecture ODS quand l'onglet ne porte aucune information.""" + + def test_read_uses_platform_column_when_sheet_information_is_missing(self): + """Verifie que la plateforme importee vient de la colonne configuree. + + Args: + Aucun. + + Returns: + None: Les assertions valident les jeux lus. + """ + + fake_reader = FakeOdsReader( + ["Janvier", "Fevrier"], + { + "Janvier": self._dataframe("Zelda", "Switch"), + "Fevrier": self._dataframe("Doom", "PC"), + }, + ) + service = OdsCollectionImportReader(reader_factory=lambda ods_path: fake_reader) + + import_data = service.read("/tmp/no-sheet-info.ods", self._description()) + + self.assertEqual(["Switch", "PC"], [platform.name for platform in import_data.platforms]) + self.assertEqual(["Switch", "PC"], [game.platform_name for game in import_data.games]) + self.assertEqual( + [("Janvier", "A1:C200", 1, "A,B,C"), ("Fevrier", "A1:C200", 1, "A,B,C")], + fake_reader.sheet_dataframe_calls, + ) + + def _dataframe(self, name, platform): + return pd.DataFrame( + [{"Nom du jeu": name, "Plateforme": platform, "Studio": ""}], + columns=["Nom du jeu", "Plateforme", "Studio"], + ) + + def _description(self): + return CollectionFileDescriptionValidator().validate({ + "file_type": "libreoffice_ods", + "wishlist": {"mode": "none"}, + "multiple_sheets_conf": { + "shared_layout": { + "included_sheets": ["Janvier", "Fevrier"], + "data_range": "A1:C200", + "header_row": 1, + "column_information": {"name": "A", "platform": "B", "studio": "C"}, + }, + }, + }) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/by_module/services/users/test_user_collection_import_admin_notifier.py b/backend/tests/by_module/services/users/test_user_collection_import_admin_notifier.py index 161dfec..969ed80 100644 --- a/backend/tests/by_module/services/users/test_user_collection_import_admin_notifier.py +++ b/backend/tests/by_module/services/users/test_user_collection_import_admin_notifier.py @@ -50,7 +50,7 @@ def test_notify_import_report_sends_email_without_warning(self): self.assertIn("Jeux associes: 4", body) self.assertIn("Aucun studio importe.", body) self.assertIn("Aucun jeu importe.", body) - self.assertIn("Warnings: aucun warning detecte.", body) + self.assertNotIn("

Warnings

", body) self.assertEqual("html", sender.sent_emails[0]["content_subtype"]) def test_notify_import_report_uses_backend_resource_template(self): @@ -122,21 +122,84 @@ def test_notify_import_report_sends_all_warning_sections(self): "reason": "no_match", } ], + skipped_mandatory_games=1, ) notifier.notify_import_report(self._context(warnings)) body = sender.sent_emails[0]["body"] + self.assertIn("

Compteurs d'erreur

", body) + self.assertLess( + body.index("

Compteurs d'erreur

"), + body.index("

Plateformes à valider par l'admin

"), + ) + self.assertLess( + body.index("

Plateformes à valider par l'admin

"), + body.index("

Studios importes

"), + ) + self.assertIn("Jeux avec erreur bloquante", body) + self.assertIn("Jeux lus dans le fichier", body) + self.assertIn("Jeux avec information invalide", body) + self.assertIn("Jeux refuses ou ignores", body) + self.assertIn("Lignes sans nom ou plateforme obligatoire", body) + self.assertIn("Jeux avec plateforme a valider", body) + self.assertIn("Non bloquant: validation admin attendue.", body) + self.assertIn("Lignes wishlist ignorees", body) self.assertIn("Duree totale de l'import: 1.234 seconde(s).", body) self.assertIn("Lecture du fichier: 0.120 seconde(s).", body) self.assertIn("Calcul des associations: 0.340 seconde(s).", body) self.assertIn("Requetes base de donnees: 0.560 seconde(s).", body) - self.assertIn("Super Nintendo", body) - self.assertIn("Alias: oui (Super Famicom)", body) + self.assertIn("

Plateformes à valider par l'admin

", body) + self.assertIn("Valeur dans le fichier", body) + self.assertIn("Plateforme proposée", body) + self.assertIn("background:#fff7ed", body) + self.assertIn("Wii", body) + self.assertIn("Nintendo Wii", body) + self.assertIn("En attente de validation", body) self.assertIn("Sports", body) - self.assertIn("Unknown Game", body) - self.assertIn("Chrono", body) - self.assertIn("Peut etre", body) + self.assertNotIn("

Warnings

", body) + + def test_notify_import_report_groups_manual_platform_values(self): + """Verifie le mapping des valeurs plateformes a valider dans le mail. + + Args: + Aucun. + + Returns: + None: Les assertions valident la synthese admin. + """ + + sender = FakePlatformMatchingEmailSender() + notifier = UserCollectionImportAdminNotifier(sender, "admin@example.com") + warnings = CollectionImportWarnings( + platform_matches=[ + { + "game_name": "Legend of dragoon", + "imported_platform": "La Playstation de la mort", + "matched_platform": "PlayStation Portable", + "score": 50, + }, + { + "game_name": "Loaded", + "imported_platform": "La Playstation de la mort", + "matched_platform": "PlayStation Portable", + "score": 50, + }, + ], + ) + + notifier.notify_import_report(self._context(warnings)) + + body = sender.sent_emails[0]["body"] + self.assertIn("

Plateformes à valider par l'admin

", body) + self.assertIn("background:#fff7ed", body) + self.assertIn("La Playstation de la mort", body) + self.assertIn("PlayStation Portable", body) + self.assertIn("Legend of dragoon, Loaded", body) + self.assertIn("En attente de validation", body) + self.assertIn("Legend of dragoon", body) + self.assertIn("Loaded", body) + self.assertNotIn("

Warnings

", body) def test_notify_import_report_sends_imported_game_match_reports_table(self): """Verifie le tableau HTML de diagnostic des jeux importes. @@ -177,18 +240,32 @@ def test_notify_import_report_sends_imported_game_match_reports_table(self): "exact_normalized_key", "Cle plateforme/jeu normalisee deja presente.", ), + ImportedGameMatchReport( + "Unknown", + False, + "", + 12, + "valeur à vérifier", + "below_threshold", + "Score insuffisant.", + ), ), ) ) body = sender.sent_emails[0]["body"] - self.assertIn("Zelda <DX>", body) - self.assertIn("Oui", body) - self.assertIn(" ", body) - self.assertIn("33", body) - self.assertIn("fuzzy_similarity", body) - self.assertIn("Mario Kart 8 Deluxe", body) - self.assertIn("exact_normalized_key", body) + self.assertIn("background:#ecfdf3", body) + self.assertIn("background:#dcfce7", body) + self.assertIn("Zelda <DX>", body) + self.assertIn('Oui', body) + self.assertIn(" ", body) + self.assertIn("33", body) + self.assertIn("fuzzy_similarity", body) + self.assertIn("Mario Kart 8 Deluxe", body) + self.assertIn("exact_normalized_key", body) + self.assertIn("background:#fef2f2", body) + self.assertIn("background:#fee2e2", body) + self.assertIn('valeur à vérifier', body) def test_notify_import_report_sends_imported_studio_match_reports_table(self): """Verifie le tableau HTML de diagnostic des studios importes. @@ -214,17 +291,60 @@ def test_notify_import_report_sends_imported_studio_match_reports_table(self): ) body = sender.sent_emails[0]["body"] - self.assertIn("Nom du studio importé", body) - self.assertIn("Créé", body) - self.assertIn("Nom du Studio associé", body) - self.assertIn("Score de matching", body) - self.assertIn("Acclaim <Import>", body) - self.assertIn("Non", body) - self.assertIn("Acclaim Studios", body) - self.assertIn("100", body) - self.assertIn("Rare", body) - self.assertIn("Oui", body) - self.assertIn("22", body) + self.assertIn("Nom du studio importé", body) + self.assertIn("Créé", body) + self.assertIn("Nom du Studio associé", body) + self.assertIn("Score de matching", body) + self.assertIn("background:#ecfdf3", body) + self.assertIn("background:#dcfce7", body) + self.assertIn("Acclaim <Import>", body) + self.assertIn('Non', body) + self.assertIn("Acclaim Studios", body) + self.assertIn("100", body) + self.assertIn("Rare", body) + self.assertIn('Oui', body) + self.assertIn("22", body) + + def test_notify_import_report_formats_configuration_json(self): + """Verifie que la configuration JSON du mail est coloree et indentee. + + Args: + Aucun. + + Returns: + None: Les assertions valident le rendu HTML lisible du JSON. + """ + + sender = FakePlatformMatchingEmailSender() + notifier = UserCollectionImportAdminNotifier(sender, "admin@example.com") + + notifier.notify_import_report( + self._context( + CollectionImportWarnings(), + collection_file_description={ + "file_type": "libreoffice_ods", + "first_data_row": 2, + "columns": {"name": "Jeu ", "wishlist": True}, + }, + ) + ) + + body = sender.sent_emails[0]["body"] + self.assertIn( + '
', body)
+        self.assertIn('"columns":', body)
+        self.assertIn('"Jeu <Nom>"', body)
+        self.assertIn(
+            '2',
+            body,
+        )
+        self.assertIn(
+            'true',
+            body,
+        )
 
     def _context(
         self,
@@ -232,6 +352,7 @@ def _context(
         created_game_match_reports=(),
         imported_game_match_reports=(),
         imported_studio_match_reports=(),
+        collection_file_description=None,
     ):
         return UserCollectionImportReportContext(
             user_id=7,
@@ -246,7 +367,8 @@ def _context(
             associated_games=4,
             wishlisted_games=1,
             warnings=warnings,
-            collection_file_description={"file_type": "libreoffice_ods"},
+            collection_file_description=collection_file_description
+            or {"file_type": "libreoffice_ods"},
             created_game_match_reports=created_game_match_reports,
             imported_game_match_reports=imported_game_match_reports,
             imported_studio_match_reports=imported_studio_match_reports,
diff --git a/backend/tests/by_module/services/users/test_user_collection_import_service.py b/backend/tests/by_module/services/users/test_user_collection_import_service.py
index e036d7d..f57a01a 100644
--- a/backend/tests/by_module/services/users/test_user_collection_import_service.py
+++ b/backend/tests/by_module/services/users/test_user_collection_import_service.py
@@ -52,13 +52,14 @@
 class FakeUserCollectionImportRepository:
     """Simule le repository d'import de collection utilisateur."""
 
-    def __init__(self, has_collection=False, result=None, import_error=None):
+    def __init__(self, has_collection=False, result=None, import_error=None, prepared_data=None):
         """Initialise le repository factice.
 
         Args:
             has_collection (bool): Indique si une collection existe deja.
             result (UserCollectionImportPersistenceResult | None): Resultat retourne.
             import_error (Exception | None): Erreur levee pendant la persistance.
+            prepared_data (OdsCollectionImportData | None): Donnees preparees pour le refus.
 
         Returns:
             None: Le constructeur ne retourne aucune valeur.
@@ -73,6 +74,7 @@ def __init__(self, has_collection=False, result=None, import_error=None):
             user_email="importer@example.com",
         )
         self.import_error = import_error
+        self.prepared_data = prepared_data
         self.import_calls = []
 
     def user_has_collection(self, user_id):
@@ -124,6 +126,18 @@ def import_collection(
             raise self.import_error
         return self.result
 
+    def prepare_import_data_for_policy(self, import_data):
+        """Retourne les donnees preparees pour la politique de refus.
+
+        Args:
+            import_data (OdsCollectionImportData): Donnees lues.
+
+        Returns:
+            OdsCollectionImportData: Donnees preparees ou donnees d'origine.
+        """
+
+        return self.prepared_data or import_data
+
 
 class FakeOdsCollectionImportReader:
     """Simule le lecteur ODS d'import."""
@@ -573,6 +587,146 @@ def test_import_collection_refuses_file_when_more_than_half_games_have_errors(se
             self.assertEqual(3, result.refusal["total_games_count"])
             self.assertIn("2/3", result.refusal["message"])
 
+    def test_import_collection_counts_platform_rejections_before_persistence(self):
+        """Verifie que les plateformes refusees participent au refus global.
+
+        Args:
+            Aucun.
+
+        Returns:
+            None: Les assertions valident le compteur de refus.
+        """
+
+        import_data = OdsCollectionImportData(
+            platforms=[OdsCollectionImportPlatform("TrouDuc")],
+            studios=[],
+            games=[
+                OdsCollectionImportGame("Loaded", "TrouDuc", None, None),
+                OdsCollectionImportGame("Oddworld", "TrouDuc", None, None),
+            ],
+            warnings=CollectionImportWarnings(),
+        )
+        prepared_data = OdsCollectionImportData(
+            platforms=[],
+            studios=[],
+            games=[],
+            warnings=CollectionImportWarnings(
+                skipped_games=[
+                    {"game_name": "Loaded", "imported_platform": "TrouDuc"},
+                    {"game_name": "Oddworld", "imported_platform": "TrouDuc"},
+                ],
+                user_skipped_games=[
+                    {"game_name": "Loaded", "imported_platform": "TrouDuc"},
+                    {"game_name": "Oddworld", "imported_platform": "TrouDuc"},
+                ],
+            ),
+        )
+        with tempfile.TemporaryDirectory() as directory:
+            service, repository, reader, source_file = self._build_service(
+                directory,
+                repository=FakeUserCollectionImportRepository(prepared_data=prepared_data),
+                reader=FakeOdsCollectionImportReader(import_data=import_data),
+            )
+
+            result = service.import_collection(
+                7,
+                str(source_file),
+                "collection.ods",
+                self._valid_description(),
+            )
+
+            self.assertEqual([], repository.import_calls)
+            self.assertTrue(result.refusal["refused"])
+            self.assertEqual(2, result.refusal["invalid_games_count"])
+            self.assertEqual(2, result.refusal["total_games_count"])
+            self.assertIn("2/2", result.refusal["message"])
+
+    def test_import_collection_rejects_file_when_no_read_game_is_associated(self):
+        """Verifie le refus quand les jeux lus sont tous ecartes au matching.
+
+        Args:
+            Aucun.
+
+        Returns:
+            None: Les assertions valident l'erreur fonctionnelle.
+        """
+
+        import_data = OdsCollectionImportData(
+            platforms=[],
+            studios=[],
+            games=[OdsCollectionImportGame("Ace combat 2", "Namco", None, None)],
+            warnings=CollectionImportWarnings(
+                skipped_games=[{
+                    "game_name": "Ace combat 2",
+                    "imported_platform": "Namco",
+                    "score": 0,
+                    "reason": "no_match",
+                }]
+            ),
+        )
+        with tempfile.TemporaryDirectory() as directory:
+            service, repository, reader, source_file = self._build_service(
+                directory,
+                repository=FakeUserCollectionImportRepository(
+                    result=UserCollectionImportPersistenceResult(
+                        linked_platforms=0,
+                        created_studios=0,
+                        created_games=0,
+                        associated_games=0,
+                    )
+                ),
+                reader=FakeOdsCollectionImportReader(import_data=import_data),
+            )
+
+            with self.assertRaises(UserCollectionImportInvalidFileError) as context:
+                service.import_collection(
+                    7,
+                    str(source_file),
+                    "collection.ods",
+                    self._valid_description(),
+                )
+
+            self.assertEqual(1, len(repository.import_calls))
+            self.assertEqual(1, len(reader.read_paths))
+            self.assertIn("Nom du jeu et Plateforme", context.exception.details[0])
+            self.assertIn("Namco", context.exception.details[0])
+
+    def test_import_collection_rejects_file_when_no_game_is_importable(self):
+        """Verifie le refus quand aucun jeu importable n'est lu.
+
+        Args:
+            Aucun.
+
+        Returns:
+            None: Les assertions valident l'erreur fonctionnelle.
+        """
+
+        import_data = OdsCollectionImportData(
+            platforms=[],
+            studios=[],
+            games=[],
+            warnings=CollectionImportWarnings(),
+        )
+        with tempfile.TemporaryDirectory() as directory:
+            service, repository, reader, source_file = self._build_service(
+                directory,
+                repository=FakeUserCollectionImportRepository(),
+                reader=FakeOdsCollectionImportReader(import_data=import_data),
+            )
+
+            with self.assertRaises(UserCollectionImportInvalidFileError) as context:
+                service.import_collection(
+                    7,
+                    str(source_file),
+                    "collection.ods",
+                    self._valid_description(),
+                )
+
+            self.assertEqual(0, len(repository.import_calls))
+            self.assertEqual(1, len(reader.read_paths))
+            self.assertIn("Aucun jeu importable", context.exception.details[0])
+            self.assertIn("Nom du jeu et Plateforme", context.exception.details[0])
+
     def test_import_collection_replaces_read_only_collection_file(self):
         """Verifie le remplacement du fichier collection deja verrouille.
 
diff --git a/backend/tests/by_module/services/users/test_user_collection_import_wishlist_result.py b/backend/tests/by_module/services/users/test_user_collection_import_wishlist_result.py
index a61d934..5b84782 100644
--- a/backend/tests/by_module/services/users/test_user_collection_import_wishlist_result.py
+++ b/backend/tests/by_module/services/users/test_user_collection_import_wishlist_result.py
@@ -59,6 +59,11 @@ def import_collection(
 
         return UserCollectionImportPersistenceResult(1, 1, 2, len(import_data.games))
 
+    def prepare_import_data_for_policy(self, import_data):
+        """Retourne les donnees sans preparation supplementaire."""
+
+        return import_data
+
 
 class FakePlatformWarningImportRepository(FakeImportRepository):
     """Simule une persistance ajoutant des warnings de matching plateforme."""
@@ -190,9 +195,12 @@ def test_import_result_contains_wishlist_count_and_warnings(self):
                 "invalid_wishlist": 1,
                 "invalid_wishlist_values_found": ["Peut etre"],
                 "invalid_games": [],
+                "skipped_mandatory_games": 0,
                 "platform_mappings": [],
                 "platform_matches": [],
                 "skipped_games": [],
+                "user_platform_matches": [],
+                "user_skipped_games": [],
                 "total_import_duration_seconds": result.warnings[
                     "total_import_duration_seconds"
                 ],
diff --git a/backend/tests/support/route_test_support.py b/backend/tests/support/route_test_support.py
index ca5fa19..7278679 100644
--- a/backend/tests/support/route_test_support.py
+++ b/backend/tests/support/route_test_support.py
@@ -479,6 +479,11 @@ def import_collection(
 
         return UserCollectionImportPersistenceResult(1, 2, 3, 4)
 
+    def prepare_import_data_for_policy(self, import_data):
+        """Retourne les donnees sans preparation supplementaire."""
+
+        return import_data
+
 
 class FakeUserCollectionImportService:
     """Service d'import factice."""
diff --git a/documentation/backend-api.md b/documentation/backend-api.md
index 6048dde..b7fa4b7 100644
--- a/documentation/backend-api.md
+++ b/documentation/backend-api.md
@@ -1212,6 +1212,7 @@ modify another user's collection.
 | --- | --- | --- |
 | `GET` | `/api/users/me/collection` | Returns whether the connected user already has an imported collection. |
 | `GET` | `/api/users/import/` | Returns the connected user's last saved import configuration. |
+| `GET` | `/api/users/import/invalid-value-help` | Returns detailed help for one refused optional import value. |
 | `POST` | `/api/users/import/file/` | Stores the connected user's temporary collection file. |
 | `POST` | `/api/users/import/analyze/` | Analyzes the temporary file and returns ODS sheet names or CSV column names. |
 | `POST` | `/api/users/import` | Imports the connected user's collection from the temporary file and JSON configuration. |
@@ -1221,6 +1222,7 @@ When a Library reset job is running, the backend rejects the import workflow
 routes that can read, write or reinitialize user import state:
 
 - `GET /api/users/import/`;
+- `GET /api/users/import/invalid-value-help`;
 - `POST /api/users/import/file/`;
 - `POST /api/users/import/analyze/`;
 - `POST /api/users/import`;
@@ -1267,6 +1269,43 @@ When no saved configuration exists, the backend returns:
 
 with status `404`.
 
+### Get Refused Import Value Help
+
+```http
+GET /api/users/import/invalid-value-help?field=region&value=Ici%20ou%20parla
+```
+
+The frontend uses this endpoint only when the user opens the detail view for a
+refused optional value in the import summary. The final import response must
+keep the summary compact and must not inline all detailed refusal explanations.
+
+Query parameters:
+
+- `field`: required import field key, for example `region`, `condition`,
+  `purchase_price` or `has_steelbook`;
+- `value`: optional raw value read from the imported file.
+
+Successful response:
+
+```json
+{
+  "field": "region",
+  "value": "Ici ou parla",
+  "reason": "La valeur ne correspond pas a une region ou version reconnue.",
+  "possible_values": ["ASIA", "AU", "CHN", "EU-DE", "EU-ES", "EU-FR"]
+}
+```
+
+`possible_values` contains the accepted user-facing values when the field is
+controlled, such as region/version, physical condition or boolean fields. It is
+empty when the rejected field has no finite accepted-value list.
+
+Errors use:
+
+- `400` when `field` is missing or unknown;
+- `403` when a Library reset blocks the import workflow;
+- `500` for unexpected failures.
+
 ### Upload User Collection File
 
 ```http
@@ -1382,7 +1421,9 @@ Every collection layout may map the nullable private fields `purchase_price`,
 `buy_location`, `buy_date`, `grade`, `condition`, `has_manual`, `is_collector`,
 `has_steelbook`, `is_digital`, `region` and `description`. Invalid non-empty
 values are ignored and reported in `warnings.invalid_games` without rejecting
-the complete import.
+the complete import. Each warning keeps the technical field key and raw value
+for backend diagnostics; frontend display must translate the field key through
+the centralized import field-label mapping before showing it to the user.
 `grade` keeps the original imported value, while `grade_normalized` persists
 the integer base-100 value rounded down. Values may be plain numbers using
 `rating_base`, or `/` strings such as `8/10`.
@@ -1469,6 +1510,7 @@ Successful response:
     ],
     "platform_matches": [],
     "skipped_games": [],
+    "skipped_mandatory_games": 0,
     "total_import_duration_seconds": 2.431
   }
 }
@@ -1486,8 +1528,16 @@ alias produced the retained match.
 `warnings.platform_matches` lists games imported with a platform score greater
 than or equal to `PLATFORM_MATCHING_LOW_LVL_RATING` and lower than
 `PLATFORM_MATCHING_HIGH_LEVEL_RATING`; they are imported but require manual
-administrator verification. `warnings.skipped_games` lists games ignored
-because the platform score is lower than `PLATFORM_MATCHING_LOW_LVL_RATING`.
+administrator verification. These warnings are non-blocking and are excluded
+from the rejected-game counter used to refuse an import file.
+`warnings.skipped_games` lists games ignored because the platform score is
+lower than `PLATFORM_MATCHING_LOW_LVL_RATING`.
+`warnings.skipped_mandatory_games` counts rows skipped because a mandatory game
+name or platform is missing.
+The rejected-game counter used for import refusal includes games with invalid
+optional values, skipped low-platform-score games and rows skipped for missing
+mandatory values. It does not include games only waiting for administrator
+platform validation.
 `warnings.total_import_duration_seconds` contains the total backend import
 duration in seconds, measured around file validation, optional workspace copy,
 file reading, matching and SQL persistence.
@@ -1549,9 +1599,16 @@ errors when the rebuild is partial.
 The same address receives exactly one report after each user collection import
 when the import reaches its final backend step. This report is sent outside the
 file reader layer and does not depend on the imported file type. It is sent even
-when the import has no warning and includes the user import context, counters,
-validated import configuration, total duration, platform mappings and every
-import warning.
+when the import has no warning and includes the user import context, import
+counters, error counters, platform values waiting for administrator validation,
+validated import configuration, total duration and diagnostic studio/game
+tables. The platform-validation section appears immediately after the error
+counters and maps each platform value from the file to the matched catalog
+platform awaiting validation. The validated configuration is shown as formatted
+colored JSON. Diagnostic tables use color to highlight created rows,
+administrator-verification rows and refused decisions. The report must not add
+an unformatted raw `Warnings` section when the same information is already
+available in structured HTML sections.
 
 The backend also runs a daily duplicate-game check at
 `GAME_DUPLICATE_DAILY_NOTIFICATION_TIME`, using local `HH:MM` format and
diff --git a/documentation/frontend-arch.md b/documentation/frontend-arch.md
index 5825eec..55d83c9 100644
--- a/documentation/frontend-arch.md
+++ b/documentation/frontend-arch.md
@@ -109,7 +109,18 @@ Use the following domain folders for new or modified hooks:
   confirms `has_collection: true` outside the just-finished import workflow.
 - Reuse the same onboarding hook and route for additive imports opened from
   Configuration. The Configuration page only triggers navigation; it must not
-  own file upload, analysis, validation or persistence state.
+  own file upload, analysis, validation or persistence state. Before opening
+  the route from Configuration, the hook must reset the current import page
+  state so the user sees a fresh form instead of the previous report.
+- Render import warnings with user-facing labels and grouped summaries. Invalid
+  optional values are grouped by field, refused value and refusal reason; the
+  game names are shown as a list using the same warning color as the refused
+  value. Platform warnings are grouped by platform value from the user's file
+  and displayed as non-blocking administrator validations, separate from
+  rejected games.
+- Fetch invalid-value explanations lazily with
+  `GET /api/users/import/invalid-value-help` when the user opens the detail
+  control. The same control must close the detail when clicked again.
 - Own the connected-user collection reinitialization workflow in a dedicated
   hook separate from onboarding. The hook calls
   `POST /api/users/collection/reinit`, refreshes collection signals and opens
diff --git a/documentation/import-mapping.md b/documentation/import-mapping.md
index c8fc6e7..b5b330a 100644
--- a/documentation/import-mapping.md
+++ b/documentation/import-mapping.md
@@ -17,6 +17,15 @@ reader architecture rules in `documentation/reader.md`.
   configured columns that are entirely empty or absent at the end of a sheet.
 - Invalid non-empty optional values become `None` and are appended to
   `warnings.invalid_games`; they do not reject the game or complete import.
+- Invalid optional-value warnings keep technical keys for backend processing,
+  but user-facing summaries must display the label chosen during import
+  configuration through the centralized frontend field-label mapping.
+- Detailed invalid-value help is served by
+  `GET /api/users/import/invalid-value-help`. Controlled fields such as
+  `region`, `condition` and boolean fields must expose their possible accepted
+  values there when available, so the final import response stays compact.
+- Rows skipped because a mandatory game name or platform is missing are counted
+  as rejected games for the import refusal policy.
 - Text cleaning uses `SheetValueFormatter.clean_text`: spreadsheet null/error
   values and blank text become `None`; other text is trimmed.
 - Matching uses normalized lowercase, accent-free text. Matching scores are
@@ -109,6 +118,9 @@ becomes `80`.
 - A unique platform score at or above `PLATFORM_MATCHING_HIGH_LEVEL_RATING` is accepted;
   a score from `PLATFORM_MATCHING_LOW_LVL_RATING` up to the high threshold is accepted
   with a manual-check warning; a lower or ambiguous score skips affected games.
+  Manual-check platform warnings are non-blocking: affected games are imported,
+  displayed as waiting for administrator validation and excluded from the
+  rejected-game counter.
 - Studios are scored against existing normalized `t_studio.name` values without
   alias lookup. A unique studio score at or above
   `STUDIO_MATCHING_HIGH_LEVEL_RATING` (default `87`) is reused; otherwise the
diff --git a/documentation/import.md b/documentation/import.md
index f276efe..175462a 100644
--- a/documentation/import.md
+++ b/documentation/import.md
@@ -16,7 +16,9 @@ database structure in `documentation/database.md`, and frontend navigation in
   to `/collection`.
 - From the Configuration page, a connected `USER` with collection access can
   open `/collection/import` to add games from a new file without
-  reinitializing the current collection.
+  reinitializing the current collection. This action must clear the frontend
+  import state, including selected file, analysis result, form values and the
+  previous import report, before opening the import page.
 - The frontend must call `GET /api/users/me/collection` to decide between those
   two paths.
 - The import page only collects the user collection file and displays
@@ -32,6 +34,15 @@ database structure in `documentation/database.md`, and frontend navigation in
 - After a successful import, the frontend displays an import summary using the
   backend counters and offers a link to `/collection`; it must not redirect
   automatically.
+- The import summary must separate blocking rejected games from non-blocking
+  platform checks. Games waiting for administrator platform validation are
+  imported, displayed as an administrator check, and excluded from the blocking
+  error counter used to reject the file.
+- User-facing import warnings must use clear business wording. Technical
+  matching details remain available in administrator emails. Invalid optional
+  values are displayed with the import-configuration field label and a simple
+  refused-value message; detailed refusal reasons and possible values are
+  fetched only when the user asks for more information.
 - From the Configuration page, a connected `USER` with collection access can
   reinitialize the current collection. After a successful reinitialization, the
   frontend redirects to `/collection/import` so the user can import a new file.
@@ -53,6 +64,10 @@ database structure in `documentation/database.md`, and frontend navigation in
   response field.
 - `GET /api/users/import/` returns the last saved import configuration, or
   `404` when none exists.
+- `GET /api/users/import/invalid-value-help` returns the detailed reason and
+  possible accepted values for a refused optional value. The summary must call
+  this endpoint lazily when the user opens the detail view, not include every
+  detail in the final import payload.
 - `POST /api/users/import` must use `application/json` and receives only the
   import configuration, including a mandatory top-level `wishlist` section.
 - The import configuration may contain a global `price_unit`. It is mandatory
@@ -157,9 +172,13 @@ database structure in `documentation/database.md`, and frontend navigation in
   without manual-verification warning.
 - Scores greater than or equal to `PLATFORM_MATCHING_LOW_LVL_RATING` and lower than
   `PLATFORM_MATCHING_HIGH_LEVEL_RATING` are imported and reported in
-  `warnings.platform_matches` for administrator verification.
+  `warnings.platform_matches` for administrator verification. These platform
+  warnings are non-blocking: affected games are imported and must not be counted
+  as rejected games.
 - Scores lower than `PLATFORM_MATCHING_LOW_LVL_RATING`, including `0`, skip the impacted
   games and report them in `warnings.skipped_games`.
+- Rows without a usable mandatory game name or platform are skipped and counted
+  in the blocking rejected-game counter.
 - The import warnings keep a `platform_mappings` list with the imported platform
   name, matched platform name, matching score, imported game count and alias
   usage flag for every platform read from the file.
@@ -168,17 +187,25 @@ database structure in `documentation/database.md`, and frontend navigation in
 - At the end of each import, the backend sends exactly one administrator report
   when `ADMIN_NOTIFICATION_EMAIL` is configured, even when the import has no
   warning. The report is sent outside the reader layer as an HTML email and
-  includes the import context, counters, validated configuration, total
-  duration, platform mappings and every import warning. The studio section is
-  an HTML table listing every imported studio with the original studio name,
-  whether a reference studio was created, the associated existing studio when
-  one was accepted and the matching score. The game section is an HTML table
-  listing every imported game with the original file name, whether a reference
-  game was created, the associated existing game when one was accepted, the
-  final matching score, the matching decision, the applied rule and the
-  explanatory reason. When a game reference is created because no exact or
+  includes the import context, counters, error counters, the platform values to
+  validate, validated configuration, total duration and diagnostic tables. The
+  platform-validation section must appear immediately after the error counters
+  and list each imported file platform value, the matched catalog platform and
+  the games waiting for administrator validation. The validated configuration is
+  formatted as readable colored JSON, not as a compact raw string. The studio
+  section is an HTML table listing every imported studio with the original
+  studio name, whether a reference studio was created, the associated existing
+  studio when one was accepted and the matching score. The game section is an
+  HTML table listing every imported game with the original file name, whether a
+  reference game was created, the associated existing game when one was
+  accepted, the final matching score, the matching decision, the applied rule
+  and the explanatory reason. Tables use visual state colors: created elements
+  in light green, values requiring verification in light orange, and refused
+  decisions in light red. When a game reference is created because no exact or
   high-confidence existing game match was accepted, the same diagnostic table
-  keeps the best existing same-platform candidate score and explanation.
+  keeps the best existing same-platform candidate score and explanation. The
+  email must not include an unformatted raw `Warnings` block when the same data
+  is already represented by structured sections.
 - Studios are matched by normalized studio name with the configurable studio
   matching rules from `documentation/import-mapping.md`.
 - Games are first matched by exact normalized `(platform, name)` key. When no
diff --git a/documentation/site-plan.md b/documentation/site-plan.md
index e9da52a..e01a6b2 100644
--- a/documentation/site-plan.md
+++ b/documentation/site-plan.md
@@ -75,7 +75,9 @@ public pages.
 - `/collection/import`: authenticated onboarding/import page shown when
   `GET /api/users/me/collection` returns `has_collection: false` for a
   non-`ADMIN` user, and reachable from Configuration when the same user already
-  has a collection and wants to add games from another file.
+  has a collection and wants to add games from another file. When opened from
+  Configuration, the page must start from a fresh import form and must not show
+  the previous import report.
 - `/configuration`: authenticated Configuration page for protected application
   actions.
 - `/configuration/partages`: authenticated owner page for creating, copying,
diff --git a/frontend/src/components/ImportCollapsibleSection.jsx b/frontend/src/components/ImportCollapsibleSection.jsx
new file mode 100644
index 0000000..4bf467a
--- /dev/null
+++ b/frontend/src/components/ImportCollapsibleSection.jsx
@@ -0,0 +1,38 @@
+/*
+ *   ____ _                 _  ____      _ _           _   _             ___
+ *  / ___| | ___  _   _  __| |/ ___|___ | | | ___  ___| |_(_) ___  _ __ / _ \ _ __  _ __
+ * | |   | |/ _ \| | | |/ _` | |   / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ |
+ * | |___| | (_) | |_| | (_| | |__| (_) | | |  __/ (__| |_| | (_) | | | | |_| | |_) | |_) |
+ *  \____|_|\___/ \__,_|\__,_|\____\___/|_|_|\___|\___|\__|_|\___/|_| |_|\___/|_| |_|
+ * Projet : CloudCollectionApp
+ * Date de creation : 2026-08-18
+ * Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien
+ * Licence : Apache 2.0
+ *
+ * Description : section repliable reutilisable du formulaire d'import.
+ */
+
+/**
+ * Affiche une section repliable de formulaire d'import.
+ *
+ * @param {Object} props - Propriétés de la section.
+ * @param {string} props.title - Titre visible de la section.
+ * @param {string} props.description - Résumé court de la section.
+ * @param {boolean} props.defaultOpen - Indique si la section est ouverte par défaut.
+ * @param {import("react").ReactNode} props.children - Contenu de la section.
+ * @returns {import("react").JSX.Element} Section repliable.
+ * @throws {void} Ne lève pas d'exception.
+ */
+function ImportCollapsibleSection({ title, description, defaultOpen = true, children }) {
+  return (
+    
+ + {title} + {description} + +
{children}
+
+ ); +} + +export default ImportCollapsibleSection; diff --git a/frontend/src/components/ImportConfigurationFields.jsx b/frontend/src/components/ImportConfigurationFields.jsx index 1785e81..a453929 100644 --- a/frontend/src/components/ImportConfigurationFields.jsx +++ b/frontend/src/components/ImportConfigurationFields.jsx @@ -12,12 +12,17 @@ * * Description : champs de configuration d'import de collection. */ +import { useState } from "react"; import ImportLayoutFields from "./ImportLayoutFields"; import ImportCsvConfigurationFields from "./ImportCsvConfigurationFields"; +import ImportCollapsibleSection from "./ImportCollapsibleSection"; +import { ImportGlobalOptions } from "./ImportGlobalOptions"; +import ImportSpreadsheetWishlistFields from "./ImportSpreadsheetWishlistFields"; import { collectionColumnFields, - wishlistSheetColumnFields, + collectionRequiredFields, } from "../hooks/collection/importConfigurationBuilder"; +import { hasSpreadsheetImportColumn } from "../hooks/collection/importGlobalOptionsVisibility"; /** * Affiche les champs frontend de configuration d'import. @@ -56,211 +61,205 @@ function ImportConfigurationFields({ } const columnFields = collectionColumnFields(configuration, !configuration.multipleSheets); + const showPriceUnit = hasSpreadsheetImportColumn(configuration, "purchase_price"); + const showRatingBase = hasSpreadsheetImportColumn(configuration, "grade"); return ( -
- Configuration du fichier -

* Champs obligatoires

- - - -
+ + + - - - + + {showPriceUnit || showRatingBase ? ( + + + + ) : null} + + ); +} -
- Multiple onglets +/** + * Affiche le choix des onglets a importer avec une configuration partagee. + * + * @param {Object} props - Etat et callbacks de selection des onglets. + * @returns {import("react").JSX.Element} Champs de selection des onglets. + * @throws {void} Ne leve pas d'exception. + */ +function SheetSelectionFields({ availableSheetNames, configuration, onLayoutChange }) { + const selectionMode = configuration.sharedSheetLayout.sheetSelectionMode; + return ( + <> +
+ Onglets à importer + +

+ Sélectionnez uniquement les onglets contenant les jeux de votre collection. + Un onglet dédié à la liste de souhaits doit être exclu ici, puis configuré + dans la section Liste de souhaits. +

- - {!configuration.multipleSheets ? ( - onLayoutChange( - "singleSheetLayout", - fieldName, - value - )} - onLayoutColumnChange={(fieldName, value) => onLayoutColumnChange( - "singleSheetLayout", - fieldName, - value - )} - /> - ) : ( - + {selectionMode === "excluded" + ? "Onglets exclus" + : "Onglets inclus"} + + + {selectionMode === "excluded" + ? "Listez les onglets à ignorer, notamment un onglet dédié à la liste de souhaits. Laissez vide pour importer tous les onglets détectés." + : "Listez seulement les onglets qui contiennent les jeux de votre collection, sans l'onglet dédié à la liste de souhaits."} + + )} - + ); } /** - * Affiche la configuration wishlist commune aux modes d'import. + * Affiche la section de structure du fichier tableur. * - * @param {Object} props - Etat wishlist et callbacks. - * @returns {import("react").JSX.Element} Champs wishlist. + * @param {Object} props - Etat de structure et callbacks. + * @returns {import("react").JSX.Element} Champs de structure. + * @throws {void} Ne leve pas d'exception. */ -function WishlistFields({ +function FileStructureFields({ configuration, + disabled, availableSheetNames, - onWishlistConfigurationChange, - onWishlistLayoutChange, - onWishlistLayoutColumnChange, + onConfigurationChange, + onLayoutChange, }) { + const singleSheetName = availableSheetNames.length === 1 ? availableSheetNames[0] : ""; return ( -
-
- Wishlist - {["none", "sheet", "column"].map((mode) => ( - - ))} -
- {configuration.wishlist.mode === "sheet" ? ( +
+ Structure du fichier + {configuration.multipleSheets ? ( <> - - ) : null} -
- ); -} - -const modeLabels = Object.freeze({ - none: "Aucune", - sheet: "Onglet dedie", - column: "Colonne", -}); - -/** - * Affiche une selection d'onglet simple. - * - * @param {Object} props - Valeur courante, options et callback. - * @returns {import("react").JSX.Element} Champ onglet. - */ -function SheetNameField({ value, availableSheetNames, onChange }) { - if (availableSheetNames.length) { - return ( - - ); - } - return ( - onChange(event.target.value)} - /> + ) : ( +

+ Aucune configuration de structure n'est nécessaire car le fichier ne contient qu'un seul onglet + {singleSheetName ? ` : ${singleSheetName}` : ""}. +

+ )} + ); } /** - * Affiche les champs propres aux modes multi-onglets. + * Affiche les champs de colonnes propres aux modes multi-onglets. * * @param {Object} props - Etat multi-onglets et callbacks. - * @returns {import("react").JSX.Element} Champs multi-onglets. + * @returns {import("react").JSX.Element} Champs de colonnes multi-onglets. + * @throws {void} Ne leve pas d'exception. */ -function MultipleSheetsFields({ +function MultipleSheetsLayoutFields({ configuration, - availableSheetNames, onConfigurationChange, onLayoutChange, onLayoutColumnChange, onSheetChange, onSheetLayoutChange, onSheetColumnChange, - onAddSheet, - onRemoveSheet, }) { return ( <> -
- Memes plages sur chaque onglet + Mêmes plages sur chaque onglet +

+ Oui : configurez une seule plage de données et les mêmes colonnes pour tous les onglets + importés. Non : configurez séparément la plage et les colonnes de chaque onglet. +

{configuration.sharedLayout ? ( - <> -
- Selection des onglets - - -
- - onLayoutChange( - "sharedSheetLayout", - fieldName, - value - )} - onLayoutColumnChange={(fieldName, value) => onLayoutColumnChange( - "sharedSheetLayout", - fieldName, - value - )} - /> - + onLayoutChange( + "sharedSheetLayout", + fieldName, + value + )} + onLayoutColumnChange={(fieldName, value) => onLayoutColumnChange( + "sharedSheetLayout", + fieldName, + value + )} + /> ) : ( )} @@ -364,9 +327,11 @@ function SheetSelectionField({ availableSheetNames, configuration, onLayoutChang const value = configuration.sharedSheetLayout[fieldName]; if (availableSheetNames.length) { const selectedValues = Array.isArray(value) ? value : splitSheetNames(value); + const visibleRows = availableSheetNames.length > 4 ? 8 : 4; return ( onSheetChange(index, "sheetName", event.target.value)} - /> - - onSheetLayoutChange(index, fieldName, value)} - onLayoutColumnChange={(fieldName, value) => onSheetColumnChange(index, fieldName, value)} - /> - - ))} - +
+ {sheets.map((sheet, index) => ( + + ))} +
+
+
+

{activeSheet.sheetName || `Onglet ${activeIndex + 1}`}

+
+ onSheetChange(activeIndex, "sheetName", event.target.value)} /> + onSheetLayoutChange(activeIndex, fieldName, value)} + onLayoutColumnChange={(fieldName, value) => onSheetColumnChange(activeIndex, fieldName, value)} + /> +
); } diff --git a/frontend/src/components/ImportCsvConfigurationFields.jsx b/frontend/src/components/ImportCsvConfigurationFields.jsx index 056e2cf..ccabbbf 100644 --- a/frontend/src/components/ImportCsvConfigurationFields.jsx +++ b/frontend/src/components/ImportCsvConfigurationFields.jsx @@ -17,7 +17,11 @@ import { OPTIONAL_CSV_FIELDS, REQUIRED_CSV_FIELDS, } from "../hooks/collection/csvImportConfigurationBuilder"; -import { FIELD_LABELS } from "./ImportLayoutFields"; +import { IMPORT_FIELD_LABELS } from "../hooks/collection/importFieldLabels"; +import { hasCsvImportColumn } from "../hooks/collection/importGlobalOptionsVisibility"; +import { ImportGlobalOptions } from "./ImportGlobalOptions"; +import ImportCollapsibleSection from "./ImportCollapsibleSection"; +import ImportFieldHelp from "./ImportFieldHelp"; /** * Affiche les champs frontend de configuration CSV. @@ -34,35 +38,88 @@ function ImportCsvConfigurationFields({ onWishlistConfigurationChange, }) { const requiredFields = csvRequiredFields(configuration); + const showPriceUnit = hasCsvImportColumn(configuration, "purchase_price"); + const showRatingBase = hasCsvImportColumn(configuration, "grade"); return ( -
- Configuration du fichier -

* Champs obligatoires

- - - - + + {showPriceUnit || showRatingBase ? ( + + + + ) : null} + + ); +} -
- Wishlist +/** + * Affiche la configuration de liste de souhaits pour un import CSV. + * + * @param {Object} props - Etat CSV et callbacks wishlist. + * @returns {import("react").JSX.Element} Section liste de souhaits CSV. + * @throws {void} Ne leve pas d'exception. + */ +function CsvWishlistFields({ + configuration, + availableColumnNames, + disabled, + requiredFields, + onCsvMappingChange, + onWishlistConfigurationChange, +}) { + return ( +
+ Liste de souhaits +

+ Indiquez si une colonne du CSV signale les jeux à placer dans votre liste + de souhaits. Sans colonne dédiée, toutes les lignes importées sont ajoutées + à votre collection. +

+
+ Source {["none", "column"].map((mode) => ( ))}
- -
- {[...REQUIRED_CSV_FIELDS, ...OPTIONAL_CSV_FIELDS, "wishlist"].map((fieldName) => { - if (fieldName === "wishlist" && configuration.wishlist.mode !== "column") { - return null; - } - return ( - - ); - })} -
+ {configuration.wishlist.mode === "column" ? ( + + ) : null}
); } @@ -108,7 +158,7 @@ function ColumnNameField({ required, value, availableColumnNames, onChange }) { if (availableColumnNames.length) { return ( onConfigurationChange("priceUnit", event.target.value)} + > + {["EUR", "USD", "GBP", "JPY", "AUD", "CAD", "CHF", "CNY", "KRW"].map( + (unit) => + )} + + + Devise appliquée aux prix d'achat importés. Aucune conversion n'est effectuée. + + + ) : null} + + {showRatingBase ? ( + + ) : null} + + ); +} + +export { ImportGlobalOptions }; diff --git a/frontend/src/components/ImportLayoutFields.jsx b/frontend/src/components/ImportLayoutFields.jsx index 2103b22..7e7c982 100644 --- a/frontend/src/components/ImportLayoutFields.jsx +++ b/frontend/src/components/ImportLayoutFields.jsx @@ -13,24 +13,8 @@ * Description : champs reutilisables de layout tableur pour les imports. */ -const FIELD_LABELS = Object.freeze({ - name: "Nom du jeu", - platform: "Plateforme", - studio: "Studio", - release_date: "Date de sortie", - wishlist: "Wishlist", - purchase_price: "Prix d'achat", - buy_location: "Lieu d'achat", - buy_date: "Date d'achat", - grade: "Note", - condition: "Etat", - has_manual: "Notice", - is_collector: "Collector", - has_steelbook: "Steelbook", - is_digital: "Version digitale", - region: "Region", - description: "Description", -}); +import { IMPORT_FIELD_LABELS } from "../hooks/collection/importFieldLabels"; +import ImportFieldHelp from "./ImportFieldHelp"; /** * Affiche un layout tableur configurable. @@ -49,38 +33,53 @@ function ImportLayoutFields({ return (
- {columnFields.map((fieldName) => ( - - ))} + {columnFields.map((fieldName) => { + const isRequired = requiredFields.includes(fieldName); + return ( + + ); + })}
); } -export { FIELD_LABELS }; export default ImportLayoutFields; diff --git a/frontend/src/components/ImportSpreadsheetWishlistFields.jsx b/frontend/src/components/ImportSpreadsheetWishlistFields.jsx new file mode 100644 index 0000000..5f3d008 --- /dev/null +++ b/frontend/src/components/ImportSpreadsheetWishlistFields.jsx @@ -0,0 +1,105 @@ +/* + * ____ _ _ ____ _ _ _ _ ___ + * / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ + * | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | + * | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | + * \____|_|\___/ \__,_|\__,_|\____\___/|_|_|\___|\___|\__|_|\___/|_| |_|\___/|_| |_| + * Projet : CloudCollectionApp + * Date de creation : 2026-08-18 + * Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien + * Licence : Apache 2.0 + * + * Description : configuration liste de souhaits pour les imports tableur. + */ + +import { wishlistSheetColumnFields } from "../hooks/collection/importConfigurationBuilder"; +import ImportLayoutFields from "./ImportLayoutFields"; + +const modeLabels = Object.freeze({ + none: "Aucune", + sheet: "Onglet dédié", + column: "Colonne dédiée", +}); + +/** + * Affiche la configuration liste de souhaits commune aux modes tableur. + * + * @param {Object} props - Etat liste de souhaits et callbacks. + * @returns {import("react").JSX.Element} Champs liste de souhaits. + * @throws {void} Ne leve pas d'exception. + */ +function ImportSpreadsheetWishlistFields({ + configuration, + availableSheetNames, + disabled, + onWishlistConfigurationChange, + onWishlistLayoutChange, + onWishlistLayoutColumnChange, +}) { + return ( +
+ Liste de souhaits +

+ Indiquez comment reconnaître les jeux qui doivent aller dans votre liste + de souhaits. Sans source dédiée, toutes les lignes importées sont ajoutées + à votre collection. +

+
+ Source + {["none", "sheet", "column"].map((mode) => ( + + ))} +
+ {configuration.wishlist.mode === "sheet" ? ( + <> + + + + ) : null} +
+ ); +} + +/** + * Affiche une selection d'onglet simple. + * + * @param {Object} props - Valeur courante, options et callback. + * @returns {import("react").JSX.Element} Champ onglet. + * @throws {void} Ne leve pas d'exception. + */ +function SheetNameField({ value, availableSheetNames, onChange }) { + if (availableSheetNames.length) { + return ( + + ); + } + return onChange(event.target.value)} />; +} + +export default ImportSpreadsheetWishlistFields; diff --git a/frontend/src/components/ImportSummary.jsx b/frontend/src/components/ImportSummary.jsx index dfd9b0b..d0ea752 100644 --- a/frontend/src/components/ImportSummary.jsx +++ b/frontend/src/components/ImportSummary.jsx @@ -12,6 +12,10 @@ * * Description : resume reutilisable des imports utilisateur et admin. */ +import { useState } from "react"; + +import { getImportFieldLabel } from "../hooks/collection/importFieldLabels"; +import UserCollectionApi from "../services/UserCollectionApi"; /** * Affiche le resume d'un import termine. @@ -32,22 +36,28 @@ function ImportSummary({ const totalImportDuration = formatImportDuration( result.warnings?.total_import_duration_seconds ); + const invalidGamesCount = Number(refusal.invalid_games_count || 0); const displayedCounters = counters || [ ["Plateformes liees", result.linked_platforms], ["Studios crees", result.created_studios], ["Jeux crees", result.created_games], ["Jeux associes", result.associated_games], - ["Souhaits importes", result.wishlisted_games], + ["Jeux en liste de souhaits", result.wishlisted_games], ["Duree totale", totalImportDuration], ]; const invalidWishlist = result.warnings?.invalid_wishlist || 0; const invalidGames = Array.isArray(result.warnings?.invalid_games) ? result.warnings.invalid_games : []; - const platformMatches = Array.isArray(result.warnings?.platform_matches) + const platformMatches = Array.isArray(result.warnings?.user_platform_matches) + ? result.warnings.user_platform_matches + : Array.isArray(result.warnings?.platform_matches) ? result.warnings.platform_matches : []; - const skippedGames = Array.isArray(result.warnings?.skipped_games) + const platformMatchesCount = platformMatches.length; + const skippedGames = Array.isArray(result.warnings?.user_skipped_games) + ? result.warnings.user_skipped_games + : Array.isArray(result.warnings?.skipped_games) ? result.warnings.skipped_games : []; return ( @@ -60,6 +70,18 @@ function ImportSummary({
{value}
))} + {invalidGamesCount > 0 ? ( +
+
Jeux avec erreur
+
{formatInvalidGamesRatio(refusal)}
+
+ ) : null} + {platformMatchesCount > 0 ? ( +
+
Jeux à vérifier
+
{platformMatchesCount}
+
+ ) : null} {isRefused ? (

{formatImportRefusalMessage(refusal)}

@@ -69,7 +91,7 @@ function ImportSummary({ ) : null} {invalidWishlist ? (

- {invalidWishlist} ligne(s) wishlist ignoree(s). + {invalidWishlist} ligne(s) de liste de souhaits ignorée(s).

) : null} {invalidGames.length > 0 ? ( @@ -130,6 +152,19 @@ function formatImportDuration(durationSeconds) { return `${minutes} min ${seconds.toString().padStart(2, "0")} s`; } +/** + * Formate le nombre de jeux contenant une erreur sur le total lu. + * + * @param {Object} refusal - Decision de refus ou compteurs d'erreurs d'import. + * @returns {string} Ratio lisible pour le resume d'import. + * @throws {void} Ne leve pas d'exception. + */ +function formatInvalidGamesRatio(refusal) { + const invalidGamesCount = Number(refusal.invalid_games_count || 0); + const totalGamesCount = Number(refusal.total_games_count || 0); + return `${invalidGamesCount}/${totalGamesCount}`; +} + /** * Affiche les plateformes rattachees avec verification manuelle. * @@ -138,14 +173,25 @@ function formatImportDuration(durationSeconds) { * @throws {void} Ne leve pas d'exception. */ function PlatformMatchWarningsList({ platformMatches }) { + const groupedPlatformMatches = groupPlatformWarningsByPlatformAndCause(platformMatches); return (
-

Plateformes a verifier

+

Plateformes à vérifier par un admin

    - {platformMatches.map((warning) => ( -
  • - {warning.game_name} - {formatPlatformMatchWarning(warning)} + {groupedPlatformMatches.map((group) => ( +
  • + Plateforme dans votre fichier : {group.importedPlatform} + + Statut : ces jeux sont importés, mais la plateforme doit être validée + par un admin. + + Raison : {formatPlatformRefusal(group.warning)} + Jeux en attente de validation admin pour cette plateforme : +
      + {group.games.map((gameName) => ( +
    • {gameName}
    • + ))} +
  • ))}
@@ -153,6 +199,31 @@ function PlatformMatchWarningsList({ platformMatches }) { ); } +/** + * Regroupe les avertissements de plateforme par plateforme importee et cause. + * + * @param {Array} platformWarnings - Plateformes a verifier retournees par l'API. + * @returns {Array} Groupes affichables par l'IHM. + * @throws {void} Ne leve pas d'exception. + */ +function groupPlatformWarningsByPlatformAndCause(platformWarnings) { + const groupsByKey = new Map(); + platformWarnings.forEach((warning) => { + const importedPlatform = warning.imported_platform || "-"; + const cause = warning.message || `${warning.matched_platform || "-"}-${warning.score || 0}`; + const key = `${importedPlatform}-${cause}`; + const group = groupsByKey.get(key) || { + key, + importedPlatform, + warning, + games: [], + }; + group.games.push(warning.game_name || "-"); + groupsByKey.set(key, group); + }); + return Array.from(groupsByKey.values()); +} + /** * Affiche les jeux ignores faute de plateforme fiable. * @@ -161,14 +232,21 @@ function PlatformMatchWarningsList({ platformMatches }) { * @throws {void} Ne leve pas d'exception. */ function SkippedGamesWarningsList({ skippedGames }) { + const groupedSkippedGames = groupSkippedGamesByPlatformAndCause(skippedGames); return ( -
-

Jeux ignores

+
+

Jeux non importés

+ Vous pouvez corriger votre fichier puis le réimporter pour corriger ces erreurs.
    - {skippedGames.map((warning) => ( -
  • - {warning.game_name} - {formatSkippedGameWarning(warning)} + {groupedSkippedGames.map((group) => ( +
  • + {group.importedPlatform} + {formatSkippedPlatformRefusal(group.warning)} +
      + {group.games.map((gameName) => ( +
    • {gameName}
    • + ))} +
  • ))}
@@ -176,6 +254,31 @@ function SkippedGamesWarningsList({ skippedGames }) { ); } +/** + * Regroupe les jeux ignores par plateforme importee et cause de refus. + * + * @param {Array} skippedGames - Jeux ignores retournes par l'API. + * @returns {Array} Groupes affichables par l'IHM. + * @throws {void} Ne leve pas d'exception. + */ +function groupSkippedGamesByPlatformAndCause(skippedGames) { + const groupsByKey = new Map(); + skippedGames.forEach((warning) => { + const importedPlatform = warning.imported_platform || "-"; + const cause = warning.message || warning.reason || ""; + const key = `${importedPlatform}-${cause}`; + const group = groupsByKey.get(key) || { + key, + importedPlatform, + warning, + games: [], + }; + group.games.push(warning.game_name || "-"); + groupsByKey.set(key, group); + }); + return Array.from(groupsByKey.values()); +} + /** * Affiche les jeux importes avec des informations invalides ignorees. * @@ -184,14 +287,62 @@ function SkippedGamesWarningsList({ skippedGames }) { * @throws {void} Ne leve pas d'exception. */ function InvalidImportedGamesList({ invalidGames }) { + const [detailsByFieldKey, setDetailsByFieldKey] = useState({}); + const groupedInvalidFields = groupInvalidFieldsByField(invalidGames); + + async function toggleInvalidFieldDetails(fieldGroup) { + const fieldKey = invalidFieldGroupKey(fieldGroup.field); + const currentDetails = detailsByFieldKey[fieldKey]; + if (currentDetails?.isLoading) { + return; + } + if (currentDetails?.data || currentDetails?.error) { + setDetailsByFieldKey((currentValues) => ({ + ...currentValues, + [fieldKey]: { + ...currentDetails, + isOpen: !currentDetails.isOpen, + }, + })); + return; + } + setDetailsByFieldKey((currentValues) => ({ + ...currentValues, + [fieldKey]: { isLoading: true, isOpen: true, data: null, error: "" }, + })); + try { + const data = await UserCollectionApi.fetchImportInvalidValueHelp( + fieldGroup.field, + fieldGroup.sampleValue || "", + ); + setDetailsByFieldKey((currentValues) => ({ + ...currentValues, + [fieldKey]: { isLoading: false, isOpen: true, data, error: "" }, + })); + } catch (error) { + setDetailsByFieldKey((currentValues) => ({ + ...currentValues, + [fieldKey]: { + isLoading: false, + isOpen: true, + data: null, + error: error?.message || "Impossible de charger le détail.", + }, + })); + } + } + return ( -
-

Informations ignorees

+
+

Informations ignorées

    - {invalidGames.map((gameWarning) => ( -
  • - {gameWarning.name} - {formatInvalidFields(gameWarning.invalid_fields || [])} + {groupedInvalidFields.map((fieldGroup) => ( +
  • +
  • ))}
@@ -200,41 +351,132 @@ function InvalidImportedGamesList({ invalidGames }) { } /** - * Formate les champs invalides d'un jeu pour affichage. + * Regroupe les informations ignorees par champ refuse. + * + * @param {Array} invalidGames - Jeux avec champs invalides retournes par l'API. + * @returns {Array} Groupes affichables par champ refuse. + * @throws {void} Ne leve pas d'exception. + */ +function groupInvalidFieldsByField(invalidGames) { + const groupsByKey = new Map(); + invalidGames.forEach((gameWarning) => { + (gameWarning.invalid_fields || []).forEach((fieldWarning) => { + const field = fieldWarning.field || ""; + const key = invalidFieldGroupKey(field); + const group = groupsByKey.get(key) || { + key, + field, + sampleValue: fieldWarning.value || "", + games: [], + }; + group.games.push({ + gameName: gameWarning.name || "-", + value: fieldWarning.value || "", + }); + groupsByKey.set(key, group); + }); + }); + return Array.from(groupsByKey.values()); +} + +/** + * Affiche un groupe de valeurs refusees et son aide chargee a la demande. + * + * @param {Object} props - Groupe invalide et etat d'aide. + * @returns {import("react").JSX.Element} Groupe invalide avec bouton de detail. + * @throws {void} Ne leve pas d'exception. + */ +function InvalidFieldGroupWarning({ details, fieldGroup, onToggleDetails }) { + const isOpen = Boolean(details?.isOpen); + return ( +
+ Champ ignoré : {formatInvalidFieldLabel(fieldGroup.field)} + Valeurs refusées dans votre fichier : +
    + {fieldGroup.games.map((game) => ( +
  • + {game.gameName}: "{game.value}" Valeur refusée +
  • + ))} +
+ + {isOpen && details?.data ? : null} + {isOpen && details?.error ? ( + {details.error} + ) : null} +
+ ); +} + +/** + * Affiche la raison d'un refus et les valeurs possibles lorsqu'elles existent. * - * @param {Array} invalidFields - Champs invalides retournes par l'API. - * @returns {string} Description courte des champs invalides. + * @param {Object} props - Aide retournee par le backend. + * @returns {import("react").JSX.Element} Detail du refus. * @throws {void} Ne leve pas d'exception. */ -function formatInvalidFields(invalidFields) { - return invalidFields - .map((field) => { - const label = field.field === "release_date" ? "Date de sortie" : field.field; - return field.value ? `${label}: ${field.value}` : label; - }) - .join(", "); +function InvalidFieldDetails({ details }) { + const possibleValues = Array.isArray(details.possible_values) + ? details.possible_values + : []; + return ( + + {details.reason} + {possibleValues.length > 0 ? ( + Valeurs possibles : {possibleValues.join(", ")} + ) : null} + + ); } /** - * Formate un warning de rattachement de plateforme incertain. + * Formate le libelle d'un champ invalide. + * + * @param {string} field - Champ invalide retourne par l'API. + * @returns {string} Libelle lisible du champ. + * @throws {void} Ne leve pas d'exception. + */ +function formatInvalidFieldLabel(field) { + return getImportFieldLabel(field); +} + +function invalidFieldGroupKey(field) { + return field || ""; +} + +/** + * Formate la cause d'un groupe de plateformes a verifier. * * @param {Object} warning - Warning retourne par l'API d'import. - * @returns {string} Description concise du rattachement. + * @returns {string} Description concise de la cause. * @throws {void} Ne leve pas d'exception. */ -function formatPlatformMatchWarning(warning) { - return `${warning.imported_platform || "-"} -> ${warning.matched_platform || "-"} (${Number(warning.score || 0)}%)`; +function formatPlatformRefusal(warning) { + if (warning.message) { + return warning.message; + } + return `${warning.matched_platform || "-"} (${Number(warning.score || 0)}%)`; } /** - * Formate un warning de jeu ignore. + * Formate la cause d'un groupe de jeux ignores. * * @param {Object} warning - Warning retourne par l'API d'import. - * @returns {string} Description concise du refus. + * @returns {string} Description concise de la cause. * @throws {void} Ne leve pas d'exception. */ -function formatSkippedGameWarning(warning) { - return `${warning.imported_platform || "-"} - ${formatSkippedGameReason(warning.reason)} (${Number(warning.score || 0)}%)`; +function formatSkippedPlatformRefusal(warning) { + if (warning.message) { + return warning.message; + } + return `${formatSkippedGameReason(warning.reason)} (${Number(warning.score || 0)}%)`; } /** @@ -250,8 +492,8 @@ function formatSkippedGameReason(reason) { low_score: "score trop faible", no_match: "aucune correspondance", }; - return labels[reason] || "plateforme non fiable"; + return labels[reason] || reason || "plateforme non fiable"; } -export { formatImportDuration }; +export { formatImportDuration, formatInvalidGamesRatio }; export default ImportSummary; diff --git a/frontend/src/components/UserCollectionOnboardingView.jsx b/frontend/src/components/UserCollectionOnboardingView.jsx index ffdf315..593b746 100644 --- a/frontend/src/components/UserCollectionOnboardingView.jsx +++ b/frontend/src/components/UserCollectionOnboardingView.jsx @@ -12,6 +12,7 @@ * * Description : vue d'onboarding pour importer la collection utilisateur initiale. */ +import { useState } from "react"; import ImportConfigurationFields from "./ImportConfigurationFields"; import ImportSummary from "./ImportSummary"; import PageLayout from "./PageLayout"; @@ -67,6 +68,7 @@ function UserCollectionOnboardingView({ }) { const isBusy = isCheckingCollection || isAnalyzingCollection || isImportingCollection; const isImportRefused = Boolean(importResult?.refusal?.refused); + const [showFileExpectation, setShowFileExpectation] = useState(false); /** * Transmet le fichier selectionne au hook d'orchestration. @@ -116,18 +118,18 @@ function UserCollectionOnboardingView({
1 -

Selectionner

+

Sélectionner

Choisissez le fichier qui contient votre collection.

2 -

Importer

-

L'import cree les elements manquants et associe les jeux a votre compte.

+

Configurer votre import

+

Indiquez ou se trouvent les colonnes obligatoires et optionnelles.

3 -

Consulter

-

Apres succes, ouvrez Ma collection depuis le resume.

+

Importer et consulter

+

L'import associe les jeux à votre compte, puis affiche un résumé.

@@ -140,58 +142,108 @@ function UserCollectionOnboardingView({ /> ) : (
- - - {selectedCollectionFileName ? ( -

{selectedCollectionFileName}

- ) : null} - {hasAnalyzedImportFile ? ( - - ) : null} - {onboardingError ?

{onboardingError}

: null} - {isCheckingCollection ? : null} - {isAnalyzingCollection ? : null} - {isImportingCollection ? : null} -
- -
+ {selectedCollectionFileName ? ( +
+ {selectedCollectionFileName} + +
+ ) : ( + <> +
+ Étape 1 +

Fournir votre fichier de collection

+

+ Sélectionnez un fichier Excel, LibreOffice ou CSV. Le format est + détecté automatiquement à partir du fichier fourni. +

+
+
+ + {showFileExpectation ? ( + + Le fichier doit contenir une ligne par jeu, avec au minimum une + information de nom de jeu et de plateforme. Vous pouvez aussi y + ajouter des colonnes optionnelles comme studio, date de sortie, + prix, note, état, région ou description. Il peut comporter + plusieurs onglets. L'appartenance à votre collection ou à votre + liste de souhaits peut être indiquée par une colonne ou par un + onglet dédié. + + ) : null} +
+ + + )} + + {hasAnalyzedImportFile ? ( +
+
+ Étape 2 +

Configurer votre import

+

+ Renseignez les emplacements ou les colonnes qui contiennent + les informations de vos jeux. Les champs obligatoires sont + mis en évidence. +

+
+ +
+ ) : null} + {onboardingError ?

{onboardingError}

: null} + {isCheckingCollection ? : null} + {isAnalyzingCollection ? : null} + {isImportingCollection ? : null} +
+ +
)} @@ -209,9 +261,9 @@ function UserImportContributionNotice() { return ( <>

- Tous les jeux que vous avez importes et qui n'existaient pas encore dans - la Bibliotheque commune sont accessibles dans votre collection privee. - Ils seront visibles dans la Bibliotheque commune apres validation par un + Tous les jeux que vous avez importés et qui n'existaient pas encore dans + la Bibliothèque commune sont accessibles dans votre collection privée. + Ils seront visibles dans la Bibliothèque commune après validation par un administrateur.

Merci pour votre contribution.

diff --git a/frontend/src/hooks/app/useCloudCollectionViewModel.js b/frontend/src/hooks/app/useCloudCollectionViewModel.js index 163d2ec..69abc25 100644 --- a/frontend/src/hooks/app/useCloudCollectionViewModel.js +++ b/frontend/src/hooks/app/useCloudCollectionViewModel.js @@ -215,6 +215,10 @@ function useCloudCollectionViewModel() { onCollectionReinitialized: userCollectionOnboarding.markCollectionMissingAfterReinitialization, openCollectionOnboarding: navigation.openCollectionOnboarding, }); + const openNewCollectionImport = () => { + userCollectionOnboarding.prepareNewCollectionImport(); + navigation.openCollectionOnboarding(); + }; const canManageCollectionShares = ( session.authenticatedProfile === "USER" && userCollectionOnboarding.hasCollection === true && @@ -315,7 +319,7 @@ function useCloudCollectionViewModel() { openLibraryPlatformDetail: navigation.openLibraryPlatformDetail, openWishlist: navigation.openWishlist, openStatistics: navigation.openStatistics, - openCollectionOnboarding: navigation.openCollectionOnboarding, + openCollectionOnboarding: openNewCollectionImport, openUsersPage: navigation.openUsersPage, openAdminLibraryImport: navigation.openAdminLibraryImport, openPlatformImageModeration: navigation.openPlatformImageModeration, diff --git a/frontend/src/hooks/collection/csvImportConfigurationBuilder.js b/frontend/src/hooks/collection/csvImportConfigurationBuilder.js index f4402bd..d603ddb 100644 --- a/frontend/src/hooks/collection/csvImportConfigurationBuilder.js +++ b/frontend/src/hooks/collection/csvImportConfigurationBuilder.js @@ -13,6 +13,8 @@ * Description : construction frontend de la configuration d'import CSV. */ +import { hasCsvImportColumn } from "./importGlobalOptionsVisibility.js"; + const REQUIRED_CSV_FIELDS = Object.freeze(["name", "platform"]); const OPTIONAL_CSV_FIELDS = Object.freeze([ "studio", "release_date", "purchase_price", "buy_location", "buy_date", "grade", @@ -94,6 +96,9 @@ function buildCsvImportConfigurationDescription(configuration) { * @returns {number} Base de notation. */ function buildCsvRatingBase(configuration, errors) { + if (!hasCsvImportColumn(configuration, "grade")) { + return 10; + } const ratingBase = Number.parseInt(configuration.ratingBase, 10); if (!Number.isInteger(ratingBase) || ratingBase <= 0) { errors.push("Renseignez une base de notation valide."); @@ -114,7 +119,9 @@ function buildCsvWishlistConfiguration(configuration, errors) { if (mode === "none" || mode === "column") { return { mode }; } - errors.push("Le CSV accepte uniquement une wishlist absente ou portee par une colonne."); + errors.push( + "Le CSV accepte uniquement une liste de souhaits absente ou portée par une colonne." + ); return { mode: "none" }; } diff --git a/frontend/src/hooks/collection/importAnalysisConfiguration.js b/frontend/src/hooks/collection/importAnalysisConfiguration.js index 60484fa..3c265c2 100644 --- a/frontend/src/hooks/collection/importAnalysisConfiguration.js +++ b/frontend/src/hooks/collection/importAnalysisConfiguration.js @@ -56,8 +56,8 @@ function buildImportConfigurationAfterAnalysis( sharedLayout: true, sharedSheetLayout: { ...currentConfiguration.sharedSheetLayout, - sheetSelectionMode: "included", - includedSheets: sheetNames, + sheetSelectionMode: "all", + includedSheets: [], excludedSheets: [], }, }; diff --git a/frontend/src/hooks/collection/importConfigurationBuilder.js b/frontend/src/hooks/collection/importConfigurationBuilder.js index 7fb2402..fec437b 100644 --- a/frontend/src/hooks/collection/importConfigurationBuilder.js +++ b/frontend/src/hooks/collection/importConfigurationBuilder.js @@ -13,24 +13,22 @@ * Description : construction frontend de la description d'import de collection. */ +import { hasSpreadsheetImportColumn } from "./importGlobalOptionsVisibility.js"; import { applyDataRangeDefaults } from "./importSpreadsheetColumnTools.js"; import { buildCsvImportConfigurationDescription, buildFrontendCsvConfiguration, createDefaultCsvMapping, } from "./csvImportConfigurationBuilder.js"; - -const REQUIRED_FIELDS = Object.freeze(["name", "platform"]); -const REFERENCE_OPTIONAL_FIELDS = Object.freeze(["studio", "release_date"]); -const PRIVATE_INFORMATION_FIELDS = Object.freeze([ - "purchase_price", "buy_location", "buy_date", "grade", "condition", - "has_manual", "is_collector", "has_steelbook", "is_digital", "region", "description", -]); -const OPTIONAL_FIELDS = Object.freeze([ - ...REFERENCE_OPTIONAL_FIELDS, - ...PRIVATE_INFORMATION_FIELDS, -]); -const SHEET_INFORMATION = "platform"; +import { + OPTIONAL_FIELDS, + PRIVATE_INFORMATION_FIELDS, + REQUIRED_FIELDS, + SHEET_INFORMATION, + collectionColumnFields, + collectionRequiredFields, + wishlistSheetColumnFields, +} from "./importSpreadsheetFieldDefinitions.js"; /** * Construit un layout d'import par defaut. @@ -74,7 +72,7 @@ function createDefaultImportConfiguration() { singleSheetLayout: createDefaultLayout(true), sharedSheetLayout: { ...createDefaultLayout(false), - sheetSelectionMode: "included", + sheetSelectionMode: "all", includedSheets: "", excludedSheets: "", }, @@ -131,7 +129,7 @@ function createImportConfigurationFromDescription(description) { ratingBase: String(description.rating_base || defaultConfiguration.ratingBase), multipleSheets: true, sharedLayout: true, - sheetInformation: multipleSheetsConfiguration.sheet_information || SHEET_INFORMATION, + sheetInformation: multipleSheetsConfiguration.sheet_information || "", wishlist, sharedSheetLayout: buildFrontendSharedLayout( multipleSheetsConfiguration.shared_layout, @@ -151,11 +149,11 @@ function createImportConfigurationFromDescription(description) { ratingBase: String(description.rating_base || defaultConfiguration.ratingBase), multipleSheets: true, sharedLayout: false, - sheetInformation: multipleSheetsConfiguration.sheet_information || SHEET_INFORMATION, + sheetInformation: multipleSheetsConfiguration.sheet_information || sheets[0]?.sheet_information || "", wishlist, sheets: sheets.map((sheet) => ({ sheetName: sheet.sheet_name || "", - sheetInformation: sheet.sheet_information || SHEET_INFORMATION, + sheetInformation: sheet.sheet_information || "", layout: buildFrontendLayout(sheet, defaultConfiguration.sheets[0].layout), })), }; @@ -235,12 +233,18 @@ function buildFrontendSharedLayout(layoutDescription, defaultLayout) { includedSheets: "", }; } + if (!Array.isArray(layoutDescription.included_sheets)) { + return { + ...baseLayout, + sheetSelectionMode: "all", + includedSheets: "", + excludedSheets: "", + }; + } return { ...baseLayout, sheetSelectionMode: "included", - includedSheets: Array.isArray(layoutDescription.included_sheets) - ? layoutDescription.included_sheets - : "", + includedSheets: layoutDescription.included_sheets, excludedSheets: "", }; } @@ -277,7 +281,10 @@ function buildImportConfigurationDescription(configuration) { }; } if (configuration.sharedLayout) { - const requiredFields = collectionRequiredFields(configuration, false); + const requiredFields = collectionRequiredFields( + configuration, + configuration.sheetInformation !== SHEET_INFORMATION + ); const layout = buildLayout(configuration.sharedSheetLayout, requiredFields, errors); const selectionMode = configuration.sharedSheetLayout.sheetSelectionMode; if (selectionMode === "excluded") { @@ -285,22 +292,23 @@ function buildImportConfigurationDescription(configuration) { if (excludedSheets.length) { layout.excluded_sheets = excludedSheets; } - } else { + } else if (selectionMode === "included") { const includedSheets = splitSheetNames(configuration.sharedSheetLayout.includedSheets); if (includedSheets.length) { layout.included_sheets = includedSheets; } } + const multipleSheetsConf = { shared_layout: layout }; + if (configuration.sheetInformation) { + multipleSheetsConf.sheet_information = configuration.sheetInformation; + } return { description: errors.length ? null : { file_type: fileType, price_unit: configuration.priceUnit, rating_base: ratingBase, wishlist, - multiple_sheets_conf: { - sheet_information: SHEET_INFORMATION, - shared_layout: layout, - }, + multiple_sheets_conf: multipleSheetsConf, }, errors, }; @@ -310,12 +318,18 @@ function buildImportConfigurationDescription(configuration) { if (!sheetName) { errors.push(`Renseignez le nom de l'onglet ${index + 1}.`); } - const requiredFields = collectionRequiredFields(configuration, false); - return { + const requiredFields = collectionRequiredFields( + configuration, + configuration.sheetInformation !== SHEET_INFORMATION + ); + const sheetDescription = { sheet_name: sheetName, - sheet_information: SHEET_INFORMATION, ...buildLayout(sheet.layout, requiredFields, errors), }; + if (configuration.sheetInformation) { + sheetDescription.sheet_information = configuration.sheetInformation; + } + return sheetDescription; }); return { description: errors.length ? null : { @@ -347,6 +361,7 @@ function normalizeSpreadsheetFileType(fileType) { * @returns {number} Base de notation. */ function buildRatingBase(configuration, errors) { + if (!hasSpreadsheetImportColumn(configuration, "grade")) return 10; const ratingBase = Number.parseInt(configuration.ratingBase, 10); if (!Number.isInteger(ratingBase) || ratingBase <= 0) { errors.push("Renseignez une base de notation valide."); @@ -355,53 +370,6 @@ function buildRatingBase(configuration, errors) { return ratingBase; } -/** - * Retourne les champs requis pour les layouts collection. - * - * @param {Object} configuration - Etat frontend de configuration. - * @param {boolean} includePlatformColumn - Indique si le layout porte la plateforme. - * @returns {string[]} Champs requis dans `column_information`. - */ -function collectionRequiredFields(configuration, includePlatformColumn) { - const fields = includePlatformColumn - ? [...REQUIRED_FIELDS] - : REQUIRED_FIELDS.filter((field) => field !== SHEET_INFORMATION); - if (configuration.wishlist.mode === "column") { - fields.push("wishlist"); - } - return fields; -} - -/** - * Retourne les champs colonne a afficher pour un layout de collection ODS. - * - * @param {Object} configuration - Etat frontend de configuration. - * @param {boolean} includePlatformColumn - Indique si la plateforme est une colonne. - * @returns {string[]} Champs colonnes configurables. - */ -function collectionColumnFields(configuration, includePlatformColumn) { - const fields = includePlatformColumn - ? [...REQUIRED_FIELDS, ...REFERENCE_OPTIONAL_FIELDS] - : [ - ...REQUIRED_FIELDS.filter((field) => field !== SHEET_INFORMATION), - ...REFERENCE_OPTIONAL_FIELDS, - ]; - if (configuration.wishlist.mode === "column") { - fields.push("wishlist"); - } - fields.push(...PRIVATE_INFORMATION_FIELDS); - return fields; -} - -/** - * Retourne les champs colonne a afficher pour l'onglet wishlist dedie. - * - * @returns {string[]} Champs wishlist configurables. - */ -function wishlistSheetColumnFields() { - return [...REQUIRED_FIELDS, ...OPTIONAL_FIELDS]; -} - /** * Construit la section wishlist du contrat backend. * @@ -415,12 +383,12 @@ function buildWishlistConfiguration(configuration, errors) { return { mode }; } if (mode !== "sheet") { - errors.push("Selectionnez un mode wishlist valide."); + errors.push("Sélectionnez un mode de liste de souhaits valide."); return { mode: "none" }; } const sheetName = String(configuration.wishlist.sheetName || "").trim(); if (!sheetName) { - errors.push("Renseignez l'onglet wishlist."); + errors.push("Renseignez l'onglet de liste de souhaits."); } return { mode, @@ -441,10 +409,10 @@ function buildLayout(layout, requiredFields, errors) { const dataRange = String(layout.dataRange || "").trim().toUpperCase(); const headerRow = Number.parseInt(layout.headerRow, 10); if (!dataRange) { - errors.push("Renseignez la plage de donnees."); + errors.push("Renseignez la plage de données."); } if (!Number.isInteger(headerRow) || headerRow < 1) { - errors.push("Renseignez une ligne d'en-tete valide."); + errors.push("Renseignez une ligne d'en-tête valide."); } const columnInformation = {}; requiredFields.forEach((field) => { diff --git a/frontend/src/hooks/collection/importFieldLabels.js b/frontend/src/hooks/collection/importFieldLabels.js new file mode 100644 index 0000000..4f6ca27 --- /dev/null +++ b/frontend/src/hooks/collection/importFieldLabels.js @@ -0,0 +1,46 @@ +/* + * ____ _ _ ____ _ _ _ _ ___ + * / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ + * | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | + * | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | + * \____|_|\___/ \__,_|\__,_|\____\___/|_|_|\___|\___|\__|_|\___/|_| |_|\___/| .__/| .__/ + * |_| |_| + * Projet : CloudCollectionApp + * Date de creation : 2026-08-23 + * Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien + * Licence : Apache 2.0 + * + * Description : libelles centralises des champs configurables d'import. + */ + +const IMPORT_FIELD_LABELS = Object.freeze({ + name: "Nom du jeu", + platform: "Plateforme", + studio: "Studio", + release_date: "Date de sortie", + wishlist: "Liste de souhaits", + purchase_price: "Prix d'achat", + buy_location: "Lieu d'achat", + buy_date: "Date d'achat", + grade: "Note", + condition: "État", + has_manual: "Notice", + is_collector: "Collector", + has_steelbook: "Steelbook", + is_digital: "Version dématérialisée", + region: "Région", + description: "Description", +}); + +/** + * Retourne le libelle utilisateur d'un champ d'import. + * + * @param {string} fieldName - Nom technique du champ d'import. + * @returns {string} Libelle affiche dans la configuration et les resumes. + * @throws {void} Ne leve pas d'exception. + */ +function getImportFieldLabel(fieldName) { + return IMPORT_FIELD_LABELS[fieldName] || fieldName || ""; +} + +export { IMPORT_FIELD_LABELS, getImportFieldLabel }; diff --git a/frontend/src/hooks/collection/importGlobalOptionsVisibility.js b/frontend/src/hooks/collection/importGlobalOptionsVisibility.js new file mode 100644 index 0000000..3c47041 --- /dev/null +++ b/frontend/src/hooks/collection/importGlobalOptionsVisibility.js @@ -0,0 +1,67 @@ +/* + * ____ _ _ ____ _ _ _ _ ___ + * / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ + * | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | + * | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | + * \____|_|\___/ \__,_|\__,_|\____\___/|_|_|\___|\___|\__|_|\___/|_| |_|\___/| .__/| .__/ + * |_| |_| + * Projet : CloudCollectionApp + * Date de creation : 2026-08-17 + * Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien + * Licence : Apache 2.0 + * + * Description : regles de visibilite des options globales d'import. + */ + +/** + * Indique si une colonne donnee est configuree dans un layout. + * + * @param {Object} layout - Layout de formulaire. + * @param {string} fieldName - Nom de colonne recherche. + * @returns {boolean} Vrai si le champ contient une colonne. + * @throws {void} Ne leve pas d'exception. + */ +function hasLayoutColumn(layout, fieldName) { + return Boolean(String(layout?.columns?.[fieldName] || "").trim()); +} + +/** + * Indique si une colonne est configuree dans au moins un layout tableur actif. + * + * @param {Object} configuration - Configuration d'import tableur. + * @param {string} fieldName - Nom de colonne recherche. + * @returns {boolean} Vrai si la colonne est configuree. + * @throws {void} Ne leve pas d'exception. + */ +function hasSpreadsheetImportColumn(configuration, fieldName) { + if ( + configuration.wishlist?.mode === "sheet" && + hasLayoutColumn(configuration.wishlist?.layout, fieldName) + ) { + return true; + } + if (!configuration.multipleSheets) { + return hasLayoutColumn(configuration.singleSheetLayout, fieldName); + } + if (configuration.sharedLayout) { + return hasLayoutColumn(configuration.sharedSheetLayout, fieldName); + } + return (configuration.sheets || []).some((sheet) => hasLayoutColumn(sheet.layout, fieldName)); +} + +/** + * Indique si une colonne CSV est mappee. + * + * @param {Object} configuration - Configuration d'import CSV. + * @param {string} fieldName - Nom de colonne recherche. + * @returns {boolean} Vrai si le mapping CSV contient une colonne. + * @throws {void} Ne leve pas d'exception. + */ +function hasCsvImportColumn(configuration, fieldName) { + return Boolean(String(configuration.csvMapping?.[fieldName] || "").trim()); +} + +export { + hasCsvImportColumn, + hasSpreadsheetImportColumn, +}; diff --git a/frontend/src/hooks/collection/importSheetSelection.js b/frontend/src/hooks/collection/importSheetSelection.js new file mode 100644 index 0000000..3f8b0fb --- /dev/null +++ b/frontend/src/hooks/collection/importSheetSelection.js @@ -0,0 +1,91 @@ +/* + * ____ _ _ ____ _ _ _ _ ___ + * / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ + * | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | + * | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | + * \____|_|\___/ \__,_|\__,_|\____\___/|_|_|\___|\___|\__|_|\___/|_| |_|\___/| .__/| .__/ + * |_| |_| + * Projet : CloudCollectionApp + * Date de creation : 2026-08-18 + * Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien + * Licence : Apache 2.0 + * + * Description : selection des onglets de collection pour l'import utilisateur. + */ + +/** + * Decoupe une saisie d'onglets libre. + * + * @param {string|string[]} value - Valeur source. + * @returns {string[]} Noms d'onglets non vides. + * @throws {void} Ne leve pas d'exception. + */ +function splitImportSheetNames(value) { + if (Array.isArray(value)) { + return value.map((sheetName) => String(sheetName).trim()).filter(Boolean); + } + return String(value || "") + .split(/[\n,]/) + .map((sheetName) => sheetName.trim()) + .filter(Boolean); +} + +/** + * Déduit les onglets de collection depuis le mode inclus/exclus. + * + * @param {string[]} availableSheetNames - Onglets détectés dans le fichier. + * @param {Object} sharedSheetLayout - Configuration de sélection d'onglets. + * @returns {string[]} Onglets contenant les jeux de collection. + * @throws {void} Ne leve pas d'exception. + */ +function resolveCollectionSheetNames(availableSheetNames, sharedSheetLayout) { + const detectedSheetNames = Array.isArray(availableSheetNames) ? availableSheetNames : []; + if (sharedSheetLayout?.sheetSelectionMode === "all") { + return detectedSheetNames; + } + if (sharedSheetLayout?.sheetSelectionMode === "excluded") { + const excludedSheets = new Set(splitImportSheetNames(sharedSheetLayout.excludedSheets)); + return detectedSheetNames.filter((sheetName) => !excludedSheets.has(sheetName)); + } + return splitImportSheetNames(sharedSheetLayout?.includedSheets); +} + +/** + * Synchronise les configurations par onglet avec la selection d'onglets de collection. + * + * @param {Object} configuration - Configuration courante. + * @param {string[]} availableSheetNames - Onglets detectes dans le fichier. + * @param {Object} defaultSheetConfiguration - Configuration par defaut d'un onglet. + * @returns {Object} Configuration avec onglets par feuille synchronises. + * @throws {void} Ne leve pas d'exception. + */ +function synchronizePerSheetConfigurations( + configuration, + availableSheetNames, + defaultSheetConfiguration +) { + const sheetNames = resolveCollectionSheetNames( + availableSheetNames, + configuration.sharedSheetLayout + ); + if (!sheetNames.length) { + return configuration; + } + const existingSheetsByName = new Map( + configuration.sheets.map((sheet) => [String(sheet.sheetName || ""), sheet]) + ); + return { + ...configuration, + sheets: sheetNames.map((sheetName) => ({ + ...defaultSheetConfiguration, + ...existingSheetsByName.get(sheetName), + sheetName, + })), + }; +} + +export { + resolveCollectionSheetNames, + splitImportSheetNames, + synchronizePerSheetConfigurations, +}; diff --git a/frontend/src/hooks/collection/importSpreadsheetFieldDefinitions.js b/frontend/src/hooks/collection/importSpreadsheetFieldDefinitions.js new file mode 100644 index 0000000..b440348 --- /dev/null +++ b/frontend/src/hooks/collection/importSpreadsheetFieldDefinitions.js @@ -0,0 +1,85 @@ +/* + * ____ _ _ ____ _ _ _ _ ___ + * / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __ + * | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ | + * | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) | + * \____|_|\___/ \__,_|\__,_|\____\___/|_|_|\___|\___|\__|_|\___/|_| |_|\___/| .__/| .__/ + * |_| |_| + * Projet : CloudCollectionApp + * Date de creation : 2026-08-20 + * Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien + * Licence : Apache 2.0 + * + * Description : definitions des champs tableur configurables pour l'import. + */ + +const REQUIRED_FIELDS = Object.freeze(["name", "platform"]); +const REFERENCE_OPTIONAL_FIELDS = Object.freeze(["studio", "release_date"]); +const PRIVATE_INFORMATION_FIELDS = Object.freeze([ + "purchase_price", "buy_location", "buy_date", "grade", "condition", + "has_manual", "is_collector", "has_steelbook", "is_digital", "region", "description", +]); +const OPTIONAL_FIELDS = Object.freeze([ + ...REFERENCE_OPTIONAL_FIELDS, + ...PRIVATE_INFORMATION_FIELDS, +]); +const SHEET_INFORMATION = "platform"; + +/** + * Retourne les champs requis pour les layouts collection. + * + * @param {Object} configuration - Etat frontend de configuration. + * @param {boolean} includePlatformColumn - Indique si le layout porte la plateforme. + * @returns {string[]} Champs requis dans `column_information`. + */ +function collectionRequiredFields(configuration, includePlatformColumn) { + const fields = includePlatformColumn + ? [...REQUIRED_FIELDS] + : REQUIRED_FIELDS.filter((field) => field !== SHEET_INFORMATION); + if (configuration.wishlist.mode === "column") { + fields.push("wishlist"); + } + return fields; +} + +/** + * Retourne les champs colonne a afficher pour un layout de collection ODS. + * + * @param {Object} configuration - Etat frontend de configuration. + * @param {boolean} includePlatformColumn - Indique si la plateforme est une colonne. + * @returns {string[]} Champs colonnes configurables. + */ +function collectionColumnFields(configuration, includePlatformColumn) { + const mustIncludePlatformColumn = includePlatformColumn + || configuration.sheetInformation !== SHEET_INFORMATION; + const fields = mustIncludePlatformColumn + ? [...REQUIRED_FIELDS, ...REFERENCE_OPTIONAL_FIELDS] + : [ + ...REQUIRED_FIELDS.filter((field) => field !== SHEET_INFORMATION), + ...REFERENCE_OPTIONAL_FIELDS, + ]; + if (configuration.wishlist.mode === "column") { + fields.push("wishlist"); + } + fields.push(...PRIVATE_INFORMATION_FIELDS); + return fields; +} + +/** + * Retourne les champs colonne a afficher pour l'onglet wishlist dedie. + * + * @returns {string[]} Champs wishlist configurables. + */ +function wishlistSheetColumnFields() { + return [...REQUIRED_FIELDS, ...OPTIONAL_FIELDS]; +} + +export { + OPTIONAL_FIELDS, + PRIVATE_INFORMATION_FIELDS, + REQUIRED_FIELDS, + SHEET_INFORMATION, + collectionColumnFields, + collectionRequiredFields, + wishlistSheetColumnFields, +}; diff --git a/frontend/src/hooks/collection/useUserCollectionOnboarding.js b/frontend/src/hooks/collection/useUserCollectionOnboarding.js index 849837f..2bbb8e3 100644 --- a/frontend/src/hooks/collection/useUserCollectionOnboarding.js +++ b/frontend/src/hooks/collection/useUserCollectionOnboarding.js @@ -16,12 +16,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import UserCollectionApi from "../../services/UserCollectionApi"; import getUserCollectionErrorMessage from "./userCollectionImportMessages"; import updatedLayoutValue from "./importLayoutState"; -import { - buildImportConfigurationDescription, - collectionRequiredFields, - createImportConfigurationFromDescription, - createDefaultImportConfiguration, -} from "./importConfigurationBuilder"; +import { buildImportConfigurationDescription, collectionRequiredFields, createImportConfigurationFromDescription, createDefaultImportConfiguration } from "./importConfigurationBuilder"; import { buildIncompatibleSavedConfigurationMessage, normalizeImportFileType, @@ -29,6 +24,9 @@ import { } from "./importFileTypeTools"; import buildImportConfigurationAfterAnalysis from "./importAnalysisConfiguration"; import canCurrentTokenUseCollectionViews from "./collectionSessionPolicy"; +import { synchronizePerSheetConfigurations } from "./importSheetSelection"; + +const defaultSheetConfiguration = createDefaultImportConfiguration().sheets[0]; /** * Orchestre la verification de collection et l'import initial du fichier de collection. @@ -210,12 +208,14 @@ function useUserCollectionOnboarding(options) { }, [applyAnalyzedSheets, applySavedImportConfigurationIfConfirmed, importConfiguration.fileType]); const updateImportConfiguration = useCallback((fieldName, value) => { - setImportConfiguration((currentConfiguration) => ({ - ...currentConfiguration, - [fieldName]: value, - })); + setImportConfiguration((currentConfiguration) => { + const nextConfiguration = { ...currentConfiguration, [fieldName]: value }; + return fieldName === "sharedLayout" && value === false + ? synchronizePerSheetConfigurations(nextConfiguration, availableImportSheets, defaultSheetConfiguration) + : nextConfiguration; + }); setOnboardingError(""); - }, []); + }, [availableImportSheets]); const updateCsvMapping = useCallback((fieldName, value) => { setImportConfiguration((currentConfiguration) => ({ @@ -229,22 +229,25 @@ function useUserCollectionOnboarding(options) { }, []); const updateImportLayout = useCallback((layoutName, fieldName, value) => { - setImportConfiguration((currentConfiguration) => ({ - ...currentConfiguration, - [layoutName]: { - ...updatedLayoutValue( - currentConfiguration[layoutName], - fieldName, - value, - collectionRequiredFields( - currentConfiguration, - layoutName === "singleSheetLayout" - ) - ), - }, - })); + setImportConfiguration((currentConfiguration) => { + const nextConfiguration = { + ...currentConfiguration, + [layoutName]: { + ...updatedLayoutValue( + currentConfiguration[layoutName], + fieldName, + value, + collectionRequiredFields(currentConfiguration, layoutName === "singleSheetLayout" + || currentConfiguration.sheetInformation !== "platform") + ), + }, + }; + return layoutName === "sharedSheetLayout" && !nextConfiguration.sharedLayout + ? synchronizePerSheetConfigurations(nextConfiguration, availableImportSheets, defaultSheetConfiguration) + : nextConfiguration; + }); setOnboardingError(""); - }, []); + }, [availableImportSheets]); const updateImportLayoutColumn = useCallback((layoutName, fieldName, value) => { setImportConfiguration((currentConfiguration) => ({ @@ -281,7 +284,7 @@ function useUserCollectionOnboarding(options) { sheet.layout, fieldName, value, - collectionRequiredFields(currentConfiguration, false) + collectionRequiredFields(currentConfiguration, currentConfiguration.sheetInformation !== "platform") ), } : sheet @@ -308,26 +311,17 @@ function useUserCollectionOnboarding(options) { setOnboardingError(""); }, []); - const addImportSheetConfiguration = useCallback(() => { - setImportConfiguration((currentConfiguration) => ({ - ...currentConfiguration, - sheets: [ - ...currentConfiguration.sheets, - { - sheetName: "", - sheetInformation: "platform", - layout: createDefaultImportConfiguration().sheets[0].layout, - }, - ], - })); - }, []); + const addImportSheetConfiguration = useCallback(() => setImportConfiguration((currentConfiguration) => ({ + ...currentConfiguration, + sheets: [...currentConfiguration.sheets, { ...defaultSheetConfiguration }], + })), []); - const removeImportSheetConfiguration = useCallback((sheetIndex) => { - setImportConfiguration((currentConfiguration) => ({ + const removeImportSheetConfiguration = useCallback((sheetIndex) => setImportConfiguration( + (currentConfiguration) => ({ ...currentConfiguration, sheets: currentConfiguration.sheets.filter((_, index) => index !== sheetIndex), - })); - }, []); + }) + ), []); const updateWishlistConfiguration = useCallback((fieldName, value) => { setImportConfiguration((currentConfiguration) => ({ @@ -379,7 +373,7 @@ function useUserCollectionOnboarding(options) { const importSelectedCollection = useCallback(async () => { if (!selectedCollectionFile || importInProgressRef.current) { if (!selectedCollectionFile) { - setOnboardingError("Selectionnez un fichier de collection avant de lancer l'import."); + setOnboardingError("Sélectionnez un fichier de collection avant de lancer l'import."); } return; } @@ -475,6 +469,7 @@ function useUserCollectionOnboarding(options) { isImportingCollection, handleAuthenticatedUser, markCollectionMissingAfterReinitialization, + prepareNewCollectionImport: resetOnboardingState, selectCollectionFile, updateImportConfiguration, updateImportLayout, @@ -492,4 +487,12 @@ function useUserCollectionOnboarding(options) { }; } +/** + * Synchronise les configurations par onglet avec la selection d'onglets de collection. + * + * @param {Object} configuration - Configuration courante. + * @param {string[]} availableSheetNames - Onglets detectes dans le fichier. + * @returns {Object} Configuration avec onglets par feuille synchronises. + * @throws {void} Ne leve pas d'exception. + */ export default useUserCollectionOnboarding; diff --git a/frontend/src/services/UserCollectionApi.js b/frontend/src/services/UserCollectionApi.js index f3cfef5..b9704ff 100644 --- a/frontend/src/services/UserCollectionApi.js +++ b/frontend/src/services/UserCollectionApi.js @@ -162,6 +162,28 @@ class UserCollectionApi { }); } + /** + * Charge l'aide associee a une valeur d'import refusee. + * + * @param {string} field - Champ d'import refuse. + * @param {string} value - Valeur refusee. + * @returns {Promise} Raison du refus et valeurs possibles. + * @throws {UserCollectionApiError} Si l'aide ne peut pas etre chargee. + */ + static async fetchImportInvalidValueHelp(field, value = "") { + const query = new URLSearchParams({ + field: String(field || ""), + value: String(value || ""), + }); + return this.fetchJson( + `/api/users/import/invalid-value-help?${query.toString()}`, + "Impossible de recuperer les informations de refus.", + { + headers: AuthApi.getAuthorizationHeaders(), + } + ); + } + /** * Reinitialise la collection de l'utilisateur connecte. * diff --git a/frontend/src/styles/collection-onboarding.css b/frontend/src/styles/collection-onboarding.css index 517361f..5868150 100644 --- a/frontend/src/styles/collection-onboarding.css +++ b/frontend/src/styles/collection-onboarding.css @@ -81,6 +81,48 @@ padding: 1.25rem; } +.importFormStep { border: 1px solid #dbe4f0; border-radius: 8px; display: grid; gap: 1rem; padding: 1rem; } + +.importCollapsibleSection { border: 1px solid #dbe4f0; border-radius: 8px; overflow: hidden; } +.importCollapsibleSection summary { background: #f0fdf4; cursor: pointer; display: grid; gap: 0.25rem; list-style-position: inside; padding: 0.85rem 1rem; } +.importCollapsibleSection summary span { color: #14532d; font-weight: 900; } +.importCollapsibleSection summary small { color: #166534; font-weight: 600; } +.importCollapsibleContent { display: grid; gap: 1rem; padding: 1rem; } + +.importFormStepHeader { + display: grid; + gap: 0.35rem; +} + +.importFormStepHeader span { + color: #475569; + font-size: 0.78rem; + font-weight: 900; + text-transform: uppercase; +} + +.importFormStepHeader h2 { + color: #111827; + font-size: 1.12rem; + margin: 0; +} + +.importFormStepHeader p, +.importConfigurationIntro { + color: #475569; + margin: 0; +} + +.importFileExpectation { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + color: #334155; + font-weight: 600; + margin: 0; + padding: 0.75rem; +} + .adminImportConfigurationHelp { background: #f8fafc; border: 1px solid #dbe4f0; @@ -166,7 +208,8 @@ width: 100%; } -.importConfiguration { +.importConfiguration, +.wishlistConfiguration { border: 1px solid #dbe4f0; border-radius: 8px; display: grid; @@ -175,7 +218,23 @@ padding: 1rem; } -.importConfiguration legend { +.importConfigurationIntro, +.wishlistConfigurationIntro { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + color: #475569; + padding: 0.75rem; +} + +.wishlistConfigurationIntro { + margin: 0; +} + +.importGlobalOptions { display: grid; gap: 0.85rem; } + +.importConfiguration legend, +.wishlistConfiguration legend { color: #111827; font-weight: 800; padding: 0 0.35rem; @@ -204,8 +263,17 @@ gap: 0.35rem; } +.segmentedFieldHelp { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + color: #475569; + flex-basis: 100%; + margin: 0; + padding: 0.55rem 0.65rem; +} + .layoutFields, -.wishlistConfiguration, .sheetConfigurationList { display: grid; gap: 0.85rem; @@ -238,6 +306,7 @@ border-radius: 8px; padding: 0.85rem; } +.importSummary dl .importErrorCounterAccepted { background: #fffbeb; border-color: #fde68a; } .importSummary dl .importErrorCounterRefused { background: #fef2f2; border-color: #fecaca; } .importSummary dt { color: #475569; @@ -309,24 +378,50 @@ color: #431407; } -.invalidImportedGames span { +.invalidImportedGames span, .invalidRejectedValues { color: #7c2d12; } +.invalidImportedGames .invalidAssociatedGames { list-style: disc; padding-left: 1.2rem; } .invalidImportedGames .invalidRejectedValues { list-style-type: "› "; } +.invalidFieldWarning { display: grid; gap: 0.2rem; } +.invalidFieldDetailsButton { justify-self: start; } +.invalidFieldDetails { color: #7c2d12; display: grid; gap: 0.15rem; font-size: 0.9rem; font-weight: 600; } +.invalidFieldDetailsError { color: #991b1b; font-size: 0.9rem; font-weight: 700; } + .columnGrid { display: grid; gap: 0.85rem; grid-template-columns: repeat(4, minmax(0, 1fr)); } -.sheetConfiguration { - border: 1px solid #dbe4f0; +.columnGrid label { align-content: start; grid-template-rows: 2.4rem auto 1fr; min-width: 0; } + +.fieldLabelText { align-items: end; color: #334155; display: flex; } + +.fieldHelpText { color: #64748b; font-size: 0.86rem; font-weight: 500; line-height: 1.35; } +.fieldHelpText > span:first-child { -webkit-box-orient: vertical; -webkit-line-clamp: 2; display: -webkit-box; overflow: hidden; } + +.fieldHelpToggle { background: transparent; border: 0; color: #1d4ed8; cursor: pointer; font: inherit; padding: 0 0.2rem; text-decoration: underline; } +.fieldHelpToggle:hover:not(:disabled) { background: transparent; color: #1e40af; } +.fieldHelpValues { display: block; margin-top: 0.3rem; } + +.requiredColumnField { + background: #eff6ff; + border: 1px solid #bfdbfe; border-radius: 8px; - display: grid; - gap: 0.85rem; - padding: 0.85rem; + padding: 0.75rem; +} + +.requiredColumnField .fieldLabelText { + color: #1e3a8a; + font-weight: 900; } +.sheetConfiguration { border: 1px solid #dbe4f0; border-radius: 8px; display: grid; gap: 0.85rem; padding: 0.85rem; } +.sheetConfigurationTabs { display: flex; flex-wrap: wrap; gap: 0.45rem; } +.sheetConfigurationTabs button { background: #f8fafc; border: 1px solid #cbd5e1; color: #334155; padding: 0.45rem 0.7rem; } +.sheetConfigurationTabs .activeSheetTab { background: #f0fdf4; border-color: #86efac; color: #14532d; } + .sheetConfigurationHeader { align-items: center; display: flex; @@ -356,16 +451,21 @@ } .collectionSelectedFile { - background: #eff6ff; - border: 1px solid #bfdbfe; - border-radius: 6px; - color: #1e3a8a; - font-weight: 700; + align-items: center; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 6px; + color: #1e3a8a; display: flex; gap: 0.65rem; font-weight: 700; justify-content: space-between; margin-bottom: 0; - overflow-wrap: anywhere; padding: 0.65rem 0.75rem; } +.collectionSelectedFile span:first-child { + overflow-wrap: anywhere; +} + +.collectionSelectedFileChange { + background: #ffffff; border: 1px solid #93c5fd; border-radius: 999px; color: #1d4ed8; + flex: 0 0 auto; font-size: 0.9rem; gap: 0.3rem; height: 2rem; min-height: 0; padding: 0 0.7rem; +} + @media (max-width: 760px) { .collectionOnboardingHeader { padding-top: 3.75rem; diff --git a/frontend/tests/importConfiguration.test.js b/frontend/tests/importConfiguration.test.js index e7aebe3..247da0b 100644 --- a/frontend/tests/importConfiguration.test.js +++ b/frontend/tests/importConfiguration.test.js @@ -13,6 +13,11 @@ import { createDefaultImportConfiguration, wishlistSheetColumnFields, } from "../src/hooks/collection/importConfigurationBuilder.js"; +import { + hasCsvImportColumn, + hasSpreadsheetImportColumn, +} from "../src/hooks/collection/importGlobalOptionsVisibility.js"; +import { readFileSync } from "node:fs"; test("expose les memes informations optionnelles pour la collection et la wishlist dediee", () => { const configuration = createDefaultImportConfiguration(); @@ -86,3 +91,418 @@ test("serialise une configuration Excel avec le meme contrat tableur que l'ODS", assert.equal(description.single_sheet_conf.column_information.name, "A"); assert.equal(description.single_sheet_conf.column_information.platform, "B"); }); + +test("l'onboarding d'import detecte le type de fichier et guide la configuration", () => { + const onboardingSource = readFileSync( + new URL("../src/components/UserCollectionOnboardingView.jsx", import.meta.url), + "utf8", + ); + const layoutSource = readFileSync( + new URL("../src/components/ImportLayoutFields.jsx", import.meta.url), + "utf8", + ); + const configurationSource = readFileSync( + new URL("../src/components/ImportConfigurationFields.jsx", import.meta.url), + "utf8", + ); + const csvSource = readFileSync( + new URL("../src/components/ImportCsvConfigurationFields.jsx", import.meta.url), + "utf8", + ); + const summarySource = readFileSync( + new URL("../src/components/ImportSummary.jsx", import.meta.url), + "utf8", + ); + const fieldLabelsSource = readFileSync( + new URL("../src/hooks/collection/importFieldLabels.js", import.meta.url), + "utf8", + ); + const spreadsheetWishlistSource = readFileSync( + new URL("../src/components/ImportSpreadsheetWishlistFields.jsx", import.meta.url), + "utf8", + ); + const collapsibleSource = readFileSync( + new URL("../src/components/ImportCollapsibleSection.jsx", import.meta.url), + "utf8", + ); + + assert.equal(onboardingSource.includes("Type de fichier"), false); + assert.equal(onboardingSource.includes("Fournir votre fichier de collection"), true); + assert.equal(onboardingSource.includes("Configurer votre import"), true); + assert.equal(onboardingSource.includes("collectionSelectedFile"), true); + assert.equal(onboardingSource.includes("Changer le fichier de collection"), true); + assert.equal(onboardingSource.includes("Changer"), true); + assert.equal(onboardingSource.includes("Format detecte"), false); + assert.equal(onboardingSource.includes("Excel, LibreOffice ou CSV"), true); + assert.equal(onboardingSource.includes("ODS, Excel XLSX ou CSV"), false); + assert.equal(onboardingSource.includes("Le fichier doit contenir une ligne par jeu"), true); + assert.equal(onboardingSource.includes("Plus d'informations"), true); + assert.equal(onboardingSource.includes("showFileExpectation"), true); + assert.equal(onboardingSource.includes("Il peut comporter"), true); + assert.equal(onboardingSource.includes("liste de souhaits peut être indiquée"), true); + assert.equal(layoutSource.includes("requiredColumnField"), true); + assert.equal(layoutSource.includes("première et la dernière cellule du tableau"), true); + assert.equal(layoutSource.includes("sans les notes ou totaux"), true); + assert.equal(layoutSource.includes(""), true); + assert.equal(csvSource.includes(""), true); + assert.equal(layoutSource.includes("IMPORT_FIELD_LABELS"), true); + assert.equal(csvSource.includes("IMPORT_FIELD_LABELS"), true); + assert.equal(summarySource.includes("getImportFieldLabel"), true); + assert.equal(fieldLabelsSource.includes("Région"), true); + assert.equal(configurationSource.includes("Wishlist"), false); + assert.equal(csvSource.includes("Wishlist"), false); + assert.equal(configurationSource.includes("ImportCollapsibleSection"), true); + assert.equal(collapsibleSource.includes(" 4 ? 8 : 4"), true); + assert.equal(configurationSource.includes("configurez une seule plage de données"), true); + assert.equal(configurationSource.includes("configurez séparément la plage et les colonnes"), true); + assert.equal(configurationSource.includes("Onglets à importer"), true); + assert.equal(configurationSource.includes("Choisir les onglets de collection"), true); + assert.equal(configurationSource.includes("Tout importer sauf certains onglets"), true); + assert.equal(configurationSource.includes("Listez les onglets à ignorer"), true); + assert.equal(configurationSource.includes("Un onglet dédié à la liste de souhaits doit être exclu ici"), true); + assert.equal(configurationSource.includes("role=\"tablist\""), true); + assert.equal(configurationSource.includes("activeSheetTab"), true); +}); + +test("les aides d'import centralisent les listes longues des champs controles", () => { + const fieldHelpSource = readFileSync( + new URL("../src/components/ImportFieldHelp.jsx", import.meta.url), + "utf8", + ); + const onboardingStyleSource = readFileSync( + new URL("../src/styles/collection-onboarding.css", import.meta.url), + "utf8", + ); + + assert.equal(fieldHelpSource.includes("function ImportFieldHelp"), true); + assert.equal(fieldHelpSource.includes("\"Plus d'info\""), true); + assert.equal(fieldHelpSource.includes("formatAdditionalHelp"), true); + assert.equal(fieldHelpSource.includes("État physique du jeu"), true); + assert.equal(fieldHelpSource.includes("Les libellés proches sont rapprochés automatiquement"), true); + assert.equal(fieldHelpSource.includes("Exemples : Mauvais, Correct, Bon"), true); + assert.equal(fieldHelpSource.includes("Factory sealed, Unused"), false); + assert.equal(fieldHelpSource.includes("\"JAP\", \"US\", \"EU-FR\", \"EU-UK\""), true); + assert.equal(fieldHelpSource.includes("\"PAL - FR\", \"PAL - EUR\", \"EUR - PAL\""), true); + assert.equal(fieldHelpSource.includes("\"Oui\", \"O\", \"Yes\", \"Y\", \"True\""), true); + assert.equal(fieldHelpSource.includes("\"Non\", \"N\", \"No\", \"False\""), true); + assert.equal(onboardingStyleSource.includes("-webkit-line-clamp: 2"), true); + assert.equal(onboardingStyleSource.includes(".fieldHelpToggle:hover:not(:disabled)"), true); +}); + +test("affiche les options globales seulement quand prix ou note sont configures", () => { + const configuration = createDefaultImportConfiguration(); + + assert.equal(hasSpreadsheetImportColumn(configuration, "purchase_price"), false); + assert.equal(hasSpreadsheetImportColumn(configuration, "grade"), false); + + configuration.singleSheetLayout.columns.purchase_price = "E"; + assert.equal(hasSpreadsheetImportColumn(configuration, "purchase_price"), true); + assert.equal(hasSpreadsheetImportColumn(configuration, "grade"), false); + + configuration.singleSheetLayout.columns.purchase_price = ""; + configuration.wishlist.mode = "sheet"; + configuration.wishlist.layout.columns.grade = "F"; + assert.equal(hasSpreadsheetImportColumn(configuration, "grade"), true); + + configuration.wishlist.mode = "none"; + assert.equal(hasSpreadsheetImportColumn(configuration, "grade"), false); + + configuration.fileType = "csv"; + configuration.csvMapping.purchase_price = "Prix"; + assert.equal(hasCsvImportColumn(configuration, "purchase_price"), true); + assert.equal(hasCsvImportColumn(configuration, "grade"), false); +}); + +test("ignore la base de notation invalide quand aucune colonne note n'est configuree", () => { + const spreadsheetConfiguration = createDefaultImportConfiguration(); + spreadsheetConfiguration.ratingBase = "0"; + + const spreadsheetResult = buildImportConfigurationDescription(spreadsheetConfiguration); + + assert.deepEqual(spreadsheetResult.errors, []); + assert.equal(spreadsheetResult.description.rating_base, 10); + + const csvConfiguration = createDefaultImportConfiguration(); + csvConfiguration.fileType = "csv"; + csvConfiguration.csvMapping.name = "Jeu"; + csvConfiguration.csvMapping.platform = "Plateforme"; + csvConfiguration.ratingBase = "0"; + + const csvResult = buildImportConfigurationDescription(csvConfiguration); + + assert.deepEqual(csvResult.errors, []); + assert.equal(csvResult.description.rating_base, 10); +}); + +test("serialise le formulaire tableur mono-onglet avec colonnes optionnelles et wishlist colonne", () => { + const configuration = createDefaultImportConfiguration(); + configuration.priceUnit = "USD"; + configuration.ratingBase = "20"; + configuration.wishlist.mode = "column"; + configuration.singleSheetLayout = { + dataRange: "b2:r99", + headerRow: "2", + columns: { + ...configuration.singleSheetLayout.columns, + name: "b", + platform: "c", + studio: "d", + release_date: "e", + wishlist: "f", + purchase_price: "g", + buy_location: "h", + buy_date: "i", + grade: "j", + condition: "k", + has_manual: "l", + is_collector: "m", + has_steelbook: "n", + is_digital: "o", + region: "p", + description: "q", + }, + }; + + const { description, errors } = buildImportConfigurationDescription(configuration); + + assert.deepEqual(errors, []); + assert.deepEqual(description, { + file_type: "libreoffice_ods", + price_unit: "USD", + rating_base: 20, + wishlist: { mode: "column" }, + single_sheet_conf: { + data_range: "B2:R99", + header_row: 2, + column_information: { + name: "B", + platform: "C", + wishlist: "F", + studio: "D", + release_date: "E", + purchase_price: "G", + buy_location: "H", + buy_date: "I", + grade: "J", + condition: "K", + has_manual: "L", + is_collector: "M", + has_steelbook: "N", + is_digital: "O", + region: "P", + description: "Q", + }, + }, + }); +}); + +test("serialise le formulaire tableur multi-onglets avec layout partage et onglets inclus", () => { + const configuration = createDefaultImportConfiguration(); + configuration.multipleSheets = true; + configuration.sharedLayout = true; + configuration.sharedSheetLayout = { + ...configuration.sharedSheetLayout, + dataRange: "a3:f42", + headerRow: "3", + sheetSelectionMode: "included", + includedSheets: "Switch, PlayStation 2\nGameCube", + columns: { + ...configuration.sharedSheetLayout.columns, + name: "a", + studio: "b", + release_date: "c", + purchase_price: "d", + grade: "e", + }, + }; + + const { description, errors } = buildImportConfigurationDescription(configuration); + + assert.deepEqual(errors, []); + assert.deepEqual(description, { + file_type: "libreoffice_ods", + price_unit: "EUR", + rating_base: 10, + wishlist: { mode: "none" }, + multiple_sheets_conf: { + sheet_information: "platform", + shared_layout: { + data_range: "A3:F42", + header_row: 3, + column_information: { + name: "A", + studio: "B", + release_date: "C", + purchase_price: "D", + grade: "E", + }, + included_sheets: ["Switch", "PlayStation 2", "GameCube"], + }, + }, + }); +}); + +test("serialise le formulaire tableur multi-onglets avec layout partage et onglets exclus", () => { + const configuration = createDefaultImportConfiguration(); + configuration.multipleSheets = true; + configuration.sharedLayout = true; + configuration.sharedSheetLayout = { + ...configuration.sharedSheetLayout, + sheetSelectionMode: "excluded", + excludedSheets: ["Sommaire", "Statistiques"], + columns: { + ...configuration.sharedSheetLayout.columns, + name: "a", + region: "d", + }, + }; + + const { description, errors } = buildImportConfigurationDescription(configuration); + + assert.deepEqual(errors, []); + assert.deepEqual(description.multiple_sheets_conf.shared_layout, { + data_range: "A1:D200", + header_row: 1, + column_information: { + name: "A", + studio: "B", + release_date: "C", + region: "D", + }, + excluded_sheets: ["Sommaire", "Statistiques"], + }); + assert.equal(description.multiple_sheets_conf.shared_layout.included_sheets, undefined); +}); + +test("serialise le formulaire tableur multi-onglets avec configuration par onglet", () => { + const configuration = createDefaultImportConfiguration(); + configuration.multipleSheets = true; + configuration.sharedLayout = false; + configuration.wishlist.mode = "column"; + configuration.sheets = [ + { + sheetName: "Switch", + sheetInformation: "platform", + layout: { + dataRange: "a1:e10", + headerRow: "1", + columns: { name: "a", wishlist: "b", grade: "c" }, + }, + }, + { + sheetName: "PlayStation 2", + sheetInformation: "platform", + layout: { + dataRange: "b4:h40", + headerRow: "4", + columns: { name: "b", wishlist: "c", condition: "d" }, + }, + }, + ]; + + const { description, errors } = buildImportConfigurationDescription(configuration); + + assert.deepEqual(errors, []); + assert.deepEqual(description.multiple_sheets_conf, { + sheets: [ + { + sheet_name: "Switch", + sheet_information: "platform", + data_range: "A1:E10", + header_row: 1, + column_information: { name: "A", wishlist: "B", grade: "C" }, + }, + { + sheet_name: "PlayStation 2", + sheet_information: "platform", + data_range: "B4:H40", + header_row: 4, + column_information: { name: "B", wishlist: "C", condition: "D" }, + }, + ], + }); +}); + +test("serialise le formulaire CSV sans liste de souhaits", () => { + const configuration = createDefaultImportConfiguration(); + configuration.fileType = "csv"; + configuration.csvMapping = { + ...configuration.csvMapping, + name: "Nom", + platform: "Plateforme", + studio: "Studio", + purchase_price: "Prix", + grade: "Note", + }; + configuration.priceUnit = "JPY"; + configuration.ratingBase = "100"; + + const { description, errors } = buildImportConfigurationDescription(configuration); + + assert.deepEqual(errors, []); + assert.deepEqual(description, { + file_type: "csv", + price_unit: "JPY", + rating_base: 100, + wishlist: { mode: "none" }, + mapping: { + name: "Nom", + platform: "Plateforme", + studio: "Studio", + purchase_price: "Prix", + grade: "Note", + }, + }); +}); + +test("serialise le formulaire CSV avec liste de souhaits portee par une colonne", () => { + const configuration = createDefaultImportConfiguration(); + configuration.fileType = "csv"; + configuration.wishlist.mode = "column"; + configuration.csvMapping = { + ...configuration.csvMapping, + name: "Titre", + platform: "Console", + wishlist: "Liste de souhaits", + release_date: "Sortie", + condition: "Etat", + }; + + const { description, errors } = buildImportConfigurationDescription(configuration); + + assert.deepEqual(errors, []); + assert.deepEqual(description, { + file_type: "csv", + price_unit: "EUR", + rating_base: 10, + wishlist: { mode: "column" }, + mapping: { + name: "Titre", + platform: "Console", + wishlist: "Liste de souhaits", + release_date: "Sortie", + condition: "Etat", + }, + }); +}); diff --git a/frontend/tests/importSheetInformationNone.test.js b/frontend/tests/importSheetInformationNone.test.js new file mode 100644 index 0000000..b2d5e3a --- /dev/null +++ b/frontend/tests/importSheetInformationNone.test.js @@ -0,0 +1,67 @@ +/* + * Projet : CloudCollectionApp + * Date de creation : 2026-08-20 + * Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien + * Licence : Apache 2.0 + * Description : tests du mode multi-onglets sans information portee par l'onglet. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + buildImportConfigurationDescription, + collectionColumnFields, + createDefaultImportConfiguration, +} from "../src/hooks/collection/importConfigurationBuilder.js"; + +test("serialise un layout partage multi-onglets sans information d'onglet", () => { + const configuration = createDefaultImportConfiguration(); + configuration.multipleSheets = true; + configuration.sheetInformation = ""; + configuration.sharedSheetLayout.columns.platform = "B"; + + const { description, errors } = buildImportConfigurationDescription(configuration); + + assert.deepEqual(errors, []); + assert.equal(description.multiple_sheets_conf.sheet_information, undefined); + assert.equal( + description.multiple_sheets_conf.shared_layout.column_information.platform, + "B" + ); + assert.equal(collectionColumnFields(configuration, false).includes("platform"), true); +}); + +test("serialise des layouts par onglet sans information d'onglet", () => { + const configuration = createDefaultImportConfiguration(); + configuration.multipleSheets = true; + configuration.sharedLayout = false; + configuration.sheetInformation = ""; + configuration.sheets = [{ + sheetName: "Jeux 2026", + layout: { + dataRange: "A1:C50", + headerRow: "1", + columns: { name: "A", platform: "B", studio: "C" }, + }, + }]; + + const { description, errors } = buildImportConfigurationDescription(configuration); + + assert.deepEqual(errors, []); + assert.equal(description.multiple_sheets_conf.sheets[0].sheet_information, undefined); + assert.deepEqual(description.multiple_sheets_conf.sheets[0].column_information, { + name: "A", + platform: "B", + studio: "C", + }); +}); + +test("refuse le mode sans information quand la colonne plateforme manque", () => { + const configuration = createDefaultImportConfiguration(); + configuration.multipleSheets = true; + configuration.sheetInformation = ""; + + const { description, errors } = buildImportConfigurationDescription(configuration); + + assert.equal(description, null); + assert.equal(errors.includes("Renseignez la colonne platform."), true); +}); diff --git a/frontend/tests/importSheetSelection.test.js b/frontend/tests/importSheetSelection.test.js new file mode 100644 index 0000000..e805625 --- /dev/null +++ b/frontend/tests/importSheetSelection.test.js @@ -0,0 +1,72 @@ +/* + * Projet : CloudCollectionApp + * Date de creation : 2026-08-18 + * Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien + * Licence : Apache 2.0 + * Description : tests frontend de deduction des onglets d'import de collection. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + resolveCollectionSheetNames, + synchronizePerSheetConfigurations, +} from "../src/hooks/collection/importSheetSelection.js"; +import { createDefaultImportConfiguration } from "../src/hooks/collection/importConfigurationBuilder.js"; + +test("deduit les onglets de collection depuis les onglets inclus", () => { + const configuration = createDefaultImportConfiguration(); + configuration.sharedSheetLayout.sheetSelectionMode = "included"; + configuration.sharedSheetLayout.includedSheets = "Switch, PlayStation 2\nGameCube"; + + assert.deepEqual( + resolveCollectionSheetNames(["Switch", "Wishlist"], configuration.sharedSheetLayout), + ["Switch", "PlayStation 2", "GameCube"] + ); +}); + +test("deduit tous les onglets de collection avec le mode tous", () => { + const configuration = createDefaultImportConfiguration(); + + assert.deepEqual( + resolveCollectionSheetNames(["Switch", "Wishlist"], configuration.sharedSheetLayout), + ["Switch", "Wishlist"] + ); +}); + +test("deduit les onglets de collection en excluant les onglets non collection", () => { + const configuration = createDefaultImportConfiguration(); + configuration.sharedSheetLayout.sheetSelectionMode = "excluded"; + configuration.sharedSheetLayout.excludedSheets = ["Wishlist", "Sommaire"]; + + assert.deepEqual( + resolveCollectionSheetNames(["Switch", "Wishlist", "Sommaire", "GameCube"], configuration.sharedSheetLayout), + ["Switch", "GameCube"] + ); +}); + +test("synchronise les configurations par onglet en conservant les saisies existantes", () => { + const configuration = createDefaultImportConfiguration(); + configuration.sheets = [ + { + sheetName: "Switch", + sheetInformation: "platform", + layout: { dataRange: "B2:D8", headerRow: "2", columns: { name: "B" } }, + }, + ]; + configuration.sharedSheetLayout.sheetSelectionMode = "included"; + configuration.sharedSheetLayout.includedSheets = ["Switch", "GameCube"]; + + const synchronizedConfiguration = synchronizePerSheetConfigurations( + configuration, + ["Switch", "GameCube", "Wishlist"], + createDefaultImportConfiguration().sheets[0] + ); + + assert.deepEqual( + synchronizedConfiguration.sheets.map((sheet) => sheet.sheetName), + ["Switch", "GameCube"] + ); + assert.equal(synchronizedConfiguration.sheets[0].layout.dataRange, "B2:D8"); + assert.equal(synchronizedConfiguration.sheets[0].layout.columns.name, "B"); + assert.equal(synchronizedConfiguration.sheets[1].layout.columns.name, "A"); +}); diff --git a/frontend/tests/userCollectionImportNavigation.test.js b/frontend/tests/userCollectionImportNavigation.test.js new file mode 100644 index 0000000..1cd3721 --- /dev/null +++ b/frontend/tests/userCollectionImportNavigation.test.js @@ -0,0 +1,33 @@ +/* + * Projet : CloudCollectionApp + * Date de creation : 2026-08-23 + * Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien + * Licence : Apache 2.0 + * Description : tests frontend de navigation vers l'import de collection utilisateur. + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +test("ouvrir un nouvel import depuis Configuration reinitialise le formulaire", () => { + const onboardingHookSource = readFileSync( + new URL("../src/hooks/collection/useUserCollectionOnboarding.js", import.meta.url), + "utf8", + ); + const viewModelSource = readFileSync( + new URL("../src/hooks/app/useCloudCollectionViewModel.js", import.meta.url), + "utf8", + ); + + assert.equal( + onboardingHookSource.includes("prepareNewCollectionImport: resetOnboardingState"), + true, + ); + assert.equal(viewModelSource.includes("const openNewCollectionImport = () =>"), true); + assert.equal( + viewModelSource.includes("userCollectionOnboarding.prepareNewCollectionImport();"), + true, + ); + assert.equal(viewModelSource.includes("navigation.openCollectionOnboarding();"), true); + assert.equal(viewModelSource.includes("openCollectionOnboarding: openNewCollectionImport"), true); +}); diff --git a/frontend/tests/userImportSummaryNotice.test.js b/frontend/tests/userImportSummaryNotice.test.js index 6a4a7a6..8e55dbb 100644 --- a/frontend/tests/userImportSummaryNotice.test.js +++ b/frontend/tests/userImportSummaryNotice.test.js @@ -33,9 +33,77 @@ test("user import summary displays private collection and admin validation notic assert.equal(summarySource.includes("contributionNotice"), true); assert.equal(summarySource.includes("importContributionNotice"), true); + assert.equal(summarySource.includes("Jeux avec erreur"), true); + assert.equal(summarySource.includes("formatInvalidGamesRatio"), true); + assert.equal(summarySource.includes("invalidGamesCount > 0"), true); + assert.equal(styleSource.includes("importErrorCounterAccepted"), true); + assert.equal(styleSource.includes("importErrorCounterRefused"), true); assert.equal(onboardingSource.includes("UserImportContributionNotice"), true); - assert.equal(onboardingSource.includes("collection privee"), true); - assert.equal(onboardingSource.includes("Bibliotheque commune apres validation"), true); + assert.equal(onboardingSource.includes("collection privée"), true); + assert.equal(onboardingSource.includes("Bibliothèque commune après validation"), true); assert.equal(onboardingSource.includes("Merci pour votre contribution."), true); assert.equal(styleSource.includes(".importContributionNotice"), true); }); + +test("user import summary displays readable skipped platform reason", () => { + const summarySource = readFileSync( + new URL("../src/components/ImportSummary.jsx", import.meta.url), + "utf8", + ); + + assert.equal(summarySource.includes("labels[reason] || reason"), true); + assert.equal(summarySource.includes("groupSkippedGamesByPlatformAndCause"), true); + assert.equal(summarySource.includes("warning.message || warning.reason"), true); + assert.equal(summarySource.includes("Jeux non importés"), true); + assert.equal(summarySource.includes("corriger votre fichier puis le réimporter"), true); +}); + +test("user import summary prefers simplified platform warnings", () => { + const summarySource = readFileSync( + new URL("../src/components/ImportSummary.jsx", import.meta.url), + "utf8", + ); + + assert.equal(summarySource.includes("user_platform_matches"), true); + assert.equal(summarySource.includes("user_skipped_games"), true); + assert.equal(summarySource.includes("groupPlatformWarningsByPlatformAndCause"), true); + assert.equal(summarySource.includes("platformMatchesCount"), true); + assert.equal(summarySource.includes("Jeux à vérifier"), true); + assert.equal(summarySource.includes("formatPlatformRefusal"), true); + assert.equal(summarySource.includes("invalidAssociatedGames"), true); + assert.equal(summarySource.includes("Plateforme dans votre fichier"), true); + assert.equal(summarySource.includes("Plateformes à vérifier par un admin"), true); + assert.equal(summarySource.includes("ces jeux sont importés"), true); + assert.equal(summarySource.includes("Jeux en attente de validation admin"), true); +}); + +test("user import summary marks invalid field values as refused", () => { + const summarySource = readFileSync( + new URL("../src/components/ImportSummary.jsx", import.meta.url), + "utf8", + ); + const styleSource = readFileSync( + new URL("../src/styles/collection-onboarding.css", import.meta.url), + "utf8", + ); + + assert.equal(summarySource.includes("groupInvalidFieldsByField"), true); + assert.equal(summarySource.includes("Champ ignoré"), true); + assert.equal(summarySource.includes("Valeurs refusées dans votre fichier"), true); + assert.equal(summarySource.includes('"{game.value}" Valeur refusée'), true); + assert.equal(styleSource.includes('list-style-type: "› "'), true); + assert.equal(summarySource.includes("fetchImportInvalidValueHelp"), true); + assert.equal(summarySource.includes("Plus d'info"), true); + assert.equal(summarySource.includes("Masquer"), true); + assert.equal(summarySource.includes("isOpen: !currentDetails.isOpen"), true); +}); + +test("user collection api exposes invalid value help endpoint", () => { + const apiSource = readFileSync( + new URL("../src/services/UserCollectionApi.js", import.meta.url), + "utf8", + ); + + assert.equal(apiSource.includes("fetchImportInvalidValueHelp"), true); + assert.equal(apiSource.includes("/api/users/import/invalid-value-help?"), true); +}); diff --git a/tasks/0.4.2/improve_import_onboarding/import_onboarding.md b/tasks/0.4.2/improve_import_onboarding/import_onboarding.md new file mode 100644 index 0000000..386fe80 --- /dev/null +++ b/tasks/0.4.2/improve_import_onboarding/import_onboarding.md @@ -0,0 +1,11 @@ +Il faut améliorer l'écran d'import d'un fichier de collection utilisateur pour le rendre plus compréhensible : + - Ajouter une étape 2 : Configurer votre import + - Ne plus demander le type de fichier, le detecter automatiquement en fonction du fichier importé et son extension. + +Chaque étape de la saisie des informations doit être plus détaillée. +Chaque colonne à configurer obligatoirement doit être bien visible et chaque colonne du fichier doit être décrite pour indiquer le type de valeurs attendues dans les colonnes. + +De manière générale, il faut que ce formulaire de saisie soit très clair pour un utilisateur ne connaissant pas du tout le site. +Il faut que chaque étape soit bien séparé pour ne pas perdre l'utilisateur avec trop d'informations demandées en meme temps. + +Tout ceci, pour chaque type de fichier importable. \ No newline at end of file diff --git a/tasks/0.4.2/improve_import_unboarding/import_unboarding.md b/tasks/0.4.2/improve_import_unboarding/import_unboarding.md deleted file mode 100644 index 3923381..0000000 --- a/tasks/0.4.2/improve_import_unboarding/import_unboarding.md +++ /dev/null @@ -1,3 +0,0 @@ -Il faut améliorer l'écran d'import d'un fichier de collection utilisateur pour le rendre plus compréhensible : - - Ajouter une étape 2 : Configurer votre import - - Ne plus demander le type de fichier, le detecter automatiquement en fonction du fichier importé et son extension. \ No newline at end of file