Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,514 changes: 0 additions & 1,514 deletions backend/resources/2-collection.csv

This file was deleted.

39 changes: 39 additions & 0 deletions backend/resources/collection_import_refusal_email_template.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="fr">
<body>
<h1>Import refuse</h1>

<h2>Contexte</h2>
<table border="1" cellpadding="6" cellspacing="0">
<tbody>
<tr><th>Type d'import</th><td>${import_kind}</td></tr>
<tr><th>Utilisateur demandeur</th><td>${requester_user_id}</td></tr>
<tr><th>Email demandeur</th><td>${requester_email}</td></tr>
<tr><th>Type de fichier</th><td>${file_type}</td></tr>
<tr><th>Nom du fichier</th><td>${original_filename}</td></tr>
</tbody>
</table>

<h2>Raison du rejet</h2>
<p>${message}</p>
<p>Code raison: ${reason}</p>
<p>Jeux en erreur: ${invalid_games_count}/${total_games_count}</p>

<h2>Resultat de l'import</h2>
<ul>
<li>Plateformes rattachees: ${linked_platforms}</li>
<li>Studios crees: ${created_studios}</li>
<li>Jeux crees: ${created_games}</li>
<li>Jeux associes: ${associated_games}</li>
<li>Jeux en liste de souhaits: ${wishlisted_games}</li>
</ul>

<h2>Erreurs des jeux</h2>
${invalid_games}

<h2>Warnings</h2>
<pre>
${warnings}
</pre>
</body>
</html>
538 changes: 538 additions & 0 deletions backend/resources/super-nintendo-europe.csv

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions backend/services/collection/imports/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@
CollectionImportStudio,
CollectionImportWarnings,
)
from .collection_import_refusal_policy import (
CollectionImportRefusal,
CollectionImportRefusalPolicy,
)
from .collection_import_refusal_admin_notifier import CollectionImportRefusalAdminNotifier
from .collection_import_refusal_context import CollectionImportRefusalContext
from .collection_import_refusal_notification_service import (
CollectionImportRefusalNotificationService,
)
from .collection_import_date_validator import CollectionImportDateValidator
from .collection_import_value_mapper import CollectionImportValueMapper
from .condition_matching_configuration import ConditionMatchingConfiguration
Expand All @@ -69,6 +78,11 @@
"CollectionImportField",
"CollectionImportGame",
"CollectionImportPlatform",
"CollectionImportRefusal",
"CollectionImportRefusalAdminNotifier",
"CollectionImportRefusalContext",
"CollectionImportRefusalNotificationService",
"CollectionImportRefusalPolicy",
"CollectionImportStudio",
"CollectionImportWarnings",
"CollectionImportValueMapper",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# ____ _ _ ____ _ _ _ _ ___
# / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __
# | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ |
# | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) |
# \____|_|\___/ \__,_|\__,_|\____\___|_|_|\___|\___|\__|_|\___/|_| |_|\___/|_| |_|\___/
# Projet : CloudCollectionApp
# Date de creation : 2026-08-11
# Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien
# Licence : Apache 2.0
#
# Description : notification administrateur d'un import refuse apres lecture.

import os
from html import escape
from pathlib import Path

from services.email import EmailConfiguration, EmailSenderFactory, EmailTemplateRenderer

from .collection_import_refusal_context import CollectionImportRefusalContext


class CollectionImportRefusalAdminNotifier:
"""Envoie un email administrateur quand un import est refuse."""

def __init__(
self,
email_sender=None,
admin_notification_email: str | None = None,
template_path: str | Path | None = None,
template_renderer: EmailTemplateRenderer | None = None,
):
"""Initialise le notifier de refus d'import.

Args:
email_sender (object | None): Expediteur email injectable.
admin_notification_email (str | None): Adresse administrateur destinataire.
template_path (str | Path | None): Chemin optionnel du template HTML.
template_renderer (EmailTemplateRenderer | None): Moteur de rendu injectable.

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()
self.template_path = Path(template_path) if template_path else self.default_template_path()
self.template_renderer = template_renderer or EmailTemplateRenderer()

@classmethod
def default_template_path(cls) -> Path:
"""Retourne le chemin du template email par defaut.

Args:
Aucun.

Returns:
Path: Chemin du template stocke dans `backend/resources`.
"""

return (
EmailTemplateRenderer.default_resources_directory()
/ "collection_import_refusal_email_template.txt"
)

def is_enabled(self) -> bool:
"""Indique si une notification de refus peut etre envoyee.

Args:
Aucun.

Returns:
bool: `True` lorsqu'une adresse administrateur est configuree.
"""

return bool(self.admin_notification_email)

def notify_import_refusal(self, context: CollectionImportRefusalContext) -> None:
"""Envoie le rapport de refus d'import a l'administrateur.

Args:
context (CollectionImportRefusalContext): Contexte complet du refus.

Returns:
None: La methode ne retourne aucune valeur.
"""

if not self.admin_notification_email:
return
email_sender = self.email_sender or EmailSenderFactory.create(
EmailConfiguration.from_environment()
)
email_sender.send_email(
recipient_email=self.admin_notification_email,
subject=f"Import refuse - {context.import_kind}",
body=self._build_email_body(context),
content_subtype="html",
)

def _build_email_body(self, context: CollectionImportRefusalContext) -> str:
warnings = context.import_data.warnings
return self.template_renderer.render(
self.template_path,
{
"import_kind": escape(context.import_kind),
"requester_user_id": self._requester_user_id(context.requester_user_id),
"requester_email": escape(context.requester_email or "inconnu"),
"file_type": escape(context.file_type or "inconnu"),
"original_filename": escape(context.original_filename or "inconnu"),
"reason": escape(str(context.refusal.get("reason") or "")),
"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),
"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),
},
)

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 [])}",
]
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)

def _invalid_games_html(self, invalid_games: list[dict]) -> str:
if not invalid_games:
return "<p>Aucun jeu avec information invalide.</p>"
rows = []
for invalid_game in invalid_games:
rows.append(
"<tr>"
f"<td>{escape(str(invalid_game.get('name') or ''))}</td>"
f"<td>{escape(self._invalid_fields_text(invalid_game.get('invalid_fields') or []))}</td>"
"</tr>"
)
return (
'<table border="1" cellpadding="6" cellspacing="0">'
"<thead><tr><th>Jeu</th><th>Erreurs</th></tr></thead><tbody>"
+ "".join(rows)
+ "</tbody></table>"
)

def _invalid_fields_text(self, invalid_fields: list[dict]) -> str:
values = []
for invalid_field in invalid_fields:
field_name = str(invalid_field.get("field") or "")
field_value = str(invalid_field.get("value") or "")
values.append(f"{field_name}: {field_value}" if field_value else field_name)
return ", ".join(values)
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# ____ _ _ ____ _ _ _ _ ___
# / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __
# | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ |
# | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) |
# \____|_|\___/ \__,_|\__,_|\____\___|_|_|\___|\___|\__|_|\___/|_| |_|\___/|_| |_|\___/
# Projet : CloudCollectionApp
# Date de creation : 2026-08-11
# Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien
# Licence : Apache 2.0
#
# Description : contexte de notification administrateur d'un import refuse.

from dataclasses import dataclass

from .collection_import_models import CollectionImportData


@dataclass(frozen=True)
class CollectionImportRefusalContext:
"""Regroupe les informations envoyees quand un import est refuse.

Attributes:
import_kind (str): Type fonctionnel d'import.
requester_user_id (int | None): Identifiant utilisateur demandeur si connu.
requester_email (str): Email ou sujet d'authentification du demandeur.
file_type (str): Type de fichier importe.
original_filename (str): Nom original du fichier.
refusal (dict): Decision de refus serialisable.
import_data (CollectionImportData): Donnees lues et warnings de l'import.
"""

import_kind: str
requester_user_id: int | None
requester_email: str
file_type: str
original_filename: str
refusal: dict
import_data: CollectionImportData
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# ____ _ _ ____ _ _ _ _ ___
# / ___| | ___ _ _ __| |/ ___|___ | | | ___ ___| |_(_) ___ _ __ / _ \ _ __ _ __
# | | | |/ _ \| | | |/ _` | | / _ \| | |/ _ \/ __| __| |/ _ \| `_ \| | | | `_ \| `_ |
# | |___| | (_) | |_| | (_| | |__| (_) | | | __/ (__| |_| | (_) | | | | |_| | |_) | |_) |
# \____|_|\___/ \__,_|\__,_|\____\___|_|_|\___|\___|\__|_|\___/|_| |_|\___/|_| |_|\___/
# Projet : CloudCollectionApp
# Date de creation : 2026-08-11
# Auteurs : OpenAI ChatGPT, Codex, Binda Sébastien
# Licence : Apache 2.0
#
# Description : orchestration non bloquante des notifications d'import refuse.

from .collection_import_models import CollectionImportData
from .collection_import_refusal_context import CollectionImportRefusalContext


class CollectionImportRefusalNotificationService:
"""Construit et envoie les notifications administrateur de refus d'import."""

def notify_refusal(
self,
refusal_notifier: object,
logger: object,
import_kind: str,
requester_user_id: int | None,
requester_email: str,
file_type: str,
original_filename: str,
refusal: dict,
import_data: CollectionImportData,
) -> None:
"""Envoie une notification de refus sans perturber la reponse d'import.

Args:
refusal_notifier (object): Notifier expose par la configuration d'import.
logger (object): Logger utilise si l'envoi echoue.
import_kind (str): Type fonctionnel d'import concerne.
requester_user_id (int | None): Identifiant du demandeur si disponible.
requester_email (str): Email ou sujet d'authentification du demandeur.
file_type (str): Type de fichier concerne.
original_filename (str): Nom de fichier transmis par le client.
refusal (dict): Decision de refus serialisable.
import_data (CollectionImportData): Donnees lues et warnings.

Returns:
None: La methode ne retourne aucune valeur.
"""

notify_import_refusal = getattr(refusal_notifier, "notify_import_refusal", None)
if not callable(notify_import_refusal):
return
try:
notify_import_refusal(
CollectionImportRefusalContext(
import_kind=import_kind,
requester_user_id=requester_user_id,
requester_email=str(requester_email or ""),
file_type=str(file_type or ""),
original_filename=str(original_filename or ""),
refusal=refusal,
import_data=import_data,
)
)
except Exception:
logger.exception("Impossible d'envoyer le rapport de refus d'import.")
Loading
Loading