MissawB commited on
Commit
0dfadbd
·
verified ·
1 Parent(s): 2ad1350

Upload folder using huggingface_hub

Browse files
.gitignore CHANGED
@@ -144,6 +144,9 @@ data/processed/failed_attempts.json
144
  # dbt build artifacts & local state
145
  backend/pipeline/dbt_project/target/
146
  backend/pipeline/dbt_project/.user.yml
 
 
 
147
 
148
  # Uploaded media (generated at runtime, not source)
149
  backend/api/media/
@@ -151,3 +154,8 @@ backend/api/media/
151
  # Frontend test coverage reports
152
  frontend/coverage/
153
  coverage/
 
 
 
 
 
 
144
  # dbt build artifacts & local state
145
  backend/pipeline/dbt_project/target/
146
  backend/pipeline/dbt_project/.user.yml
147
+ # profiles.yml est GÉNÉRÉ par run_data_quality_tests (chemins absolus locaux) :
148
+ # le tracker garantissait un working tree sale après chaque run.
149
+ backend/pipeline/dbt_project/profiles.yml
150
 
151
  # Uploaded media (generated at runtime, not source)
152
  backend/api/media/
 
154
  # Frontend test coverage reports
155
  frontend/coverage/
156
  coverage/
157
+ # Backend coverage report (pytest --cov)
158
+ coverage.xml
159
+
160
+ # Catalogue offline généré au runtime (SQLite), pas une source
161
+ backend/api/animetix/static/animetix/data/
TODO.md CHANGED
@@ -6,44 +6,17 @@
6
 
7
  ## 🔴 Critiques
8
 
9
- - [x] **Prod — secrets réels embarqués dans l'image Docker** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-05, détail archivé dans [docs/HISTORY.md](docs/HISTORY.md))_
10
- - ✅ Le `.env` racine ne part plus dans l'image : `.env` exclu de [.gcloudignore](.gcloudignore) (vérifié via `gcloud meta list-files-for-upload` — seul `.env.example` reste), [.dockerignore](.dockerignore) **racine** créé (`.env` + `**/.env` — celui que Docker lit vraiment), config morte `deploy/.dockerignore` supprimée. Le `read_env` de [settings.py:14-16](backend/api/animetix_project/settings.py#L14-L16) devient inoffensif (fichier absent de l'image, ignoré silencieusement).
11
- - ✅ Second canal de fuite fermé : [hf_deploy.py](scripts/deploy/huggingface/hf_deploy.py) exclut `.env`/`**/.env` **inconditionnellement** (indépendamment de l'ignore fourni par l'appelant) ; vérifié via l'API Hub que le Space n'a jamais contenu de `.env` réel.
12
- - [ ] **Prod — secrets manquants sur `animetix-web` + hygiène post-fuite** _(découvert lors de la remédiation `.env` du 2026-07-05 — la fuite elle-même est close et vérifiée, archivée dans [docs/HISTORY.md](docs/HISTORY.md))_
13
- - ⏳ **Monter les secrets sur le service web** (action manuelle — bloquée en session par permission) : l'image prod, CI-buildée et donc sans `.env`, tourne **sans** `NEO4J_URI/USER/PASSWORD` (lues par [neo4j_client.py:17-19](backend/pipeline/neo4j_client.py#L17-L19) — features graphe/Oracle dégradent en silence) ni `HUGGINGFACE_API_KEY` ([qwen3_vl_adapter.py:34](backend/adapters/inference/qwen3_vl_adapter.py#L34)) ; `HF_SPACES` y est un littéral au lieu d'un secretKeyRef. Les secrets existent déjà en SM (montés sur les 8 jobs). Exécuter :
14
- ```
15
- gcloud run services update animetix-web --region europe-west9 --project animetix \
16
- --remove-env-vars=HF_SPACES \
17
- --update-secrets=HF_SPACES=HF_SPACES:latest,HUGGINGFACE_API_KEY=HF_TOKEN:latest,NEO4J_URI=NEO4J_URI:latest,NEO4J_USER=NEO4J_USER:latest,NEO4J_PASSWORD=NEO4J_PASSWORD:latest
18
- ```
19
- (Sans risque : nouvelle révision, même image, rollback instantané. [ci.yml](.github/workflows/ci.yml) déploie avec `--update-env-vars` → ne wipe rien.)
20
- - ⏳ **Hygiène historique** : purger les anciens tags de `animetix-repo/web` buildés localement avant l'exclusion (peuvent contenir le `.env`) ; révoquer `TRIPO_API_KEY` et `MAPBOX_TOKEN` (valeurs réelles dans le `.env` local, consommées nulle part dans le code).
21
- - [x] **Sécu — SQL généré par LLM exécuté en base (auto-flaggé HIGH-RISK)** _(audit dette 2026-07-05 ; **clos** le 2026-07-05 par suppression, archivé dans [docs/HISTORY.md](docs/HISTORY.md))_
22
- - Code mort de bout en bout (zéro appelant de production, flag off, prod sur Neon) → feature supprimée plutôt que durcie : port + 3 adapters, `sql_guard.py` + fuzzing + script d'audit, settings `ALLOYDB_NL_*`, dépendance `sqlglot`. Spec : [docs/plans/2026-07-05-remove-text-to-sql-design.md](docs/plans/2026-07-05-remove-text-to-sql-design.md).
23
- - [ ] **Prod — WebSockets cassés (500 sur chaque handshake)** _(audit dette 2026-07-05 ; **corrigé en code** le 2026-07-05, reste le deploy)_
24
- - ✅ **Cause racine confirmée par les logs Cloud Run** (repro live, trace `b03e26f1…`) : `ModuleNotFoundError: No module named 'channels_redis'` — le backend Redis était sélectionné dès que `REDIS_URL` existe (prod), mais **le paquet n'a jamais été dans requirements** ; en local, pas de `REDIS_URL` → fallback InMemory → invisible. Les hypothèses TLS et WSGI étaient toutes deux secondaires.
25
- - ✅ **Fix committé** : `channels-redis==4.3.0` ajouté (lock régénéré sans dérive) ; [channels_config.py](backend/api/animetix_project/channels_config.py) extrait la construction du layer — **fail-fast** `ImproperlyConfigured` si `REDIS_URL` manque en prod (l'InMemory par-process casse le fan-out avec 2 workers), et miroir du `ssl_cert_reqs=None` de CACHES pour `rediss://`. Tests de régression : [test_channel_layers.py](tests/backend/test_channel_layers.py) (dont `import_string` sur le backend configuré — le test qui aurait attrapé le bug).
26
- - ⏳ **Reste** : rebuild + deploy prod (manuel : `gh workflow run ci.yml -f deploy_to_prod=true` après push), puis vérifier le handshake : `curl -si -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" https://animetix-web-zhwkba45ta-od.a.run.app/ws/undercover/testroom/` → attendu `101 Switching Protocols` (aujourd'hui : 500).
27
 
28
  ## 🟠 Élevés
29
 
30
- - [x] **Backend — triple espace de noms d'import du même package** _(audit dette 2026-07-05 ; **clos** le 2026-07-05, archivé dans [docs/HISTORY.md](docs/HISTORY.md))_
31
- - Racine nue unifiée partout (~31 fichiers réécrits, chaînes `@patch`/settings comprises) ; hacks supprimés (sync contextvars de middleware.py, `SrcPipelineMapper` du conftest) ; tripwire 3 couches : garde levante [backend/\_\_init\_\_.py](backend/__init__.py), ruff TID251, [test_import_hygiene.py](tests/test_import_hygiene.py). Spec : [docs/plans/2026-07-05-unify-import-namespace-design.md](docs/plans/2026-07-05-unify-import-namespace-design.md).
32
- - [x] **Sécu/coûts — endpoints IA/GPU en `AllowAny` + throttling incohérent** _(audit dette 2026-07-05 ; **clos** le 2026-07-05, archivé dans [docs/HISTORY.md](docs/HISTORY.md))_
33
- - Inventaire : 3 des 4 vues labs citées étaient CPU (restent publiques). 4 vrais trous fermés : S2S Live WS (auth+Bx forfaitaire+cap 10 min ; token via `Sec-WebSocket-Protocol`, pas l'URL), carte-monde (cache partagé public 24 h), VideoRAG (auth+Bx 6), fallback modération LLM (réservé aux authentifiés). Throttling à deux vitesses : burst/minute global (anon 30/min, user 120/min) + scope `gpu` (30/h) réhabilité + `cpu_game` (60/min) sur tous les jeux CPU (fin des `throttle_classes=[]`). Gates login front sur Video/VisualNexus/S2S. Spec : [docs/plans/2026-07-05-secure-ai-endpoints-design.md](docs/plans/2026-07-05-secure-ai-endpoints-design.md).
34
- - [ ] **Backend — N+1 quasi garantis sur les endpoints listes** _(audit dette 2026-07-05)_
35
- - 8 `select_related`/`prefetch_related` dans tout le backend pour un [models.py](backend/api/animetix/models.py) de 895 lignes plein de FK. Les endpoints listes/social/leaderboard ([social.py](backend/api/animetix/api/social.py), [core.py](backend/api/animetix/api/core.py)) sérialisent des objets liés sur Postgres distant (Neon) — latence qui scale avec le volume.
36
- - [ ] **CI — workflow HF cassé (mauvais chemin de script)** _(audit dette 2026-07-05)_
37
- - [deploy_to_hf.yml:44](.github/workflows/deploy_to_hf.yml#L44) appelle `scripts/deploy/hf_deploy.py` qui n'existe pas (réel : `scripts/deploy/huggingface/hf_deploy.py`) → le job échoue à chaque déclenchement. Fixer le chemin, ou supprimer le déploiement HF si Cloud Run est la vraie prod.
38
- - [x] **Build — dépendances de dev embarquées dans l'image prod** _(audit dette 2026-07-05 — corrigé 2026-07-08)_
39
- - ~~[requirements.in:30-39](requirements.in#L30-L39) mélange pytest/pytest-playwright/playwright/watchdog/coloredlogs dans le lock de prod (playwright tire tout un stack navigateur). Scinder en `requirements.in` + `requirements-dev.in`.~~ Fait : 9 pkgs dev sortis du lock prod vers `requirements-dev.{in,txt}` (layered `-c requirements.txt`) ; les 3 images Docker maigrissent sans changement ; 3 jobs CI pytest installent le lock dev. Commits 22a12a30 / 6d045714 / 8294c9c8.
40
- - [x] **Frontend — tokens de design CSS cassés (triplets RGB à virgules)** _(audit dette 2026-07-05 — corrigé 2026-07-05)_
41
- - 23 tokens de [index.css](frontend/src/index.css) convertis en triplets espace-séparés (`37 99 235`). Vérifié en navigateur : `bg-brand-primary` → `rgb(37, 99, 235)`, `bg-anime-accent/20` → `rgba(253, 185, 19, 0.2)`. Les seuls autres consommateurs (`rgb(var(--color-bg))` body) acceptent les deux formats.
42
 
43
  ## 🟡 Moyens
44
 
45
- - [ ] **Backend — fichiers-dieux dans la couche API** _(audit dette 2026-07-05)_
46
- - 25 fichiers > 500 lignes ; pires côté runtime : [labs.py](backend/api/animetix/api/labs.py) (1262 lignes, ~25 vues) et [core.py](backend/api/animetix/api/core.py) (1216 lignes — auth, recherche, config et jeu dans le même fichier). Découper par domaine.
47
  - [ ] **Backend — service locator au lieu d'injection** _(audit dette 2026-07-05)_
48
  - 169 appels directs `container.x.y()` dans les vues + monkey-patch `ProviderDelegate` ([containers/\_\_init\_\_.py:40-54](backend/api/animetix/containers/__init__.py#L40-L54)) pour contourner le wiring de `dependency_injector`. Couplage global difficile à tester ; lié au bug DI contourné dans [tests/api/games/conftest.py](tests/api/games/conftest.py).
49
  - [ ] **Backend — exceptions avalées à grande échelle** _(audit dette 2026-07-05)_
@@ -52,95 +25,19 @@
52
  - [brain_api.py](backend/adapters/inference/brain_api.py) n'est importé nulle part (le container câble `brain_api_adapter`). À supprimer — son nom quasi identique au module actif est un piège.
53
  - [ ] **Backend — signaux dispersés + write amplification** _(audit dette 2026-07-05)_
54
  - Signaux dans [models.py:549-557](backend/api/animetix/models.py#L549-L557) ET [signals.py](backend/api/animetix/signals.py) ; `save_user_profile` ré-écrit le profil à **chaque** save de User et lève `RelatedObjectDoesNotExist` si le profil manque (users bulk/legacy). Consolider et garder.
55
- - [ ] **Backend — squash des migrations** _(audit dette 2026-07-05)_
56
- - 48 migrations sur une seule app, avec churn (QuizWhoRoom créé en 0044, supprimé en 0046). Squash = setup DB de test/CI plus rapide.
57
- - [x] **Frontend — composants-dieux (14 pages de 400-560 lignes)** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-07)_
58
- - ✅ Refactorisé les trois pages principales : `ForgePage.tsx`, `SynapticLabPage.tsx` et `UndercoverRoom.tsx`. Extrait la logique de state et d'intégration réseau/socket dans des hooks réutilisables (`useForge.ts`, `useSynapticLab.ts`, `useUndercoverRoom.ts`) et découpé les interfaces complexes en sous-composants présentationnels modulaires sous `components/forge/`, `components/labs/` et `components/games/`. Tous les tests unitaires et le typage TypeScript passent à 100%.
59
- - [x] **Frontend — deux implémentations WebSocket divergentes (+ 2 bugs)** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-07)_
60
- - ✅ Corrigé le bug d'ordre d'assignation dans `useSocket.ts` : le toast « Connexion rétablie ! » s'affiche désormais correctement lors d'une reconnexion réussie.
61
- - ✅ Standardisé la reconnexion de `notificationStore.ts` pour s'aligner sur `useSocket` : ajout d'un cap de 5 tentatives max, backoff exponentiel, détection et non-reconnexion lors d'un close volontaire (ex. déconnexion/logout avec code `1000`), toasts d'information, et nettoyage du timeout lors de `disconnect()`. Tous les tests unitaires et le typage TypeScript passent.
62
- - [x] **Frontend — i18n incohérent (~500 chaînes FR en dur)** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-06)_
63
- - ✅ Tous les ~150 fichiers restants (labs, social, admin, media, search, dev) ont été externalisés avec `t('clé', 'défaut FR')`. Les fragments de traduction ont été générés et fusionnés via le script `merge_translation_fragments.py`. Les incohérences de clés préexistantes (`games.akinetix` vs `labs.akinetix` et `labs.ai_debate_arena.protocol_text`) ont été résolues et vérifiées par `compare_translations.py` (succès 100% de concordance des clés). Les 611 tests unitaires vitest et le type checking passent sans erreur.
64
- - [x] **Tests — le conftest des jeux est une mine (bug `dependency_injector`)** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-06)_
65
- - ✅ Corrigé à la racine dans `dependency_injector.wiring` en adaptant `_is_marker` et les vérifications de types (`Provide`, `Provider`, `Closing`) pour utiliser du duck-typing et des comparaisons de noms de classes. Cela résout la collision d'importation multiple sous couverture de tests. Le test skippé dans [test_archetypist_coverage.py](tests/api/games/test_archetypist_coverage.py) a été restauré et corrigé, et tout le suite de tests passe au vert.
66
- - [x] **Tests — les e2e Playwright ne tournent nulle part** _(audit dette 2026-07-05)_
67
- - `npm run test:e2e` filtre `--grep @e2e` mais **aucune spec n'a ce tag** → 0 test sélectionné. En CI seule `a11y.spec.ts` tourne ([ci.yml:289](.github/workflows/ci.yml#L289)) ; `akinetix.spec.ts`, `vrt.spec.ts`, `forge-ui.spec.ts` ne s'exécutent jamais. Idem projet vitest `storybook` (jamais en CI).
68
- - [x] **Tests — asymétrie de couverture + angles morts** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-07)_
69
- - ✅ Écrit 25 tests unitaires ciblant les angles morts (`pipeline/movies`, `pipeline/games`, `pipeline/actors`, `adapters/infrastructure`), atteignant une couverture ciblée de 78.83% (au-dessus du seuil de 75%).
70
- - ✅ Configuré Ollama et rendu les tests d'intégration bloquants en CI.
71
- - [x] **Tests — 77 mutations brutes de `sys.modules` dans 16 fichiers** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-07)_
72
- - ✅ Sécurisé le nettoyage inter-tests via un snapshot/restore complet de `sys.modules` dans `conftest.py`.
73
- - ✅ Remplacé les assignations `os.environ` au niveau module dans les fichiers de test de `brain_api` par `setdefault` pour éviter le couplage temporel.
74
- - ✅ Remplacé les vrais `asyncio.sleep` (50ms) par un simple `yield` (`asyncio.sleep(0)`) dans `test_cove_parallel.py`.
75
- - ✅ Supprimé le partage d'objets mocks globaux dans `test_s2s_inference.py` (instanciés à chaque test).
76
- - [x] **CI — dérive pre-commit ↔ CI + audit sécu frontend factice** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-07)_
77
- - ✅ Configuré `.pre-commit-config.yaml` pour exécuter `mypy` (avec `--no-site-packages` pour correspondre à la CI et éviter les conflits locaux) et `pytest` avec une barrière de couverture de 75%.
78
- - ✅ Créé des scripts de contrôle `run_in_venv.py` et `run_cmd.py` pour exécuter les hooks dans le venv et les sous-répertoires correspondants de manière multiplateforme.
79
- - ✅ Ajouté des hooks frontend unifiés (`lint-staged` au commit, `vitest` au push).
80
- - ✅ Corrigé le type checker `mypy` (0 erreurs sur 515 fichiers).
81
- - ✅ Corrigé le job `frontend-security` pour lancer un vrai `npm audit --audit-level=high` et résolu 12 vulnérabilités de sécurité via `npm audit fix`.
82
- - [x] **Infra — `animetix.xyz` absent des défauts `ALLOWED_HOSTS`/CORS** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-07)_
83
- - ✅ Ajouté le domaine custom `animetix.xyz` et ses sous-domaines (`.animetix.xyz`) aux valeurs par défaut d' `ALLOWED_HOSTS` en production.
84
- - ✅ Ajouté `https://animetix.xyz` et `https://www.animetix.xyz` aux valeurs par défaut de `CSRF_TRUSTED_ORIGINS` (en production) et configuré `CORS_ALLOWED_ORIGINS` de manière dynamique à partir des variables d'environnement avec ces domaines par défaut.
85
- - [x] **Infra — logique de déploiement dupliquée, pas d'IaC** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-07)_
86
- - ✅ Créé une source déclarative unique [deployments.yaml](file:///c:/Users/bahma/PycharmProjects/Projet%20solo/Double_scenario_Project/deploy/deployments.yaml) qui centralise tous les paramètres de déploiement (GCP Services, Cloud Run GPU, 8 Jobs planifiés, Load Balancer / CDN, Cloud Armor, Cloudflare Worker et Hugging Face Space).
87
- - ✅ Fusionné les 3 fichiers de build dans le fichier [cloudbuild.yaml](file:///c:/Users/bahma/PycharmProjects/Projet%20solo/Double_scenario_Project/cloudbuild.yaml) à la racine de manière à supporter le build sélectif ou global via des substitutions (`_BUILD_TARGET`).
88
- - ✅ Refactorisé les scripts de déploiement Python (`deploy_brain.py`, `deploy_jobs.py`, `deploy_budget.py`, `deploy_cdn.py`, `deploy_security.py`) pour éliminer les paramètres codés en dur et lire directement depuis `deployments.yaml`.
89
- - ✅ Aligné `.gcloudignore` sur `.dockerignore` via la directive `#!include:.dockerignore` pour garantir un contexte de build unique et canonique.
90
- - [x] **Scripts — sprawl (~55 scripts, one-offs exploratoires)** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-07)_
91
- - ✅ Éliminé la prolifération de scripts exploratoires orphelins (supprimé le répertoire `scripts/test/` contenant 7 scripts hors pytest).
92
- - ✅ Promu et migré tous les scripts utilitaires de base de données (Qdrant vector counts, migration checks, SQLite catalog checking et Neo4j reconciliation) vers des Django management commands standards sous `backend/api/animetix/management/commands/` (`check_db_status`, `reconcile_db` et `generate_offline_db`).
93
- - ✅ Optimisé la commande `reconcile_db` en effectuant un chargement en bloc (`values_list` avec `item_id__in`) pour éviter les boucles de requêtes N+1 et en validant la connectivité Neo4j une seule fois pour éviter d'inonder les logs.
94
- - ✅ Éliminé les emojis et caractères non-ASCII des commandes de base de données pour empêcher les erreurs d'encodage de console (`UnicodeEncodeError`) sous Windows.
95
- - [ ] **Backend — noms de modèles hardcodés + mismatch de version** _(revue archi 2026-06-22 ; en partie fait)_
96
- - ✅ Phase 1 : registre sécu [model_registry.py](backend/core/utils/model_registry.py) (SHA + trust). ✅ Phase 2a : unification Gemini en 3 rôles canoniques via [gemini_models.py](backend/core/utils/gemini_models.py) (`gemini-3.5-flash`/`live-2.5-native-audio`/`embedding-2`) + garde-fou anti-littéral. ✅ Révisions d'embeddings résolues depuis le registre central (`EMBEDDING_VERSIONS`, manifest abandonné).
97
  - ⏳ **Reste (Phase 2b)** : registre d'**IDs logiques locaux** (`llama3`, `Qwen2.5-1.5B` vs `Qwen3.5-4B`, `DRAFT_MODEL_ID`, `VibeThinker-3B`, `FLUX`) + fusion résiduelle [pipeline/models_registry.py](backend/pipeline/models_registry.py).
98
 
99
  ## 🟢 Faibles
100
 
101
- - [x] **Git — `dev/null/` versionné (hooks Git-LFS committés)** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-07)_
102
- - ✅ Supprimé définitivement le chemin littéral `dev/null/` et les 4 hooks LFS (`post-checkout`, `post-commit`, `post-merge`, `pre-push`) qui y avaient été committés par accident lors d'une redirection de commande Windows (commit `3df26086`).
103
- - [x] **Git — ~30 Mo de PNG en blobs bruts (64 fichiers, hors LFS)** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-07)_
104
- - ✅ Configuré le suivi automatique des fichiers `*.png` par Git LFS dans [.gitattributes](file:///c:/Users/bahma/PycharmProjects/Projet%20solo/Double_scenario_Project/.gitattributes).
105
- - ✅ Converti et renormalisé en place les 64 images PNG existantes (dont les gros arrière-plans et mock-ups comme `background.png`) en pointeurs Git LFS via la commande sécurisée `git add --renormalize .` sans altérer l'historique des commits.
106
- - [x] **Docs — deux TODO contradictoires + docs périmées** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-07)_
107
- - ✅ Supprimé le fichier de suivi en double et obsolète [docs/TODO.md](docs/TODO.md) pour conserver le fichier `TODO.md` à la racine comme seule et unique source de vérité.
108
- - ✅ Corrigé le chemin d'accès vers `setup_e2e.py` dans [README.md](file:///c:/Users/bahma/PycharmProjects/Projet%20solo/Double_scenario_Project/README.md) en le faisant pointer vers `scripts/verify/setup_e2e.py`.
109
- - ✅ Nettoyé [docs/ARCHITECTURE.md](file:///c:/Users/bahma/PycharmProjects/Projet%20solo/Double_scenario_Project/docs/ARCHITECTURE.md) en remplaçant la référence à Celery par les Cloud Run Jobs actifs.
110
- - ✅ Mis à jour [docs/FULL_GUIDE.md](file:///c:/Users/bahma/PycharmProjects/Projet%20solo/Double_scenario_Project/docs/FULL_GUIDE.md) et [docs/ROADMAP.md](file:///c:/Users/bahma/PycharmProjects/Projet%20solo/Double_scenario_Project/docs/ROADMAP.md) pour documenter et marquer comme terminés les développements clés de juillet 2026 (la détection de drift d'embeddings/d'archétypes, les diagnostics de transparence XAI et la sécurité des budgets de facturation).
111
- - [x] **Nettoyage — résidus de working tree et de refactor** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-07)_
112
- - ✅ Ajouté `/src/` au fichier [.gitignore](file:///c:/Users/bahma/PycharmProjects/Projet%20solo/Double_scenario_Project/.gitignore) pour s'assurer que si des outils recréent un dossier `src/` vide à la racine du projet, cela ne vienne pas polluer les statuts Git.
113
- - ✅ Vérifié que les fichiers résiduels temporaires (`error_latent.html` et `brainstorming_status.txt`) sont déjà correctement configurés dans `.gitignore`.
114
- - [x] **Divers cosmétique/hygiène** _(audit dette 2026-07-05 ; **clos et vérifié** le 2026-07-07)_
115
- - ✅ Supprimé et désindexé de Git les fichiers binaires de traduction `.mo` compilés (`backend/api/locale/*/LC_MESSAGES/django.mo`) et configuré leur exclusion permanente via le modèle `*.mo` dans [.gitignore](file:///c:/Users/bahma/PycharmProjects/Projet solo/Double_scenario_Project/.gitignore).
116
- - ✅ Corrigé le fichier légal [NOTICE](file:///c:/Users/bahma/PycharmProjects/Projet solo/Double_scenario_Project/NOTICE) pour remplacer les mentions de « Double Scenario Project » par la marque officielle « Animetix ».
117
- - ✅ Converti le fichier Windows-only `sync-api.bat` en un script Python cross-platform moderne [scripts/sync_api.py](file:///c:/Users/bahma/PycharmProjects/Projet%20solo/Double_scenario_Project/scripts/sync_api.py) qui détecte automatiquement l'interpréteur virtuel et execute spectacular + openapi-typescript de manière native sur tous les OS.
118
- - ✅ Supprimé la variable de configuration redondante `WSGI_APPLICATION` dans [settings.py](file:///c:/Users/bahma/PycharmProjects/Projet%20solo/Double_scenario_Project/backend/api/animetix_project/settings.py) puisque le serveur est ASGI-only (évite les erreurs d'incompréhension au déploiement).
119
- - ✅ Désindexé de Git le fichier d'environnement sensible de production [frontend/.env.production](file:///c:/Users/bahma/PycharmProjects/Projet%20solo/Double_scenario_Project/frontend/.env.production) et ajouté sa règle d'exclusion dans `.gitignore`.
120
- - ✅ Consolidé l'injection du jeton d'authentification Firebase et les en-têtes XML HTTP Request de [api.ts](file:///c:/Users/bahma/PycharmProjects/Projet%20solo/Double_scenario_Project/frontend/src/api.ts#L342-L371) (`downloadDataset`) en adaptant [apiClient.ts](file:///c:/Users/bahma/PycharmProjects/Projet%20solo/Double_scenario_Project/frontend/src/utils/apiClient.ts) pour qu'il prenne en charge le type de retour binaire `responseType: 'blob'`.
121
- - ✅ Résolu les erreurs de compilation TypeScript induites par la génération du schéma sur les composants d'audit XAI en liant proprement le décorateur `@extend_schema` aux serializers correspondants dans l'API Django.
122
  - [ ] **Settings — défauts d'infra codés en dur** _(audit dette 2026-07-05)_
123
  - `DEBUG=True` par défaut hors-prod ([settings.py:104](backend/api/animetix_project/settings.py#L104)) : toute la sécurité repose sur `DJANGO_ENV=production` ; URLs hf.space et emails de service accounts en littéraux ([settings.py:331,391,395,581,595,620](backend/api/animetix_project/settings.py#L331)) ; `print()` au démarrage ([settings.py:375,510](backend/api/animetix_project/settings.py#L375)) au lieu du logger ; `ssl_cert_reqs: None` sur le cache `rediss://` ([settings.py:361](backend/api/animetix_project/settings.py#L361)) désactive la vérif de cert.
124
- - [ ] **Backend — duplication entre adapters d'inférence** _(revue archi 2026-06-22 ; en partie fait)_
125
- - ✅ `health_check` *reachability* des adapters API (`brain_api` HTTP-ping, `google_genai` client-init, `unified`/ollama) factorisé dans [ReachabilityHealthCheckMixin](backend/adapters/inference/reachability_health_mixin.py) (builder de statut standardisé + sonde HTTP générique pilotée par un `requester` injecté → chaque adapter garde son client HTTP et ses cibles de patch).
126
- - ✅ `health_check` *readiness* factorisé dans [LazyLocalModelAdapter](backend/adapters/inference/lazy_local_model_adapter.py) (tous les adapters à modèle local migrés) ; motif `_load_model()` multi-sous-modèles factorisé dans [LazyLoadMixin](backend/adapters/inference/lazy_load_mixin.py) (`ImageGenMixin`/`AudioMixin`).
127
  - ⏳ **Reste (résiduel, hors scope)** : `RerankMixin` et `LocalTextAdapter.get_text_embedding` (chargements inline aux sémantiques d'erreur différentes) ; `LocalGuardrailAdapter` (aucun modèle).
128
- - [x] **Backend — validation des env vars d'inférence** _(revue archi 2026-06-22 ; fait)_
129
- - ✅ `BrainAPIAdapter` échoue tôt (`raise ConfigurationError` si `BRAIN_API_URL` manquant) ([brain_api_adapter.py](backend/adapters/inference/brain_api_adapter.py)).
130
- - ✅ Garde-fou de cohérence (coherence-only) pour `unified` (`LLM_API_BASE` malformé) et `google_genai` (`GEMINI_API_KEY` blanc) dans leurs `__init__`, + validation groupée à erreurs agrégées au démarrage du container via `validate_inference_config()` ([inference_config.py](backend/core/utils/inference_config.py), câblée dans [inference.py](backend/api/animetix/containers/inference.py)).
131
- - ✅ `BrainAPIAdapter` routé sur `check_brain_config` ([brain_api_adapter.py](backend/adapters/inference/brain_api_adapter.py)) : message unifié (`"BRAIN_API_URL is not configured"`) + check d'URL malformée côté adapter. En-tête d'erreur agrégée passé tout en anglais (`"Invalid inference configuration:"`) et filtre `is not None` dans `validate_inference_config()`.
132
- - [x] **Backend — adapters synchrones (pas de parallélisation des streams)** _(revue archi 2026-06-22 ; fait)_
133
- - ✅ `astream_generate` natif (I/O non bloquante) pour `UnifiedInferenceAdapter` (httpx.AsyncClient/SSE), `GoogleGenAIAdapter` (`client.aio`) et orchestration async dans `FallbackInferenceAdapter` ; les modèles locaux restent sur le pont thread par défaut de `InferencePort`. Plusieurs streams peuvent désormais être parallélisés via `asyncio.gather` sans bloquer la boucle. (Caveat documenté : le cache de diagnostics `_last_completion` de `UnifiedInferenceAdapter` est best-effort sous streams concurrents sur le Singleton.)
134
- - [ ] **Backend — exposer le streaming async aux endpoints HTTP** _(suite du streaming async natif)_
135
- - ✅ Chemin service async réutilisable : `LLMService.aask_oracle_stream` + `AniminatorDomainService.aask_oracle_stream` consomment `engine.astream_generate` (Neo4j off-loop via `asyncio.to_thread`). ✅ Bug latent corrigé : `AniminatorStreamView` lit `token.text` (les chunks sont des `InferenceResponse`) — l'endpoint SSE Oracle re-fonctionne.
136
- - ✅ `AniminatorStreamView` réécrite en vue Django async native (hors DRF) consommant `aask_oracle_stream` : libère le thread worker pendant le stream (meilleure tenue sous streams concurrents). Rate-limit via `is_ratelimited()` + `sync_to_async` ; session/ORM via `sync_to_async`.
137
- - ✅ `EmojiStreamView` & `ParadoxStreamView` réécrites en vues Django async natives via un helper SSE partagé `api/sse.py` (`check_rate_limit` + `sse_stream_response`) ; `AniminatorStreamView` refactorée pour réutiliser `check_rate_limit`. Chemins service async : `LLMService.astream_generate`, `EmojiDomainService.agenerate_emojis_stream`, `ParadoxDomainService.agenerate_logic_stream`.
138
- - ✅ `ToTStreamView` réécrite en vue Django async native ; `solve_with_tree_of_thoughts_stream` sync remplacé par `asolve_with_tree_of_thoughts_stream` qui **parallélise chaque niveau via `asyncio.gather`** (premier vrai usage du gather — gain réel sur moteurs distants). Helpers async `_agen_thought` / `_aevaluate_thought_node`.
139
- - ✅ AgenticRAG SP1 (fondation) : pipeline async de bout en bout — `StateProcessor.aprocess` (défaut pont-thread sur `process`, pose `ctx.next_state`), `RAGOrchestrator.arun_workflow`, `AgenticRAGService.aplan_and_solve_stream` (I/O via `to_thread`), `AgenticRAGStreamView` vue Django async (`auser`/`sync_to_async` billing). **Les 5 vues SSE sont désormais async natives.** Le sync (`process`/`run_workflow`/`plan_and_solve_stream`) reste (le pont `aprocess` en dépend).
140
- - ✅ AgenticRAG SP2 : `aprocess` natifs sur les **2 processors streaming** (boucle de tokens longue) — `FallbackRagProcessor.aprocess` (`hybrid_search` via `to_thread`, streaming natif `inference_engine.astream_generate`) et `SynthesizeProcessor.aprocess` (`measure_confidence` via `to_thread`, streaming via nouvelle `ResponseSynthesizer.asynthesize_stream` → `llm_service.astream_generate`). Parsing `<thought>` byte-for-byte identique au sync. La boucle d'événements est libérée pendant la génération LLM (plus de thread executor mobilisé sur toute la durée du stream).
141
- - ✅ Nettoyage SSE : `Cache-Control: no-cache` posé sur `AniminatorStreamView` (la seule vue qui construit son `StreamingHttpResponse` directement) → les 5 vues SSE sont harmonisées. **4 vues stream sync mortes** retirées de `animetix.views.api` (`emoji_decode_stream`, `paradox_stream`, `agentic_rag_stream`, `animinator_stream` — miroirs non routés des vues SSE async) + leurs tests + re-exports `views/__init__.py` ; `get_task_status` et `sync_offline_data` (routé) conservés.
142
  - ⏳ **Reste** : les **8 processors one-shot** (Plan/Research/Judge/Acquire/Speculate/VlmRerank/GraphExplore/SagaLookup) restent sur le pont-thread par défaut (appels bloquants ponctuels → bénéfice marginal d'un `aprocess` natif, YAGNI) ; suppression du sync (`process`/`run_workflow`/`plan_and_solve_stream`) différée tant que les 8 dépendent du pont + voie WSGI.
143
- - ✅ Bug `.text` éradiqué dans tous les consommateurs de streaming : `emoji_service`, `paradox_service`, `fallback_rag_processor`, `synthesize_processor` lisent désormais `token.text` (+ annotation de `synthesize_stream` corrigée). Tests de régression feeding `InferenceResponse` ajoutés.
144
  - [ ] **Couverture backend — orchestrateur `finetuning_dataset`**
145
  - `run_generate_instruction_dataset` (433 lignes, 14 %). À traiter au cas par cas, sans gonfler la couverture.
146
  - [ ] **Frontend — `fetch()` brut : reliquat optionnel**
 
6
 
7
  ## 🔴 Critiques
8
 
9
+ _Aucun item ouvert._
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  ## 🟠 Élevés
12
 
13
+ - [ ] **Sécu — révoquer les clés mortes du `.env` local** _(reliquat de l'hygiène post-fuite `.env`, close le 2026-07-09 — secrets montés sur `animetix-web` + 33 images pré-exclusion purgées, archivé dans [docs/HISTORY.md](docs/HISTORY.md))_
14
+ - ⏳ Révoquer `TRIPO_API_KEY` et `MAPBOX_TOKEN` depuis leurs dashboards respectifs (Tripo3D / Mapbox — action manuelle externe) : valeurs réelles dans le `.env` local, consommées nulle part dans le code. Risque résiduel faible depuis la purge des anciennes images.
 
 
 
 
 
 
 
 
 
 
15
 
16
  ## 🟡 Moyens
17
 
18
+ - [ ] **Backend — `Profile.rank` n'existe pas (consommé par ConfigView + ProfileSerializer)** _(découvert lors du découpage API du 2026-07-10 — à confirmer)_
19
+ - `ConfigView` lit `request.user.profile.rank` ([core/config.py](backend/api/animetix/api/core/config.py)) et `ProfileSerializer` déclare `rank = ReadOnlyField()`, mais le modèle `Profile` ne définit aucun attribut `rank` — [test_core_coverage.py](tests/api/test_core_coverage.py) le patche même (`create=True`) avec le commentaire « which the Profile model does not define ». Vérifier le comportement réel en prod (500 probable sur `/config/` authentifié ?) et soit définir la propriété (dérivée de `ranked_points` ?), soit retirer le champ.
20
  - [ ] **Backend — service locator au lieu d'injection** _(audit dette 2026-07-05)_
21
  - 169 appels directs `container.x.y()` dans les vues + monkey-patch `ProviderDelegate` ([containers/\_\_init\_\_.py:40-54](backend/api/animetix/containers/__init__.py#L40-L54)) pour contourner le wiring de `dependency_injector`. Couplage global difficile à tester ; lié au bug DI contourné dans [tests/api/games/conftest.py](tests/api/games/conftest.py).
22
  - [ ] **Backend — exceptions avalées à grande échelle** _(audit dette 2026-07-05)_
 
25
  - [brain_api.py](backend/adapters/inference/brain_api.py) n'est importé nulle part (le container câble `brain_api_adapter`). À supprimer — son nom quasi identique au module actif est un piège.
26
  - [ ] **Backend — signaux dispersés + write amplification** _(audit dette 2026-07-05)_
27
  - Signaux dans [models.py:549-557](backend/api/animetix/models.py#L549-L557) ET [signals.py](backend/api/animetix/signals.py) ; `save_user_profile` ré-écrit le profil à **chaque** save de User et lève `RelatedObjectDoesNotExist` si le profil manque (users bulk/legacy). Consolider et garder.
28
+ - [ ] **Backend — squash des migrations : purge des originales** _(audit dette 2026-07-05 ; squash livrée le 2026-07-08, archivée dans [docs/HISTORY.md](docs/HISTORY.md))_
29
+ - ⏳ La squash `0001_squashed_0049` existe (commit `68fec496`). Reste : une fois la squash enregistrée comme appliquée partout (prod Neon comprise), supprimer les 49 migrations d'origine et retirer l'attribut `replaces` (procédure Django standard).
30
+ - [ ] **Backend — noms de modèles hardcodés + mismatch de version** _(revue archi 2026-06-22 ; Phases 1/2a livrées, archivées dans [docs/HISTORY.md](docs/HISTORY.md))_
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  - ⏳ **Reste (Phase 2b)** : registre d'**IDs logiques locaux** (`llama3`, `Qwen2.5-1.5B` vs `Qwen3.5-4B`, `DRAFT_MODEL_ID`, `VibeThinker-3B`, `FLUX`) + fusion résiduelle [pipeline/models_registry.py](backend/pipeline/models_registry.py).
32
 
33
  ## 🟢 Faibles
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  - [ ] **Settings — défauts d'infra codés en dur** _(audit dette 2026-07-05)_
36
  - `DEBUG=True` par défaut hors-prod ([settings.py:104](backend/api/animetix_project/settings.py#L104)) : toute la sécurité repose sur `DJANGO_ENV=production` ; URLs hf.space et emails de service accounts en littéraux ([settings.py:331,391,395,581,595,620](backend/api/animetix_project/settings.py#L331)) ; `print()` au démarrage ([settings.py:375,510](backend/api/animetix_project/settings.py#L375)) au lieu du logger ; `ssl_cert_reqs: None` sur le cache `rediss://` ([settings.py:361](backend/api/animetix_project/settings.py#L361)) désactive la vérif de cert.
37
+ - [ ] **Backend — duplication entre adapters d'inférence** _(revue archi 2026-06-22 ; mixins de factorisation livrés, archivés dans [docs/HISTORY.md](docs/HISTORY.md))_
 
 
38
  - ⏳ **Reste (résiduel, hors scope)** : `RerankMixin` et `LocalTextAdapter.get_text_embedding` (chargements inline aux sémantiques d'erreur différentes) ; `LocalGuardrailAdapter` (aucun modèle).
39
+ - [ ] **Backend — exposer le streaming async aux endpoints HTTP** _(suite du streaming async natif — les 5 vues SSE sont désormais async natives, détail archivé dans [docs/HISTORY.md](docs/HISTORY.md))_
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  - ⏳ **Reste** : les **8 processors one-shot** (Plan/Research/Judge/Acquire/Speculate/VlmRerank/GraphExplore/SagaLookup) restent sur le pont-thread par défaut (appels bloquants ponctuels → bénéfice marginal d'un `aprocess` natif, YAGNI) ; suppression du sync (`process`/`run_workflow`/`plan_and_solve_stream`) différée tant que les 8 dépendent du pont + voie WSGI.
 
41
  - [ ] **Couverture backend — orchestrateur `finetuning_dataset`**
42
  - `run_generate_instruction_dataset` (433 lignes, 14 %). À traiter au cas par cas, sans gonfler la couverture.
43
  - [ ] **Frontend — `fetch()` brut : reliquat optionnel**
backend/api/animetix/admin.py CHANGED
@@ -11,9 +11,24 @@ from animetix.models import (
11
  BossParticipation,
12
  GlobalBoss,
13
  GoldDatasetEntry,
 
14
  )
15
 
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  @admin.register(GlobalBoss)
18
  class GlobalBossAdmin(admin.ModelAdmin):
19
  """Pilotage manuel des raids World Boss (le spawn hebdo est automatique)."""
 
11
  BossParticipation,
12
  GlobalBoss,
13
  GoldDatasetEntry,
14
+ SiteConfiguration,
15
  )
16
 
17
 
18
+ @admin.register(SiteConfiguration)
19
+ class SiteConfigurationAdmin(admin.ModelAdmin):
20
+ """Réglages globaux (mode maintenance). Singleton : ni ajout ni suppression."""
21
+
22
+ list_display = ("__str__", "maintenance_mode", "maintenance_until", "updated_at")
23
+
24
+ def has_add_permission(self, request):
25
+ # Une seule ligne : on la crée à la volée au premier accès.
26
+ return not SiteConfiguration.objects.exists()
27
+
28
+ def has_delete_permission(self, request, obj=None):
29
+ return False
30
+
31
+
32
  @admin.register(GlobalBoss)
33
  class GlobalBossAdmin(admin.ModelAdmin):
34
  """Pilotage manuel des raids World Boss (le spawn hebdo est automatique)."""
backend/api/animetix/api/core/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core API views, split by domain.
2
+
3
+ Star re-exports preserve the public surface of the former monolithic
4
+ ``core.py`` (``api_views`` star-imports this package, and URLs resolve the
5
+ view classes through it). Tests patch the domain submodules directly.
6
+ """
7
+
8
+ from .accounts import * # noqa: F401, F403
9
+ from .config import * # noqa: F401, F403
10
+ from .manga import * # noqa: F401, F403
11
+ from .media import * # noqa: F401, F403
12
+ from .suwayomi import * # noqa: F401, F403
backend/api/animetix/api/core/accounts.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Session-auth endpoints (login/logout/register/me) and game-session state."""
2
+
3
+ from animetix_project.logging_config import get_logger
4
+ from django.contrib.auth import authenticate, login, logout # noqa: E402
5
+ from django.contrib.auth.models import User
6
+ from django.utils.decorators import method_decorator
7
+ from django.views.decorators.csrf import ensure_csrf_cookie
8
+ from django_ratelimit.decorators import ratelimit # noqa: E402
9
+ from rest_framework import permissions, status
10
+ from rest_framework.response import Response
11
+ from rest_framework.views import APIView
12
+
13
+ from animetix.api.dependencies import get_session_service # noqa: E402
14
+
15
+ from ...serializers import ProfileSerializer
16
+
17
+ logger = get_logger("animetix.api")
18
+
19
+
20
+ class GameSessionView(APIView):
21
+ """Endpoint pour gérer l'état du jeu via API."""
22
+
23
+ permission_classes = [permissions.IsAuthenticatedOrReadOnly]
24
+
25
+ def get(self, request):
26
+ # Récupère l'état actuel de la session via le service de session
27
+ session = get_session_service(request)
28
+ return Response(
29
+ {
30
+ "media_type": session.get("media_type"),
31
+ "is_ranked": session.get("is_ranked"),
32
+ "is_daily": session.get("is_daily"),
33
+ "game_over": session.get("game_over"),
34
+ "guess_count": len(session.get("guesses", [])),
35
+ }
36
+ )
37
+
38
+
39
+ @method_decorator(ensure_csrf_cookie, name="dispatch")
40
+ @method_decorator(
41
+ ratelimit(key="ip", rate="5/m", method="POST", block=True), name="dispatch"
42
+ )
43
+ class LoginView(APIView):
44
+ permission_classes = [permissions.AllowAny]
45
+
46
+ def post(self, request):
47
+ username = request.data.get("username")
48
+ password = request.data.get("password")
49
+ user = authenticate(request, username=username, password=password)
50
+ if user is not None:
51
+ login(request, user)
52
+ return Response({"success": True})
53
+ return Response(
54
+ {"success": False, "error": "Invalid credentials"},
55
+ status=status.HTTP_401_UNAUTHORIZED,
56
+ )
57
+
58
+
59
+ class LogoutView(APIView):
60
+ permission_classes = [permissions.IsAuthenticated]
61
+
62
+ def post(self, request):
63
+ logout(request)
64
+ return Response({"success": True})
65
+
66
+
67
+ @method_decorator(ensure_csrf_cookie, name="dispatch")
68
+ @method_decorator(
69
+ ratelimit(key="ip", rate="5/m", method="POST", block=True), name="dispatch"
70
+ )
71
+ class RegisterView(APIView):
72
+ permission_classes = [permissions.AllowAny]
73
+
74
+ def post(self, request):
75
+ username = request.data.get("username")
76
+ email = request.data.get("email")
77
+ password = request.data.get("password")
78
+
79
+ if not username or not password or not email:
80
+ return Response(
81
+ {"success": False, "error": "Missing fields"},
82
+ status=status.HTTP_400_BAD_REQUEST,
83
+ )
84
+
85
+ if User.objects.filter(username=username).exists():
86
+ return Response(
87
+ {"success": False, "error": "Username already exists"},
88
+ status=status.HTTP_400_BAD_REQUEST,
89
+ )
90
+
91
+ try:
92
+ user = User.objects.create_user(
93
+ username=username, email=email, password=password
94
+ )
95
+ login(request, user)
96
+ return Response({"success": True})
97
+ except Exception as e:
98
+ return Response(
99
+ {"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST
100
+ )
101
+
102
+
103
+ @method_decorator(ensure_csrf_cookie, name="dispatch")
104
+ class CurrentUserView(APIView):
105
+ permission_classes = [permissions.AllowAny]
106
+
107
+ def get(self, request):
108
+ if request.user.is_authenticated:
109
+ serializer = ProfileSerializer(request.user.profile)
110
+ return Response(serializer.data)
111
+ return Response(
112
+ {"detail": "Not authenticated"}, status=status.HTTP_401_UNAUTHORIZED
113
+ )
backend/api/animetix/api/core/config.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public app config (incl. maintenance mode) and the transparency report."""
2
+
3
+ from animetix_project.logging_config import get_logger
4
+ from django.utils.decorators import method_decorator
5
+ from django.views.decorators.csrf import ensure_csrf_cookie
6
+ from rest_framework import permissions
7
+ from rest_framework.response import Response
8
+ from rest_framework.views import APIView
9
+
10
+ from ...containers import get_container
11
+
12
+ logger = get_logger("animetix.api")
13
+
14
+ # Version publiée du modèle « Champion » (libellé déclaré : aucun registre de
15
+ # version n'est persisté en base — c'est la seule source configurable).
16
+ AI_MODEL_VERSION = "Animetix-Champion-v2.4"
17
+
18
+ # Les taux (hallucination, conformité, fiabilité) sont calculés sur une fenêtre
19
+ # glissante et masqués sous un seuil d'échantillon : sur trop peu d'évaluations,
20
+ # un pourcentage donne une fausse impression de précision.
21
+ TRANSPARENCY_WINDOW_DAYS = 30
22
+ MIN_EVAL_SAMPLE = 20
23
+
24
+
25
+ @method_decorator(ensure_csrf_cookie, name="dispatch")
26
+ class ConfigView(APIView):
27
+ """Configuration publique de l'app (type frontend ``AppConfig``).
28
+
29
+ Porte aussi le mode maintenance : le frontend polle cet endpoint pendant
30
+ la maintenance pour détecter la sortie, d'où l'absence de throttle (le
31
+ day-cap anonyme couperait le poll) et son exemption dans
32
+ ``MaintenanceModeMiddleware``.
33
+ """
34
+
35
+ permission_classes = [permissions.AllowAny]
36
+ throttle_classes = []
37
+
38
+ def get(self, request):
39
+ from ...models import SiteConfiguration # noqa: E402
40
+
41
+ site = SiteConfiguration.get_solo()
42
+ data = {
43
+ "version": AI_MODEL_VERSION,
44
+ "theme": "auto",
45
+ "language": "fr",
46
+ "maintenance_mode": site.maintenance_mode,
47
+ "maintenance_message": site.maintenance_message,
48
+ "maintenance_until": (
49
+ site.maintenance_until.isoformat() if site.maintenance_until else None
50
+ ),
51
+ "user": {
52
+ "is_authenticated": request.user.is_authenticated,
53
+ "username": (
54
+ request.user.username if request.user.is_authenticated else None
55
+ ),
56
+ "rank": getattr(request.user, "profile", None)
57
+ and request.user.profile.rank
58
+ or None,
59
+ },
60
+ "features": {
61
+ "EXPERIMENTAL_MODES": True,
62
+ },
63
+ }
64
+ return Response(data)
65
+
66
+
67
+ class CustomConfigDataView(APIView):
68
+ permission_classes = [permissions.AllowAny]
69
+
70
+ def get(self, request):
71
+ if not request.user.is_authenticated:
72
+ return Response({"status": "guest", "visual_theme": "default"})
73
+ return Response({"status": "stub"})
74
+
75
+
76
+ class TransparencyDataView(APIView):
77
+ """
78
+ Vue de transparence communautaire — alimentée par des données réelles.
79
+
80
+ Chaque section est calculée à partir des tables/services existants et
81
+ encapsulée dans un try/except : l'endpoint étant public, il ne doit jamais
82
+ renvoyer 500. Les champs sans source réelle sont renvoyés à ``None`` (le
83
+ front applique alors ses propres valeurs de repli).
84
+ """
85
+
86
+ permission_classes = [permissions.AllowAny]
87
+
88
+ def get(self, request):
89
+ from datetime import timedelta # noqa: E402
90
+
91
+ from django.db.models import Avg # noqa: E402
92
+ from django.db.models.functions import TruncMonth # noqa: E402
93
+ from django.utils import timezone # noqa: E402
94
+
95
+ from ...models import ( # noqa: E402
96
+ AIFeedback,
97
+ AIREvalResult,
98
+ AISafetyEvent,
99
+ VectorRecord,
100
+ )
101
+
102
+ container = get_container()
103
+ cutoff = timezone.now() - timedelta(days=TRANSPARENCY_WINDOW_DAYS)
104
+
105
+ # 1. Feedbacks communautaires (réel, cumulés).
106
+ total_feedbacks = AIFeedback.objects.count()
107
+ community_satisfaction = 0.0
108
+ if total_feedbacks:
109
+ positive = AIFeedback.objects.filter(is_positive=True).count()
110
+ community_satisfaction = round(positive / total_feedbacks, 2)
111
+
112
+ # 2. Base de connaissances vectorielle (réel).
113
+ knowledge_nodes = VectorRecord.objects.count()
114
+
115
+ # 3. Évaluations RAG sur la fenêtre récente. Sous MIN_EVAL_SAMPLE, on
116
+ # renvoie None : un taux sur 3 évals n'a aucune valeur informative.
117
+ recent_evals = AIREvalResult.objects.filter(created_at__gte=cutoff)
118
+ recent_total = recent_evals.count()
119
+ enough_evals = recent_total >= MIN_EVAL_SAMPLE
120
+
121
+ hallucination_rate = None
122
+ model_reliability = None
123
+ if enough_evals:
124
+ recent_halluc = recent_evals.filter(hallucination_detected=True).count()
125
+ hallucination_rate = round(recent_halluc / recent_total, 4)
126
+ model_reliability = round(100 * (1 - hallucination_rate), 2)
127
+
128
+ # Timeline mensuelle de la précision (réel) — le front masque le graphe
129
+ # tant qu'il n'y a pas au moins deux points.
130
+ timeline = [
131
+ {"date": row["m"].strftime("%Y-%m"), "accuracy": round(row["a"], 4)}
132
+ for row in (
133
+ AIREvalResult.objects.annotate(m=TruncMonth("created_at"))
134
+ .values("m")
135
+ .annotate(a=Avg("faithfulness"))
136
+ .order_by("m")
137
+ )
138
+ if row["m"] is not None and row["a"] is not None
139
+ ]
140
+
141
+ # 4. Conformité sécurité sur la même fenêtre (blocages Guardrail /
142
+ # interactions évaluées), masquée sous le même seuil.
143
+ safety_compliance = None
144
+ if enough_evals:
145
+ try:
146
+ recent_blocked = AISafetyEvent.objects.filter(
147
+ created_at__gte=cutoff, action__in=["block", "rewrite"]
148
+ ).count()
149
+ safety_compliance = round(
150
+ max(0.0, 1 - recent_blocked / recent_total), 4
151
+ )
152
+ except Exception:
153
+ logger.warning("Transparency: safety stats unavailable", exc_info=True)
154
+
155
+ # 5. Score éthique composite à partir des seuls signaux fiables du moment.
156
+ ethics_parts = []
157
+ if hallucination_rate is not None:
158
+ ethics_parts.append(1 - hallucination_rate)
159
+ if community_satisfaction:
160
+ ethics_parts.append(community_satisfaction)
161
+ if safety_compliance is not None:
162
+ ethics_parts.append(safety_compliance)
163
+ ethics_score = (
164
+ round(100 * sum(ethics_parts) / len(ethics_parts), 1)
165
+ if ethics_parts
166
+ else None
167
+ )
168
+
169
+ # 6. Benchmarks SOTA (source curée canonique de l'app).
170
+ try:
171
+ sota_benchmarks = (
172
+ container.core.sota_benchmark_service().get_all_benchmarks()
173
+ )
174
+ except Exception:
175
+ sota_benchmarks = []
176
+ logger.warning("Transparency: SOTA benchmarks unavailable", exc_info=True)
177
+
178
+ # 7. Dérive des embeddings (réel — test KS ; "unknown" sans baseline).
179
+ try:
180
+ embedding_drift = container.core.drift_service().get_drift_report()
181
+ except Exception:
182
+ embedding_drift = {}
183
+ logger.warning("Transparency: drift report unavailable", exc_info=True)
184
+
185
+ # Dernière activité d'évaluation (réel), sinon None.
186
+ last_eval = AIREvalResult.objects.order_by("-created_at").first()
187
+ last_training = last_eval.created_at.date().isoformat() if last_eval else None
188
+
189
+ return Response(
190
+ {
191
+ "status": "synchronized",
192
+ "global_metrics": {
193
+ "total_feedbacks": total_feedbacks,
194
+ "community_satisfaction": community_satisfaction,
195
+ "knowledge_nodes": knowledge_nodes,
196
+ "model_version": AI_MODEL_VERSION,
197
+ "last_training": last_training,
198
+ "uptime": model_reliability,
199
+ },
200
+ "model_uptime": model_reliability,
201
+ "evolution_timeline": timeline,
202
+ "sota_benchmarks": sota_benchmarks,
203
+ "embedding_drift": embedding_drift,
204
+ "ethics_score": ethics_score,
205
+ "ethics_audit": {
206
+ "safety_compliance": safety_compliance,
207
+ "hallucination_rate": hallucination_rate,
208
+ },
209
+ }
210
+ )
backend/api/animetix/api/core/manga.py ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Manga chapters, favorites and third-party tracker sync (AniList/MAL)."""
2
+
3
+ import base64
4
+
5
+ from animetix_project.logging_config import get_logger
6
+ from rest_framework import permissions, status
7
+ from rest_framework.response import Response
8
+ from rest_framework.views import APIView
9
+
10
+ from ...containers import get_container
11
+ from ...serializers import MangaChapterSerializer
12
+
13
+ logger = get_logger("animetix.api")
14
+
15
+
16
+ class MangaChapterListView(APIView):
17
+ """Liste des chapitres d'un manga."""
18
+
19
+ permission_classes = [permissions.AllowAny]
20
+
21
+ def get(self, request, media_id):
22
+ container = get_container()
23
+ manga_service = container.core.manga_service()
24
+ chapters = manga_service.get_chapters(media_id)
25
+ serializer = MangaChapterSerializer(chapters, many=True)
26
+ return Response(serializer.data)
27
+
28
+
29
+ class MangaChapterDetailView(APIView):
30
+ """Détails d'un chapitre (incluant les pages)."""
31
+
32
+ permission_classes = [permissions.AllowAny]
33
+
34
+ def get(self, request, media_id, chapter_number):
35
+ container = get_container()
36
+ manga_service = container.core.manga_service()
37
+ chapter = manga_service.get_chapter_details(media_id, float(chapter_number))
38
+
39
+ if chapter:
40
+ serializer = MangaChapterSerializer(chapter)
41
+ return Response(serializer.data)
42
+ return Response(
43
+ {"error": "Chapter not found"}, status=status.HTTP_404_NOT_FOUND
44
+ )
45
+
46
+
47
+ class FavoriteMangaToggleView(APIView):
48
+ """Permet de s'abonner / désabonner (favoris) à un manga."""
49
+
50
+ permission_classes = [permissions.IsAuthenticated]
51
+
52
+ def post(self, request, media_id):
53
+ from ...models import FavoriteManga, MediaItem
54
+
55
+ # 1. Récupération ou auto-import du manga
56
+ try:
57
+ manga = MediaItem.objects.get(external_id=media_id, media_type="Manga")
58
+ except MediaItem.DoesNotExist:
59
+ source_id = request.data.get("source_id")
60
+ suwayomi_manga_id = request.data.get("suwayomi_manga_id")
61
+
62
+ if not source_id or not suwayomi_manga_id:
63
+ return Response(
64
+ {
65
+ "error": "Manga non trouvé et source_id/suwayomi_manga_id manquants pour l'import automatique."
66
+ },
67
+ status=400,
68
+ )
69
+
70
+ # Import automatique depuis Suwayomi
71
+ container = get_container()
72
+ suwayomi_adapter = container.persistence.suwayomi_adapter()
73
+ if not suwayomi_adapter:
74
+ return Response(
75
+ {"error": "Intégration Suwayomi non configurée."}, status=500
76
+ )
77
+
78
+ manga_details = suwayomi_adapter.get_manga_details(suwayomi_manga_id)
79
+ if not manga_details:
80
+ return Response(
81
+ {"error": "Impossible de charger les détails du manga."}, status=404
82
+ )
83
+
84
+ thumbnail_url = manga_details.get("thumbnailUrl")
85
+ image_url = None
86
+ if thumbnail_url:
87
+ encoded_thumb = base64.b64encode(thumbnail_url.encode("utf-8")).decode(
88
+ "utf-8"
89
+ )
90
+ image_url = (
91
+ f"/api/v1/media/Manga/suwayomi-image/?page_url={encoded_thumb}"
92
+ )
93
+
94
+ manga = MediaItem.objects.create(
95
+ external_id=media_id,
96
+ media_type="Manga",
97
+ title=manga_details.get("title", "Unknown Title"),
98
+ description=manga_details.get("description", ""),
99
+ synopsis_en=manga_details.get("description", ""),
100
+ image_url=image_url,
101
+ metadata={
102
+ "source_id": source_id,
103
+ "suwayomi_id": suwayomi_manga_id,
104
+ "author": manga_details.get("author", ""),
105
+ "artist": manga_details.get("artist", ""),
106
+ "status": manga_details.get("status", ""),
107
+ },
108
+ )
109
+
110
+ # Synchronisation initiale des chapitres
111
+ manga_service = container.core.manga_service()
112
+ manga_service.get_chapters(media_id)
113
+
114
+ # 2. Toggle or set status
115
+ status_payload = request.data.get("status")
116
+ if status_payload:
117
+ if status_payload not in ["reading", "completed", "plan_to_read"]:
118
+ return Response({"error": "Invalid status"}, status=400)
119
+ favorite, created = FavoriteManga.objects.get_or_create(
120
+ user=request.user, manga=manga
121
+ )
122
+ favorite.status = status_payload
123
+ favorite.save()
124
+ is_favorite = True
125
+ else:
126
+ favorite, created = FavoriteManga.objects.get_or_create(
127
+ user=request.user, manga=manga
128
+ )
129
+ if not created:
130
+ favorite.delete()
131
+ is_favorite = False
132
+ else:
133
+ is_favorite = True
134
+
135
+ status_val = favorite.status if is_favorite else None
136
+ return Response(
137
+ {"success": True, "is_favorite": is_favorite, "status": status_val}
138
+ )
139
+
140
+ def get(self, request, media_id):
141
+ from ...models import FavoriteManga
142
+
143
+ try:
144
+ fav = FavoriteManga.objects.get(
145
+ user=request.user, manga__external_id=media_id
146
+ )
147
+ return Response({"is_favorite": True, "status": fav.status})
148
+ except FavoriteManga.DoesNotExist:
149
+ return Response({"is_favorite": False, "status": None})
150
+
151
+
152
+ class FavoriteMangaListView(APIView):
153
+ """Liste tous les mangas favoris de l'utilisateur."""
154
+
155
+ permission_classes = [permissions.IsAuthenticated]
156
+
157
+ def get(self, request):
158
+ from django.db.models import Count, IntegerField, OuterRef, Subquery
159
+ from django.db.models.functions import Coalesce
160
+
161
+ from ...models import FavoriteManga, MangaChapter
162
+ from ...serializers import FavoriteMangaSerializer
163
+
164
+ # Use a correlated subquery to avoid N+1 query per FavoriteManga in the list
165
+ unread_chapters_subquery = (
166
+ MangaChapter.objects.filter(
167
+ manga=OuterRef("manga"), number__gt=OuterRef("last_read_chapter")
168
+ )
169
+ .values("manga")
170
+ .annotate(count=Count("id"))
171
+ .values("count")
172
+ )
173
+
174
+ favorites = (
175
+ FavoriteManga.objects.filter(user=request.user)
176
+ .select_related("manga")
177
+ .annotate(
178
+ unread_chapters_count_annotated=Coalesce(
179
+ Subquery(unread_chapters_subquery, output_field=IntegerField()), 0
180
+ )
181
+ )
182
+ )
183
+ serializer = FavoriteMangaSerializer(favorites, many=True)
184
+ return Response(serializer.data)
185
+
186
+
187
+ class TrackerConnectionListView(APIView):
188
+ """Lists all active tracker connections of the current user."""
189
+
190
+ permission_classes = [permissions.IsAuthenticated]
191
+
192
+ def get(self, request):
193
+ from ...models import TrackerConnection
194
+ from ...serializers import TrackerConnectionSerializer
195
+
196
+ connections = TrackerConnection.objects.filter(user=request.user)
197
+ serializer = TrackerConnectionSerializer(connections, many=True)
198
+ return Response(serializer.data)
199
+
200
+
201
+ class TrackerConnectionLinkView(APIView):
202
+ """Links or updates a tracker connection."""
203
+
204
+ permission_classes = [permissions.IsAuthenticated]
205
+
206
+ def post(self, request):
207
+ from ...models import TrackerConnection
208
+
209
+ tracker = request.data.get("tracker")
210
+ username = request.data.get("username")
211
+ token = request.data.get("token")
212
+
213
+ if not tracker or not username or not token:
214
+ return Response(
215
+ {"error": "tracker, username and token are required"},
216
+ status=status.HTTP_400_BAD_REQUEST,
217
+ )
218
+
219
+ if tracker not in ["myanimelist", "anilist"]:
220
+ return Response(
221
+ {"error": "invalid tracker type"}, status=status.HTTP_400_BAD_REQUEST
222
+ )
223
+
224
+ connection, created = TrackerConnection.objects.update_or_create(
225
+ user=request.user,
226
+ tracker=tracker,
227
+ defaults={"username": username, "token": token},
228
+ )
229
+
230
+ return Response({"success": True, "created": created})
231
+
232
+
233
+ class TrackerConnectionUnlinkView(APIView):
234
+ """Unlinks a tracker connection."""
235
+
236
+ permission_classes = [permissions.IsAuthenticated]
237
+
238
+ def post(self, request):
239
+ from ...models import TrackerConnection
240
+
241
+ tracker = request.data.get("tracker")
242
+
243
+ if not tracker:
244
+ return Response(
245
+ {"error": "tracker parameter is required"},
246
+ status=status.HTTP_400_BAD_REQUEST,
247
+ )
248
+
249
+ deleted_count, _ = TrackerConnection.objects.filter(
250
+ user=request.user, tracker=tracker
251
+ ).delete()
252
+
253
+ return Response({"success": True, "deleted": deleted_count > 0})
254
+
255
+
256
+ class MangaChapterSyncView(APIView):
257
+ """Synchronizes manga progress to linked third-party trackers (AniList, MyAnimeList)."""
258
+
259
+ permission_classes = [permissions.IsAuthenticated]
260
+
261
+ def post(self, request, media_id, chapter_number):
262
+ import httpx
263
+
264
+ from ...models import FavoriteManga, MangaChapter, MediaItem, TrackerConnection
265
+
266
+ # 1. Fetch the manga
267
+ try:
268
+ manga = MediaItem.objects.get(external_id=media_id, media_type="Manga")
269
+ except MediaItem.DoesNotExist:
270
+ return Response(
271
+ {"error": "Manga not found"}, status=status.HTTP_404_NOT_FOUND
272
+ )
273
+
274
+ # Parse chapter number
275
+ try:
276
+ progress = int(float(chapter_number))
277
+ progress_float = float(chapter_number)
278
+ except (ValueError, TypeError):
279
+ return Response(
280
+ {"error": "Invalid chapter number format"},
281
+ status=status.HTTP_400_BAD_REQUEST,
282
+ )
283
+
284
+ # 1.5 Update or create FavoriteManga record and handle auto-transitions
285
+ favorite, created = FavoriteManga.objects.get_or_create(
286
+ user=request.user, manga=manga
287
+ )
288
+ favorite.last_read_chapter = max(favorite.last_read_chapter, progress_float)
289
+
290
+ # Check if there are any chapters with a number strictly greater than progress_float
291
+ has_future_chapters = MangaChapter.objects.filter(
292
+ manga=manga, number__gt=progress_float
293
+ ).exists()
294
+
295
+ if not has_future_chapters:
296
+ favorite.status = "completed"
297
+ elif favorite.status == "plan_to_read":
298
+ favorite.status = "reading"
299
+
300
+ favorite.save()
301
+
302
+ # 2. Get active connections
303
+ connections = TrackerConnection.objects.filter(user=request.user)
304
+ if not connections.exists():
305
+ return Response({"success": True, "message": "No trackers connected."})
306
+
307
+ results = {}
308
+
309
+ # 4. Loop over active connections and sync
310
+ for conn in connections:
311
+ if conn.tracker == "anilist":
312
+ # Resolve AniList ID
313
+ anilist_id = None
314
+ # Check if external_id itself is a pure digit (which means it represents AniList ID)
315
+ if media_id.isdigit():
316
+ anilist_id = int(media_id)
317
+ elif manga.metadata and "id" in manga.metadata:
318
+ try:
319
+ anilist_id = int(manga.metadata["id"])
320
+ except (ValueError, TypeError):
321
+ pass
322
+
323
+ # If not resolved yet, let's search AniList GraphQL API by title
324
+ if not anilist_id:
325
+ try:
326
+ search_url = "https://graphql.anilist.co"
327
+ search_query = """
328
+ query ($search: String) {
329
+ Media (search: $search, type: MANGA) {
330
+ id
331
+ }
332
+ }
333
+ """
334
+ with httpx.Client(timeout=5.0) as client:
335
+ res = client.post(
336
+ search_url,
337
+ json={
338
+ "query": search_query,
339
+ "variables": {"search": manga.title},
340
+ },
341
+ )
342
+ if res.status_code == 200:
343
+ search_data = res.json()
344
+ if search_data.get("data", {}).get("Media"):
345
+ anilist_id = search_data["data"]["Media"]["id"]
346
+ except Exception as e:
347
+ logger.error(
348
+ f"Failed to resolve AniList ID by title search: {e}"
349
+ )
350
+
351
+ if not anilist_id:
352
+ results["anilist"] = {
353
+ "success": False,
354
+ "error": "Could not resolve AniList ID",
355
+ }
356
+ continue
357
+
358
+ # Perform mutation request to AniList
359
+ try:
360
+ mutation = """
361
+ mutation ($mediaId: Int, $progress: Int) {
362
+ SaveMediaListEntry (mediaId: $mediaId, progress: $progress, status: CURRENT) {
363
+ id
364
+ progress
365
+ }
366
+ }
367
+ """
368
+ url = "https://graphql.anilist.co"
369
+ headers = {
370
+ "Authorization": f"Bearer {conn.token}",
371
+ "Content-Type": "application/json",
372
+ }
373
+ if conn.token == "mock-token" or conn.token == "test-token":
374
+ # Simulate success for tests/CI
375
+ results["anilist"] = {"success": True, "simulated": True}
376
+ else:
377
+ with httpx.Client(timeout=5.0) as client:
378
+ res = client.post(
379
+ url,
380
+ json={
381
+ "query": mutation,
382
+ "variables": {
383
+ "mediaId": anilist_id,
384
+ "progress": progress,
385
+ },
386
+ },
387
+ headers=headers,
388
+ )
389
+ if res.status_code == 200:
390
+ results["anilist"] = {"success": True}
391
+ else:
392
+ results["anilist"] = {
393
+ "success": False,
394
+ "error": f"AniList API error: {res.text}",
395
+ }
396
+ except Exception as e:
397
+ results["anilist"] = {"success": False, "error": str(e)}
398
+
399
+ elif conn.tracker == "myanimelist":
400
+ # Resolve MAL ID
401
+ mal_id = None
402
+ if manga.metadata and "idMal" in manga.metadata:
403
+ mal_id = manga.metadata["idMal"]
404
+ elif manga.metadata and "mal_id" in manga.metadata:
405
+ mal_id = manga.metadata["mal_id"]
406
+
407
+ # Fallback: search MAL
408
+ if not mal_id:
409
+ try:
410
+ # Use Jikan API for searching since it doesn't require authentication
411
+ jikan_url = (
412
+ f"https://api.jikan.moe/v4/manga?q={manga.title}&limit=1"
413
+ )
414
+ with httpx.Client(timeout=5.0) as client:
415
+ res = client.get(jikan_url)
416
+ if res.status_code == 200:
417
+ search_data = res.json()
418
+ if (
419
+ search_data.get("data")
420
+ and len(search_data["data"]) > 0
421
+ ):
422
+ mal_id = search_data["data"][0]["mal_id"]
423
+ except Exception as e:
424
+ logger.error(f"Failed to resolve MAL ID via Jikan: {e}")
425
+
426
+ if not mal_id:
427
+ results["myanimelist"] = {
428
+ "success": False,
429
+ "error": "Could not resolve MyAnimeList ID",
430
+ }
431
+ continue
432
+
433
+ # Perform update request to MAL
434
+ try:
435
+ url = (
436
+ f"https://api.myanimelist.net/v2/manga/{mal_id}/my_list_status"
437
+ )
438
+ headers = {
439
+ "Authorization": f"Bearer {conn.token}",
440
+ "Content-Type": "application/x-www-form-urlencoded",
441
+ }
442
+ data = {
443
+ "num_chapters_read": progress,
444
+ "status": "reading",
445
+ }
446
+ if conn.token == "mock-token" or conn.token == "test-token":
447
+ # Simulate success for tests/CI
448
+ results["myanimelist"] = {"success": True, "simulated": True}
449
+ else:
450
+ with httpx.Client(timeout=5.0) as client:
451
+ res = client.patch(url, data=data, headers=headers)
452
+ if res.status_code == 200:
453
+ results["myanimelist"] = {"success": True}
454
+ else:
455
+ results["myanimelist"] = {
456
+ "success": False,
457
+ "error": f"MAL API error: {res.text}",
458
+ }
459
+ except Exception as e:
460
+ results["myanimelist"] = {"success": False, "error": str(e)}
461
+
462
+ return Response({"success": True, "results": results})
backend/api/animetix/api/core/media.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Media search, detail and the signed external-image proxy."""
2
+
3
+ import base64
4
+ import hashlib
5
+
6
+ from animetix_project.logging_config import get_logger
7
+ from core.constants import ALLOWED_IMAGE_MIMES, MAX_IMAGE_SIZE # noqa: E402
8
+ from core.domain.services.berrix_economy import FEATURE_BX_COSTS
9
+ from core.domain.services.guardrail_service import GuardrailService
10
+ from core.ports.usage_port import UsagePort
11
+ from core.utils.security import (
12
+ safe_http_request,
13
+ validate_file_mime_type,
14
+ validate_file_size,
15
+ verify_proxy_signature,
16
+ )
17
+ from dependency_injector.wiring import Provide, inject
18
+ from django.core.cache import cache # noqa: E402
19
+ from django.http import HttpResponse # noqa: E402
20
+ from django.utils.decorators import method_decorator
21
+ from django_ratelimit.decorators import ratelimit # noqa: E402
22
+ from rest_framework import permissions, status
23
+ from rest_framework.response import Response
24
+ from rest_framework.views import APIView
25
+
26
+ from animetix.api.billing import deduct_berrix
27
+
28
+ from ...containers import Container, get_container
29
+ from ...serializers import MediaItemSerializer
30
+
31
+ logger = get_logger("animetix.api")
32
+
33
+
34
+ @ratelimit(key="ip", rate="30/m", method="GET", block=True)
35
+ def image_proxy_view(request):
36
+ """
37
+ Proxy pour les images externes avec cache local.
38
+ Sécurisé par signature HMAC, Rate Limiting et validation binaire.
39
+ """
40
+ encoded_url = request.GET.get("url")
41
+ signature = request.GET.get("sig")
42
+
43
+ if not encoded_url or not signature:
44
+ return HttpResponse("Missing parameters", status=400)
45
+
46
+ try:
47
+ url = base64.b64decode(encoded_url).decode("utf-8")
48
+ except Exception as e:
49
+ logger.error(f"Failed to decode image proxy URL: {e}")
50
+ return HttpResponse(status=400)
51
+
52
+ # 1. Vérification de la signature cryptographique (Prévention Open Proxy)
53
+ if not verify_proxy_signature(url, signature):
54
+ logger.warning(f"🚩 Invalid proxy signature detected for URL: {url}")
55
+ return HttpResponse("Forbidden: Invalid signature", status=403)
56
+
57
+ cache_key = (
58
+ f"img_cache_{hashlib.md5(url.encode(), usedforsecurity=False).hexdigest()}"
59
+ )
60
+ cached_data = cache.get(cache_key)
61
+
62
+ if cached_data:
63
+ return HttpResponse(
64
+ cached_data["content"], content_type=cached_data["content_type"]
65
+ )
66
+
67
+ try:
68
+ # safe_http_request gère la validation DNS et les sauts de redirection en toute sécurité
69
+ response = safe_http_request("GET", url, timeout=10)
70
+
71
+ if response.status_code == 200:
72
+ content = response.content
73
+
74
+ # 2. Validation de la taille (DoS Protection)
75
+ if not validate_file_size(len(content), MAX_IMAGE_SIZE):
76
+ return HttpResponse("Entity Too Large", status=413)
77
+
78
+ # 3. Validation du type MIME réel (Magic Number)
79
+ if not validate_file_mime_type(content, ALLOWED_IMAGE_MIMES):
80
+ logger.warning(f"🚩 Non-image content detected in proxy for URL: {url}")
81
+ return HttpResponse("Forbidden: Content type not allowed", status=403)
82
+
83
+ content_type = response.headers.get("Content-Type", "image/jpeg")
84
+ cache.set(
85
+ cache_key,
86
+ {"content": content, "content_type": content_type},
87
+ 60 * 60 * 24 * 7,
88
+ )
89
+ return HttpResponse(content, content_type=content_type)
90
+
91
+ except ValueError as ve:
92
+ logger.warning(f"Blocked unsafe request in image proxy: {ve}")
93
+ return HttpResponse("Forbidden: Unsafe request detected", status=403)
94
+ except Exception as e:
95
+ logger.error("Image Proxy Error: %s", e, exc_info=True)
96
+
97
+ return HttpResponse(status=404)
98
+
99
+
100
+ @method_decorator(
101
+ ratelimit(key="ip", rate="20/m", method="GET", block=True), name="dispatch"
102
+ )
103
+ @method_decorator(
104
+ ratelimit(key="user", rate="5/m", method="POST", block=True), name="dispatch"
105
+ )
106
+ class MediaSearchView(APIView):
107
+ """Recherche d'œuvres via SQL ou Multi-Modale (CLIP)."""
108
+
109
+ permission_classes = [permissions.AllowAny]
110
+
111
+ @inject
112
+ def __init__(
113
+ self,
114
+ guardrail_service: GuardrailService = Provide[Container.core.guardrail_service],
115
+ usage_port: UsagePort = Provide[Container.infrastructure.usage_port],
116
+ **kwargs,
117
+ ):
118
+ super().__init__(**kwargs)
119
+ self.guardrail_service = guardrail_service
120
+ self.usage_port = usage_port
121
+
122
+ def get(self, request):
123
+ media_type = request.query_params.get("media_type")
124
+ query = request.query_params.get("q", "")
125
+ limit = min(int(request.query_params.get("limit", 10)), 50)
126
+
127
+ if not query and not media_type:
128
+ return Response([])
129
+
130
+ # Input Guardrail (Anti-Jailbreak, Injection)
131
+ if query:
132
+ guard_input = self.guardrail_service.validate_input(
133
+ query, allow_llm=request.user.is_authenticated
134
+ )
135
+ if not guard_input.get("is_safe", True):
136
+ return Response(
137
+ {"error": guard_input.get("reason", "Inappropriate search query.")},
138
+ status=status.HTTP_400_BAD_REQUEST,
139
+ )
140
+
141
+ results = (
142
+ get_container()
143
+ .core.catalog_service()
144
+ .search_items(query, media_type, limit)
145
+ )
146
+ return Response(self._format_results(results))
147
+
148
+ def post(self, request):
149
+ """Recherche par image (Cross-Modal). Requiert Authentification + Quota."""
150
+ if not request.user.is_authenticated:
151
+ return Response(
152
+ {"error": "Authentication required for image search."},
153
+ status=status.HTTP_401_UNAUTHORIZED,
154
+ )
155
+
156
+ # Quota Check
157
+ tier = getattr(request, "user_tier", "free")
158
+ if not self.usage_port.check_quota(request.user.id, tier):
159
+ return Response(
160
+ {"error": "Daily AI quota exceeded."}, status=status.HTTP_403_FORBIDDEN
161
+ )
162
+
163
+ image_file = request.FILES.get("image")
164
+ request.data.get("media_type", "Anime")
165
+ limit = min(int(request.data.get("limit", 10)), 20)
166
+
167
+ if not image_file:
168
+ return Response({"error": "No image provided"}, status=400)
169
+
170
+ # Berrix deduction for the CLIP cross-modal search (raises 402 if balance
171
+ # too low). Outside the try below so PaymentRequired isn't swallowed into 500.
172
+ deduct_berrix(
173
+ request.user,
174
+ FEATURE_BX_COSTS["vision_clip"],
175
+ "Recherche par image (CLIP)",
176
+ )
177
+
178
+ try:
179
+ if not validate_file_size(image_file.size, MAX_IMAGE_SIZE):
180
+ return Response(
181
+ {
182
+ "error": f"Image is too large (Max: {MAX_IMAGE_SIZE / 1024 / 1024}MB)"
183
+ },
184
+ status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
185
+ )
186
+
187
+ container = get_container()
188
+ image_data = b"".join(chunk for chunk in image_file.chunks())
189
+
190
+ if not validate_file_mime_type(image_data, ALLOWED_IMAGE_MIMES):
191
+ return Response(
192
+ {
193
+ "error": "Invalid image format. Allowed formats: JPEG, PNG, WEBP."
194
+ },
195
+ status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
196
+ )
197
+
198
+ # Appel au service Cross-Modal
199
+ results = container.core.cross_modal_search_service.deep_multimodal_search(
200
+ text_query="", image_data=image_data, limit=limit
201
+ )
202
+
203
+ # Log Usage
204
+ self.usage_port.log_usage(
205
+ engine="clip-vit-large-patch14", units=1, user_id=request.user.id
206
+ )
207
+
208
+ return Response(self._format_results(results))
209
+ except Exception:
210
+ logger.exception("Error in image search")
211
+ return Response({"error": "Internal server error"}, status=500)
212
+
213
+ def _format_results(self, results):
214
+ # Utilise le serializer pour formater les résultats
215
+ serializer = MediaItemSerializer(results, many=True)
216
+ return serializer.data
217
+
218
+
219
+ class MediaDetailView(APIView):
220
+ """Détails complets d'une œuvre."""
221
+
222
+ permission_classes = [permissions.IsAuthenticatedOrReadOnly]
223
+
224
+ def get(self, request, media_type, item_id):
225
+ from ...models import MediaItem # noqa: E402
226
+ from ...serializers import MediaItemSerializer # noqa: E402
227
+
228
+ # 1. Tentative via SQL direct (Source of Truth)
229
+ try:
230
+ item_obj = MediaItem.objects.get(media_type=media_type, external_id=item_id)
231
+ serializer = MediaItemSerializer(item_obj)
232
+ return Response(serializer.data)
233
+ except MediaItem.DoesNotExist:
234
+ logger.debug(
235
+ f"MediaItem {item_id} ({media_type}) not found in SQL, falling back to Catalog."
236
+ )
237
+
238
+ # 2. Fallback via Catalog Service (si non synchronisé en SQL)
239
+ container = get_container()
240
+ data = container.core.catalog_service().load_data(media_type)
241
+ if data:
242
+ item = next(
243
+ (i for i in data.get("db", []) if str(i.get("id")) == str(item_id)),
244
+ None,
245
+ )
246
+ if item:
247
+ # Enrichissement avec les nœuds du graphe si présents
248
+ graph_nodes = item.get("graph_nodes", {})
249
+ item["studios"] = graph_nodes.get("studios", [])
250
+ item["author"] = graph_nodes.get("author")
251
+ item["related_items"] = graph_nodes.get("related_items", [])
252
+
253
+ return Response(item)
254
+
255
+ return Response({"error": "Item not found"}, status=404)
backend/api/animetix/api/core/suwayomi.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Suwayomi/Tachidesk (Mihon) integration: proxy, sources, search, import, extensions."""
2
+
3
+ import base64
4
+
5
+ from animetix_project.logging_config import get_logger
6
+ from core.utils.security import safe_http_request
7
+ from dependency_injector.wiring import Provide, inject
8
+ from django.http import HttpResponse # noqa: E402
9
+ from django_ratelimit.decorators import ratelimit # noqa: E402
10
+ from rest_framework import permissions, status
11
+ from rest_framework.response import Response
12
+ from rest_framework.views import APIView
13
+
14
+ from ...containers import Container, get_container
15
+
16
+ logger = get_logger("animetix.api")
17
+
18
+
19
+ @ratelimit(key="ip", rate="120/m", method="GET", block=True)
20
+ def suwayomi_image_proxy(request):
21
+ """
22
+ Proxy pour les images de Suwayomi/Tachidesk afin de contourner le CORS et l'authentification.
23
+ """
24
+ from core.config import settings
25
+
26
+ encoded_url = request.GET.get("page_url")
27
+ if not encoded_url:
28
+ return HttpResponse("Missing page_url", status=400)
29
+ try:
30
+ url = base64.b64decode(encoded_url).decode("utf-8")
31
+ except Exception as e:
32
+ logger.error(f"Failed to decode Suwayomi proxy URL: {e}")
33
+ return HttpResponse("Invalid encoded URL", status=400)
34
+
35
+ suwayomi_base = settings.SUWAYOMI_URL.rstrip("/")
36
+ if not url.startswith("http"):
37
+ url = f"{suwayomi_base}/{url.lstrip('/')}"
38
+ else:
39
+ if not url.startswith(suwayomi_base):
40
+ logger.warning(
41
+ f"Blocked unauthorized external URL in Suwayomi proxy: {url}"
42
+ )
43
+ return HttpResponse(
44
+ "Forbidden: URL must point to the configured Suwayomi instance",
45
+ status=403,
46
+ )
47
+
48
+ headers = {}
49
+ if settings.SUWAYOMI_PASSWORD:
50
+ headers["Authorization"] = f"Bearer {settings.SUWAYOMI_PASSWORD}"
51
+
52
+ try:
53
+ response = safe_http_request("GET", url, headers=headers, timeout=15)
54
+ if response.status_code == 200:
55
+ content_type = response.headers.get("Content-Type", "image/jpeg")
56
+ return HttpResponse(response.content, content_type=content_type)
57
+ else:
58
+ logger.error(
59
+ f"Suwayomi proxy failed with status {response.status_code} for URL: {url}"
60
+ )
61
+ return HttpResponse(status=response.status_code)
62
+ except Exception as e:
63
+ logger.error(f"Error proxying Suwayomi image {url}: {e}", exc_info=True)
64
+ return HttpResponse(status=500)
65
+
66
+
67
+ class SuwayomiSourcesView(APIView):
68
+ """Liste des sources de mangas installées dans Suwayomi."""
69
+
70
+ permission_classes = [permissions.IsAuthenticatedOrReadOnly]
71
+
72
+ @inject
73
+ def __init__(
74
+ self, suwayomi_adapter=Provide[Container.persistence.suwayomi_adapter], **kwargs
75
+ ):
76
+ super().__init__(**kwargs)
77
+ self.suwayomi_adapter = suwayomi_adapter
78
+
79
+ def get(self, request):
80
+ if not self.suwayomi_adapter:
81
+ return Response(
82
+ {"error": "Suwayomi integration not configured"}, status=500
83
+ )
84
+ sources = self.suwayomi_adapter.get_sources()
85
+ return Response(sources)
86
+
87
+
88
+ class SuwayomiSearchView(APIView):
89
+ """Recherche des mangas dans une source spécifique Suwayomi."""
90
+
91
+ permission_classes = [permissions.IsAuthenticatedOrReadOnly]
92
+
93
+ @inject
94
+ def __init__(
95
+ self, suwayomi_adapter=Provide[Container.persistence.suwayomi_adapter], **kwargs
96
+ ):
97
+ super().__init__(**kwargs)
98
+ self.suwayomi_adapter = suwayomi_adapter
99
+
100
+ def get(self, request):
101
+ source_id = request.query_params.get("source_id")
102
+ query = request.query_params.get("q", "")
103
+ if not source_id:
104
+ return Response({"error": "Missing source_id parameter"}, status=400)
105
+ if not self.suwayomi_adapter:
106
+ return Response(
107
+ {"error": "Suwayomi integration not configured"}, status=500
108
+ )
109
+ results = self.suwayomi_adapter.search_manga(source_id, query)
110
+ return Response(results)
111
+
112
+
113
+ class SuwayomiImportView(APIView):
114
+ """Importe un manga depuis Suwayomi dans le catalogue local."""
115
+
116
+ permission_classes = [permissions.IsAuthenticated]
117
+
118
+ @inject
119
+ def __init__(
120
+ self, suwayomi_adapter=Provide[Container.persistence.suwayomi_adapter], **kwargs
121
+ ):
122
+ super().__init__(**kwargs)
123
+ self.suwayomi_adapter = suwayomi_adapter
124
+
125
+ def post(self, request):
126
+ from ...models import MediaItem
127
+
128
+ source_id = request.data.get("source_id")
129
+ suwayomi_manga_id = request.data.get("suwayomi_manga_id")
130
+
131
+ if not source_id or not suwayomi_manga_id:
132
+ return Response(
133
+ {"error": "Missing source_id or suwayomi_manga_id"}, status=400
134
+ )
135
+
136
+ if not self.suwayomi_adapter:
137
+ return Response(
138
+ {"error": "Suwayomi integration not configured"}, status=500
139
+ )
140
+
141
+ manga_details = self.suwayomi_adapter.get_manga_details(suwayomi_manga_id)
142
+ if not manga_details:
143
+ return Response(
144
+ {"error": "Failed to fetch manga details from Suwayomi"}, status=404
145
+ )
146
+
147
+ external_id = f"suwayomi:{source_id}:{suwayomi_manga_id}"
148
+
149
+ thumbnail_url = manga_details.get("thumbnailUrl")
150
+ image_url = None
151
+ if thumbnail_url:
152
+ encoded_thumb = base64.b64encode(thumbnail_url.encode("utf-8")).decode(
153
+ "utf-8"
154
+ )
155
+ image_url = f"/api/v1/media/Manga/suwayomi-image/?page_url={encoded_thumb}"
156
+
157
+ media_item, created = MediaItem.objects.update_or_create(
158
+ external_id=external_id,
159
+ media_type="Manga",
160
+ defaults={
161
+ "title": manga_details.get("title", "Unknown Title"),
162
+ "description": manga_details.get("description", ""),
163
+ "synopsis_en": manga_details.get("description", ""),
164
+ "image_url": image_url,
165
+ "metadata": {
166
+ "source_id": source_id,
167
+ "suwayomi_id": suwayomi_manga_id,
168
+ "author": manga_details.get("author", ""),
169
+ "artist": manga_details.get("artist", ""),
170
+ "status": manga_details.get("status", ""),
171
+ },
172
+ },
173
+ )
174
+
175
+ container = get_container()
176
+ manga_service = container.core.manga_service()
177
+ manga_service.get_chapters(external_id)
178
+
179
+ return Response(
180
+ {
181
+ "success": True,
182
+ "media_item": {
183
+ "id": media_item.id,
184
+ "external_id": media_item.external_id,
185
+ "title": media_item.title,
186
+ "image_url": media_item.image_url,
187
+ },
188
+ }
189
+ )
190
+
191
+
192
+ class SuwayomiExtensionsListView(APIView):
193
+ """Liste des extensions installées et disponibles dans Suwayomi/Mihon.
194
+
195
+ Lecture seule sur une page publique (/explore/tachidesk/) : accessible en
196
+ anonyme comme les sources et la recherche. Les actions (installation /
197
+ mise à jour) et l'import restent réservés aux utilisateurs authentifiés.
198
+ """
199
+
200
+ permission_classes = [permissions.IsAuthenticatedOrReadOnly]
201
+
202
+ @inject
203
+ def __init__(
204
+ self, suwayomi_adapter=Provide[Container.persistence.suwayomi_adapter], **kwargs
205
+ ):
206
+ super().__init__(**kwargs)
207
+ self.suwayomi_adapter = suwayomi_adapter
208
+
209
+ def get(self, request):
210
+ if not self.suwayomi_adapter:
211
+ return Response(
212
+ {"error": "Suwayomi integration not configured"}, status=500
213
+ )
214
+ try:
215
+ extensions = self.suwayomi_adapter.get_extensions()
216
+ return Response(extensions)
217
+ except Exception:
218
+ logger.exception("Failed to fetch Suwayomi extensions")
219
+ return Response({"error": "Internal server error"}, status=500)
220
+
221
+
222
+ class SuwayomiExtensionsActionView(APIView):
223
+ """Effectue une action (installation, désinstallation, mise à jour) sur une extension Suwayomi."""
224
+
225
+ permission_classes = [permissions.IsAuthenticated]
226
+
227
+ @inject
228
+ def __init__(
229
+ self, suwayomi_adapter=Provide[Container.persistence.suwayomi_adapter], **kwargs
230
+ ):
231
+ super().__init__(**kwargs)
232
+ self.suwayomi_adapter = suwayomi_adapter
233
+
234
+ def post(self, request):
235
+ ids = request.data.get("ids")
236
+ action = request.data.get("action")
237
+
238
+ if not ids or not isinstance(ids, list) or not action:
239
+ return Response(
240
+ {
241
+ "error": "Missing or invalid parameters: 'ids' (list) and 'action' (string)"
242
+ },
243
+ status=status.HTTP_400_BAD_REQUEST,
244
+ )
245
+
246
+ if action not in ["install", "uninstall", "update"]:
247
+ return Response(
248
+ {
249
+ "error": "Invalid action. Must be 'install', 'uninstall', or 'update'"
250
+ },
251
+ status=status.HTTP_400_BAD_REQUEST,
252
+ )
253
+
254
+ if not self.suwayomi_adapter:
255
+ return Response(
256
+ {"error": "Suwayomi integration not configured"}, status=500
257
+ )
258
+
259
+ try:
260
+ results = self.suwayomi_adapter.update_extensions(ids, action)
261
+ return Response(results)
262
+ except Exception:
263
+ logger.exception("Failed to update Suwayomi extensions")
264
+ return Response({"error": "Internal server error"}, status=500)
backend/api/animetix/api/games/blindtest.py CHANGED
@@ -24,6 +24,16 @@ def _max_attempts(difficulty: str) -> int:
24
  return MAX_ATTEMPTS.get(difficulty, MAX_ATTEMPTS["Normal"])
25
 
26
 
 
 
 
 
 
 
 
 
 
 
27
  class BlindtestTitlesView(APIView):
28
  """Anime titles for the guess autocomplete (same catalog used to validate)."""
29
 
@@ -47,7 +57,10 @@ class BlindtestGameStateView(APIView):
47
 
48
  @inject
49
  def get(
50
- self, request, blind_test_service=Provide[Container.core.blind_test_service]
 
 
 
51
  ):
52
  session_service = get_session_service(request)
53
  port = session_service.port
@@ -74,6 +87,11 @@ class BlindtestGameStateView(APIView):
74
  "max_attempts": max_attempts,
75
  "attempts_left": max(0, max_attempts - len(state.guesses)),
76
  "secret_title": state.secret if state.game_over else None,
 
 
 
 
 
77
  }
78
  )
79
 
@@ -266,6 +284,11 @@ class BlindtestGameGuessView(APIView):
266
  "blindtest_song": state.song,
267
  "blindtest_artists": state.artists,
268
  "secret_title": secret if state.game_over else None,
 
 
 
 
 
269
  "newly_unlocked_achievements": unlocked_achievements,
270
  }
271
  )
 
24
  return MAX_ATTEMPTS.get(difficulty, MAX_ATTEMPTS["Normal"])
25
 
26
 
27
+ def _secret_image(catalog_service, secret: str) -> str | None:
28
+ """Affiche de l'œuvre secrète, révélée uniquement en fin de manche."""
29
+ try:
30
+ data = catalog_service.load_data("Anime") or {}
31
+ item = data.get("title_to_full_data", {}).get(secret)
32
+ return str(item["image"]) if item and item.get("image") else None
33
+ except Exception: # catalogue indisponible : la révélation reste textuelle
34
+ return None
35
+
36
+
37
  class BlindtestTitlesView(APIView):
38
  """Anime titles for the guess autocomplete (same catalog used to validate)."""
39
 
 
57
 
58
  @inject
59
  def get(
60
+ self,
61
+ request,
62
+ blind_test_service=Provide[Container.core.blind_test_service],
63
+ catalog_service=Provide[Container.core.catalog_service],
64
  ):
65
  session_service = get_session_service(request)
66
  port = session_service.port
 
87
  "max_attempts": max_attempts,
88
  "attempts_left": max(0, max_attempts - len(state.guesses)),
89
  "secret_title": state.secret if state.game_over else None,
90
+ "secret_image": (
91
+ _secret_image(catalog_service, state.secret)
92
+ if state.game_over
93
+ else None
94
+ ),
95
  }
96
  )
97
 
 
284
  "blindtest_song": state.song,
285
  "blindtest_artists": state.artists,
286
  "secret_title": secret if state.game_over else None,
287
+ "secret_image": (
288
+ str(secret_item.get("image"))
289
+ if state.game_over and secret_item and secret_item.get("image")
290
+ else None
291
+ ),
292
  "newly_unlocked_achievements": unlocked_achievements,
293
  }
294
  )
backend/api/animetix/api/games/vs_battle.py CHANGED
@@ -21,7 +21,12 @@ logger = logging.getLogger(__name__)
21
  @permission_classes([AllowAny])
22
  def list_vs_battles(request):
23
  """Liste les combats publics récents pour l'Arène."""
24
- battles = VsBattle.objects.filter(is_public=True).order_by("-created_at")[:20]
 
 
 
 
 
25
  serializer = VsBattleSerializer(battles, many=True)
26
  return Response(serializer.data)
27
 
 
21
  @permission_classes([AllowAny])
22
  def list_vs_battles(request):
23
  """Liste les combats publics récents pour l'Arène."""
24
+ battles = (
25
+ VsBattle.objects.filter(is_public=True)
26
+ .select_related("creator")
27
+ .prefetch_related("likes")
28
+ .order_by("-created_at")[:20]
29
+ )
30
  serializer = VsBattleSerializer(battles, many=True)
31
  return Response(serializer.data)
32
 
backend/api/animetix/api/labs/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Labs API views, split by domain.
2
+
3
+ Star re-exports preserve the public surface of the former monolithic
4
+ ``labs.py`` (``api_views`` star-imports this package, and URLs resolve the
5
+ view classes through it). Tests patch the domain submodules directly.
6
+ """
7
+
8
+ from .audio import * # noqa: F401, F403
9
+ from .dashboards import * # noqa: F401, F403
10
+ from .manga import * # noqa: F401, F403
11
+ from .singularity import * # noqa: F401, F403
12
+ from .spatial import * # noqa: F401, F403
13
+ from .video import * # noqa: F401, F403
backend/api/animetix/api/labs/audio.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio labs: seiyuu discovery, voice ingestion/cloning, soundscape and S2S."""
2
+
3
+ from animetix_project.logging_config import get_logger
4
+
5
+ # Media-upload limits/MIME allow-lists are centralized in core.constants.
6
+ from core.constants import ALLOWED_AUDIO_MIMES # noqa: E402
7
+ from core.utils.security import validate_file_mime_type, validate_file_size
8
+ from rest_framework import permissions, status
9
+ from rest_framework.response import Response
10
+ from rest_framework.views import APIView
11
+
12
+ from ...containers import get_container # noqa: E402
13
+
14
+ logger = get_logger("animetix." + __name__)
15
+
16
+ from core.domain.services.berrix_economy import FEATURE_BX_COSTS # noqa: E402
17
+
18
+ from animetix.api.billing import deduct_berrix # noqa: E402
19
+
20
+
21
+ class AudioLabDataView(APIView):
22
+ """Metadata for the Audio Lab tools."""
23
+
24
+ permission_classes = [permissions.AllowAny]
25
+
26
+ def get(self, request):
27
+ return Response(
28
+ {
29
+ "status": "active",
30
+ "tools": [
31
+ {
32
+ "id": "soundscape",
33
+ "name": "Soundscape Generator",
34
+ "description": "Generate immersive ambient soundscapes for your video clips.",
35
+ "endpoint": "/api/v1/labs/audio/soundscape/",
36
+ },
37
+ {
38
+ "id": "s2s",
39
+ "name": "Voice Interaction (S2S)",
40
+ "description": "Direct speech-to-speech interaction with anime personas.",
41
+ "endpoint": "/api/v1/labs/audio/s2s/",
42
+ },
43
+ {
44
+ "id": "seiyuu-discovery",
45
+ "name": "Seiyuu Discovery",
46
+ "description": "Search and explore voice actors (seiyuu) and their iconic character roles.",
47
+ "endpoint": "/api/v1/labs/audio/seiyuu/",
48
+ },
49
+ ],
50
+ }
51
+ )
52
+
53
+
54
+ class SeiyuuDiscoveryView(APIView):
55
+ """Recherche et exploration des Seiyuu/Doubleurs et de leurs rôles."""
56
+
57
+ permission_classes = [permissions.AllowAny]
58
+
59
+ def get(self, request):
60
+ query = request.query_params.get("q", "")
61
+ language = request.query_params.get("language", "")
62
+ origin = request.query_params.get("origin", "")
63
+
64
+ from django.db.models import Q
65
+
66
+ from animetix.models import VoiceProfile
67
+ from animetix.serializers import VoiceProfileSerializer
68
+
69
+ profiles = VoiceProfile.objects.all()
70
+
71
+ if query:
72
+ profiles = profiles.filter(
73
+ Q(name__icontains=query)
74
+ | Q(roles__icontains=query)
75
+ | Q(definition__icontains=query)
76
+ )
77
+
78
+ if language:
79
+ profiles = profiles.filter(language=language)
80
+ if origin:
81
+ profiles = profiles.filter(origin=origin)
82
+
83
+ serializer = VoiceProfileSerializer(profiles, many=True)
84
+ return Response({"query": query, "results": serializer.data})
85
+
86
+
87
+ class VoiceProfileIngestView(APIView):
88
+ """Ingestion dynamique de voix à la volée depuis YouTube."""
89
+
90
+ permission_classes = [permissions.IsAuthenticated]
91
+
92
+ def post(self, request):
93
+ name = request.data.get("name")
94
+ language = request.data.get("language", "japanese")
95
+ youtube_url_or_query = request.data.get("youtube_url") or request.data.get(
96
+ "query"
97
+ )
98
+ definition = request.data.get("definition", "")
99
+ roles = request.data.get("roles", "")
100
+ impact = request.data.get("impact", "Custom")
101
+
102
+ if not name or not youtube_url_or_query:
103
+ return Response(
104
+ {"error": "Le nom et l'URL/requête YouTube sont obligatoires."},
105
+ status=status.HTTP_400_BAD_REQUEST,
106
+ )
107
+
108
+ # Déduction des Berrix (30 Bx pour l'ingestion/découpage de voix)
109
+ try:
110
+ deduct_berrix(
111
+ request.user,
112
+ FEATURE_BX_COSTS["voice_ingest"],
113
+ f"Ingestion vocale de {name}",
114
+ )
115
+ except Exception as e:
116
+ return Response(
117
+ {"error": f"Fonds insuffisants : {str(e)}"},
118
+ status=status.HTTP_402_PAYMENT_REQUIRED,
119
+ )
120
+
121
+ from animetix.serializers import VoiceProfileSerializer
122
+
123
+ ingestion_service = get_container().core.voice_ingestion_service()
124
+ try:
125
+ profile = ingestion_service.ingest_voice(
126
+ name=name,
127
+ language=language,
128
+ youtube_url_or_query=youtube_url_or_query,
129
+ definition=definition,
130
+ roles=roles,
131
+ impact=impact,
132
+ )
133
+ serializer = VoiceProfileSerializer(profile)
134
+ return Response(
135
+ {
136
+ "message": "Ingestion réussie !",
137
+ "profile": serializer.data,
138
+ },
139
+ status=status.HTTP_201_CREATED,
140
+ )
141
+ except Exception:
142
+ logger.exception("Erreur lors de l'ingestion vocale")
143
+ return Response(
144
+ {"error": "Erreur lors de l'ingestion"},
145
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
146
+ )
147
+
148
+
149
+ class SoundscapeGenerationView(APIView):
150
+ """Génère un soundscape ambiant pour une vidéo."""
151
+
152
+ permission_classes = [permissions.IsAuthenticated]
153
+
154
+ def post(self, request):
155
+ container = get_container()
156
+ video_file = request.FILES.get("video_file") or request.FILES.get("video")
157
+
158
+ if not video_file:
159
+ return Response({"error": "Video file is required."}, status=400)
160
+
161
+ # Déduction des Berrix (50 Bx pour génération audio)
162
+ deduct_berrix(
163
+ request.user,
164
+ FEATURE_BX_COSTS["soundscape"],
165
+ "Génération de Soundscape Immersif",
166
+ )
167
+
168
+ try:
169
+ video_bytes = video_file.read()
170
+ service = container.core.soundscape_service()
171
+ result_url = service.generate_soundscape_for_video(video_bytes)
172
+ return Response({"status": "success", "audio_url": result_url})
173
+ except Exception:
174
+ logger.exception("Error in SoundscapeGenerationView")
175
+ return Response({"error": "Internal server error"}, status=500)
176
+
177
+
178
+ class SpeechToSpeechLabView(APIView):
179
+ """Interaction directe voix-à-voix (S2S)."""
180
+
181
+ permission_classes = [permissions.IsAuthenticated]
182
+
183
+ def post(self, request):
184
+ container = get_container()
185
+ audio_file = request.FILES.get("audio_file") or request.FILES.get("audio")
186
+ persona = request.data.get("persona", "Standard")
187
+
188
+ if not audio_file:
189
+ return Response({"error": "Audio file is required."}, status=400)
190
+
191
+ # Déduction des Berrix (10 Bx pour un tour de parole S2S)
192
+ deduct_berrix(
193
+ request.user,
194
+ FEATURE_BX_COSTS["speech_to_speech"],
195
+ f"Speech-to-Speech: {persona}",
196
+ )
197
+
198
+ try:
199
+ audio_bytes = audio_file.read()
200
+ service = container.core.native_speech_llm_service()
201
+ result = service.process_voice_interaction(audio_bytes, persona=persona)
202
+
203
+ if result.get("status") == "success":
204
+ # Convert audio bytes to base64 for JSON transport
205
+ import base64 # noqa: E402
206
+
207
+ encoded_audio = base64.b64encode(result["audio_data"]).decode("utf-8")
208
+ return Response({"status": "success", "audio_data": encoded_audio})
209
+ return Response(result, status=400)
210
+ except Exception:
211
+ logger.exception("Error in SpeechToSpeechLabView")
212
+ return Response({"error": "Internal server error"}, status=500)
213
+
214
+
215
+ class VoiceCloningLabView(APIView):
216
+ permission_classes = [permissions.IsAuthenticated]
217
+
218
+ def post(self, request):
219
+ target_text = request.data.get("target_text")
220
+ ref_audio_file = request.FILES.get("reference_audio")
221
+
222
+ if not target_text or not ref_audio_file:
223
+ return Response({"error": "Missing text or audio"}, status=400)
224
+
225
+ # Déduction des Berrix (100 Bx pour clonage vocal multi-passes)
226
+ deduct_berrix(
227
+ request.user, FEATURE_BX_COSTS["voice_clone"], "Voice Cloning (Audio)"
228
+ )
229
+
230
+ # Validation: Pitch must be an integer
231
+ try:
232
+ pitch = int(request.data.get("pitch", 0))
233
+ except (ValueError, TypeError):
234
+ return Response(
235
+ {"error": "Invalid pitch value. Must be an integer."}, status=400
236
+ )
237
+
238
+ # Validation: File size (Max 5MB as per review)
239
+ MAX_VOICE_AUDIO_SIZE = 5 * 1024 * 1024
240
+ if not validate_file_size(ref_audio_file.size, MAX_VOICE_AUDIO_SIZE):
241
+ return Response(
242
+ {"error": "Reference audio file too large (Max 5MB)."}, status=400
243
+ )
244
+
245
+ container = get_container()
246
+ service = container.core.voice_cloning_service()
247
+
248
+ try:
249
+ audio_bytes = ref_audio_file.read()
250
+
251
+ # Validation: MIME Type
252
+ if not validate_file_mime_type(audio_bytes, ALLOWED_AUDIO_MIMES):
253
+ return Response(
254
+ {"error": "Invalid file type. Only wav and mp3 are allowed."},
255
+ status=400,
256
+ )
257
+
258
+ result_audio = service.clone(
259
+ reference_audio=audio_bytes, target_text=target_text, pitch=pitch
260
+ )
261
+ # Return as base64 for pure SPA handling
262
+ import base64 # noqa: E402
263
+
264
+ encoded = base64.b64encode(result_audio).decode("utf-8")
265
+ return Response(
266
+ {"status": "success", "audio_data": f"data:audio/wav;base64,{encoded}"}
267
+ )
268
+ except Exception:
269
+ logger.exception("Voice Cloning Error")
270
+ return Response({"error": "Internal server error"}, status=500)
backend/api/animetix/api/labs/dashboards.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Labs dashboards: daily challenge hub and latent-space explorer data."""
2
+
3
+ import datetime
4
+ import json
5
+ import os
6
+
7
+ from animetix_project.logging_config import get_logger
8
+ from django.conf import settings
9
+ from rest_framework import permissions, status, viewsets
10
+ from rest_framework.response import Response
11
+ from rest_framework.views import APIView
12
+
13
+ from ...models import DailyChallenge # noqa: E402
14
+ from ...serializers import DailyChallengeSerializer # noqa: E402
15
+
16
+ logger = get_logger("animetix." + __name__)
17
+
18
+
19
+ class DailyChallengeViewSet(viewsets.ReadOnlyModelViewSet):
20
+ queryset = DailyChallenge.objects.all()
21
+ serializer_class = DailyChallengeSerializer
22
+ permission_classes = [permissions.AllowAny]
23
+
24
+
25
+ class LatentSpaceDataView(APIView):
26
+ permission_classes = [permissions.AllowAny]
27
+
28
+ def get(self, request):
29
+ media = request.query_params.get("media", "anime").lower()
30
+ type_param = request.query_params.get("type", "thematic").lower()
31
+ mapping = {
32
+ ("anime", "thematic"): "latent_space_anime_thematic.json",
33
+ ("anime", "visual"): "latent_space_anime_visual_vibe.json",
34
+ ("anime", "scenario"): "latent_space_anime_plot.json",
35
+ ("manga", "thematic"): "latent_space_manga_thematic.json",
36
+ ("manga", "visual"): "latent_space_manga_visual_vibe.json",
37
+ ("manga", "scenario"): "latent_space_manga_plot.json",
38
+ ("character", "thematic"): "latent_space_character_vibe.json",
39
+ ("character", "visual"): "latent_space_character_visual_vibe.json",
40
+ }
41
+ filename = mapping.get((media, type_param), "latent_space_3d.json")
42
+ project_root = settings.BASE_DIR.parent.parent
43
+ file_path = project_root / "data" / "artifacts" / filename
44
+ if not os.path.exists(file_path):
45
+ file_path = project_root / "data" / "artifacts" / "latent_space_3d.json"
46
+ if not os.path.exists(file_path):
47
+ return Response([], status=status.HTTP_404_NOT_FOUND)
48
+ try:
49
+ with open(file_path, "r", encoding="utf-8") as f:
50
+ data = json.load(f)
51
+ return Response(data)
52
+ except Exception:
53
+ logger.exception("Error loading latent space artifact")
54
+ return Response({"error": "Internal server error"}, status=500)
55
+
56
+
57
+ class DailyChallengeDataView(APIView):
58
+ permission_classes = [permissions.AllowAny]
59
+
60
+ # How far back past challenges stay replayable.
61
+ MAX_BACK_DAYS = 30
62
+
63
+ def get(self, request):
64
+ today = datetime.date.today()
65
+ day = today
66
+ raw_date = request.query_params.get("date")
67
+ if raw_date:
68
+ try:
69
+ parsed = datetime.date.fromisoformat(raw_date)
70
+ if parsed <= today and (today - parsed).days <= self.MAX_BACK_DAYS:
71
+ day = parsed
72
+ except ValueError:
73
+ pass
74
+
75
+ modes = [
76
+ {
77
+ "id": "anime",
78
+ "media_type": "Anime",
79
+ "brush1": "ANIME",
80
+ "brush2": "DU JOUR",
81
+ "description": "Devine la série animée mystère du jour.",
82
+ "gradient": "from-blue-600 to-indigo-900",
83
+ "icon": "/static/img/modes/classic.png",
84
+ },
85
+ {
86
+ "id": "manga",
87
+ "media_type": "Manga",
88
+ "brush1": "MANGA",
89
+ "brush2": "DU JOUR",
90
+ "description": "Devine l'œuvre papier mystère du jour.",
91
+ "gradient": "from-rose-500 to-red-900",
92
+ "icon": "/static/img/modes/covertest.png",
93
+ },
94
+ {
95
+ "id": "character",
96
+ "media_type": "Character",
97
+ "brush1": "PERSO",
98
+ "brush2": "DU JOUR",
99
+ "description": "Devine le personnage mystère du jour.",
100
+ "gradient": "from-purple-600 to-fuchsia-900",
101
+ "icon": "/static/img/modes/akinetix.png",
102
+ },
103
+ ]
104
+
105
+ # Attach this user's saved score per universe for the requested day.
106
+ results = {}
107
+ if request.user.is_authenticated:
108
+ from ...models import DailyResult # noqa: E402
109
+
110
+ for r in DailyResult.objects.filter(user=request.user, date=day):
111
+ results[r.media_type] = {"score": r.score, "attempts": r.attempts}
112
+ for m in modes:
113
+ res = results.get(m["media_type"])
114
+ m["completed"] = bool(res)
115
+ m["score"] = res["score"] if res else None
116
+
117
+ prev_day = day - datetime.timedelta(days=1)
118
+ can_prev = (today - prev_day).days <= self.MAX_BACK_DAYS
119
+ return Response(
120
+ {
121
+ "date": day.isoformat(),
122
+ "is_today": day == today,
123
+ "prev_date": prev_day.isoformat() if can_prev else None,
124
+ "next_date": (
125
+ None
126
+ if day >= today
127
+ else (day + datetime.timedelta(days=1)).isoformat()
128
+ ),
129
+ "total_score": sum(r["score"] for r in results.values()),
130
+ "modes": modes,
131
+ }
132
+ )
backend/api/animetix/api/labs/manga.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Manga labs: cleaning, translation and GCP-orchestrated dubbing."""
2
+
3
+ import base64
4
+
5
+ from adapters.inference.workflows_client import GCPWorkflowsClient
6
+ from animetix_project.logging_config import get_logger
7
+ from django.conf import settings
8
+ from django.utils.decorators import method_decorator
9
+ from django_ratelimit.decorators import ratelimit
10
+ from rest_framework import permissions
11
+ from rest_framework.response import Response
12
+ from rest_framework.throttling import ScopedRateThrottle
13
+ from rest_framework.views import APIView
14
+
15
+ from ...containers import get_container # noqa: E402
16
+
17
+ logger = get_logger("animetix." + __name__)
18
+
19
+ from core.domain.services.berrix_economy import FEATURE_BX_COSTS # noqa: E402
20
+
21
+ from animetix.api.billing import deduct_berrix # noqa: E402
22
+
23
+
24
+ class MangaLabDataView(APIView):
25
+ """Metadata for the Manga Lab tools."""
26
+
27
+ permission_classes = [permissions.AllowAny]
28
+
29
+ def get(self, request):
30
+ return Response(
31
+ {
32
+ "status": "active",
33
+ "tools": [
34
+ {
35
+ "id": "clean",
36
+ "name": "Manga Cleaner",
37
+ "description": "Remove text bubbles from manga pages.",
38
+ "endpoint": "/api/v1/labs/manga-lab/clean/",
39
+ },
40
+ {
41
+ "id": "translate",
42
+ "name": "Manga Translator",
43
+ "description": "Translate manga bubbles to target language.",
44
+ "endpoint": "/api/v1/labs/manga-lab/translate/",
45
+ },
46
+ {
47
+ "id": "voice",
48
+ "name": "Manga Voice Lab",
49
+ "description": "Generate voices for manga characters (Dubbing).",
50
+ "endpoint": "/api/v1/labs/manga-voice/",
51
+ },
52
+ ],
53
+ }
54
+ )
55
+
56
+
57
+ @method_decorator(
58
+ ratelimit(key="user_or_ip", rate="5/m", method="POST", block=True), name="dispatch"
59
+ )
60
+ class MangaVoiceLabView(APIView):
61
+ """Traduction de manga + synthèse vocale orchestrée via GCP Workflows."""
62
+
63
+ permission_classes = [permissions.IsAuthenticated]
64
+ throttle_scope = "gpu"
65
+ throttle_classes = [ScopedRateThrottle]
66
+
67
+ def post(self, request):
68
+ import uuid # noqa: E402
69
+
70
+ from django.core.cache import cache # noqa: E402
71
+
72
+ image = request.data.get("image")
73
+ reference_audio = request.data.get("reference_audio")
74
+ target_lang = request.data.get("target_lang", "French")
75
+
76
+ if not image or not reference_audio:
77
+ return Response(
78
+ {"error": "Missing image or reference_audio in payload"}, status=400
79
+ )
80
+
81
+ # Déduction des Berrix (200 Bx pour orchestration GCP + Audio)
82
+ deduct_berrix(
83
+ request.user,
84
+ FEATURE_BX_COSTS["manga_voice"],
85
+ "Manga Voice Lab (Doublage IA)",
86
+ )
87
+
88
+ task_id = str(uuid.uuid4())
89
+ filename = f"manga_voice_{task_id}.wav"
90
+
91
+ # Initialisation du cache
92
+ cache.set(
93
+ f"task_result:{task_id}",
94
+ {"ready": False, "status": "pending"},
95
+ timeout=3600,
96
+ )
97
+
98
+ is_prod = getattr(settings, "IS_PRODUCTION", False)
99
+ if is_prod:
100
+ try:
101
+ client = GCPWorkflowsClient()
102
+ execution_name = client.trigger_pipeline(
103
+ image, reference_audio, target_lang, filename
104
+ )
105
+
106
+ # Cloud Task pour le polling
107
+ client.enqueue_polling_task(execution_name, task_id)
108
+ return Response({"task_id": task_id}, status=202)
109
+ except Exception as e:
110
+ cache.set(
111
+ f"task_result:{task_id}",
112
+ {"ready": True, "status": "failed", "error": str(e)},
113
+ timeout=3600,
114
+ )
115
+ logger.exception("Failed to start workflow")
116
+ return Response({"error": "Failed to start workflow"}, status=500)
117
+ else:
118
+ # Fallback local dev synchrone simulé
119
+ cache.set(
120
+ f"task_result:{task_id}",
121
+ {
122
+ "ready": True,
123
+ "status": "success",
124
+ "result": {
125
+ "translated_text": "[Local Dev Fallback] Traduction simulée.",
126
+ "audio_url": f"http://localhost:8000/media/mock_{filename}",
127
+ },
128
+ },
129
+ timeout=3600,
130
+ )
131
+ return Response({"task_id": task_id}, status=202)
132
+
133
+
134
+ class MangaCleanLabView(APIView):
135
+ """Nettoie (inpaint) les bulles de texte d'une planche de manga."""
136
+
137
+ permission_classes = [permissions.IsAuthenticated]
138
+
139
+ def post(self, request):
140
+ container = get_container()
141
+ image_file = request.FILES.get("image")
142
+
143
+ if not image_file:
144
+ return Response({"error": "Image file is required."}, status=400)
145
+
146
+ # Déduction des Berrix (20 Bx pour inpainting de planche)
147
+ deduct_berrix(
148
+ request.user, FEATURE_BX_COSTS["manga_clean"], "Manga Cleaner (Planche)"
149
+ )
150
+
151
+ try:
152
+ image_bytes = image_file.read()
153
+ inference_engine = container.inference.inference_engine()
154
+
155
+ # Un nettoyage simple équivaut à un inpainting avec une liste de bulles vide.
156
+ cleaned_bytes = inference_engine.inpaint_text_bubbles(image_bytes, [])
157
+
158
+ b64_img = base64.b64encode(cleaned_bytes).decode("utf-8")
159
+ return Response({"status": "success", "image": b64_img})
160
+ except Exception:
161
+ logger.exception("Error in MangaCleanLabView")
162
+ return Response({"error": "Internal server error"}, status=500)
163
+
164
+
165
+ class MangaTranslateLabView(APIView):
166
+ """Traduit les bulles de texte d'une planche de manga."""
167
+
168
+ permission_classes = [permissions.IsAuthenticated]
169
+
170
+ def post(self, request):
171
+ container = get_container()
172
+ image_file = request.FILES.get("image")
173
+ target_lang = request.data.get("target_lang", "French")
174
+
175
+ if not image_file:
176
+ return Response({"error": "Image file is required."}, status=400)
177
+
178
+ # Déduction des Berrix (20 Bx pour inpainting + traduction)
179
+ deduct_berrix(
180
+ request.user,
181
+ FEATURE_BX_COSTS["manga_translate"],
182
+ f"Manga Translator: {target_lang}",
183
+ )
184
+
185
+ try:
186
+ image_bytes = image_file.read()
187
+ manga_flow_service = container.core.manga_flow_service()
188
+
189
+ translated_bytes = manga_flow_service.translate_manga_page(
190
+ image_bytes, target_lang
191
+ )
192
+
193
+ b64_img = base64.b64encode(translated_bytes).decode("utf-8")
194
+ return Response({"status": "success", "image": b64_img})
195
+ except Exception:
196
+ logger.exception("Error in MangaTranslateLabView")
197
+ return Response({"error": "Internal server error"}, status=500)
backend/api/animetix/api/labs/singularity.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Singularity/Ghost labs: evolving-AI experiments, LNN, ToT and diagnostics."""
2
+
3
+ import datetime
4
+ import random
5
+ import time
6
+
7
+ import numpy as np
8
+ from animetix_project.logging_config import get_logger
9
+ from dependency_injector.wiring import Provide, inject # noqa: E402
10
+ from rest_framework import permissions, status
11
+ from rest_framework.response import Response
12
+ from rest_framework.throttling import ScopedRateThrottle
13
+ from rest_framework.views import APIView
14
+
15
+ from ...containers import Container, get_container # noqa: E402
16
+
17
+ logger = get_logger("animetix." + __name__)
18
+
19
+ from core.domain.services.berrix_economy import FEATURE_BX_COSTS # noqa: E402
20
+
21
+ from animetix.api.billing import deduct_berrix # noqa: E402
22
+
23
+
24
+ class SingularityLabDataView(APIView):
25
+ """Interact with fifth generation Evolving AI and Singularity services (SOTA 2035+)."""
26
+
27
+ permission_classes = [permissions.IsAuthenticated]
28
+ throttle_scope = "gpu"
29
+ throttle_classes = [ScopedRateThrottle]
30
+
31
+ def get(self, request):
32
+ container = get_container()
33
+ service = container.core.synaptic_plasticity_simulator()
34
+ drift_service = container.core.archetype_drift_service()
35
+
36
+ profile = getattr(request.user, "profile", None)
37
+ settings = profile.personalization_settings if profile else {}
38
+ drift_config = drift_service.calculate_drift(request.user.id, settings)
39
+
40
+ return Response(
41
+ {
42
+ "status": "success",
43
+ "weights": service.W.tolist(),
44
+ "concepts": [
45
+ "Shonen",
46
+ "Seinen",
47
+ "Cyberpunk",
48
+ "Mecha",
49
+ "Fantasy",
50
+ "Magic",
51
+ "Ghibli",
52
+ "Romance",
53
+ "Comedy",
54
+ "Drama",
55
+ ],
56
+ "plasticity_config": {
57
+ "tau_plus": service.tau_plus,
58
+ "tau_minus": service.tau_minus,
59
+ "num_concepts": service.num_concepts,
60
+ },
61
+ "personalization_settings": settings,
62
+ "current_archetype": {
63
+ "id": drift_config.archetype_id,
64
+ "accent": drift_config.primary_accent,
65
+ "aura_type": drift_config.aura_type,
66
+ "intensity": drift_config.aura_intensity,
67
+ "font_vibe": drift_config.font_vibe,
68
+ },
69
+ }
70
+ )
71
+
72
+ def post(self, request):
73
+ action = request.data.get("action", "")
74
+ container = get_container()
75
+
76
+ if action == "update_config":
77
+ profile = getattr(request.user, "profile", None)
78
+ if profile:
79
+ settings = profile.personalization_settings or {}
80
+ mode = request.data.get("mode")
81
+ if mode in ["auto", "manual"]:
82
+ settings["mode"] = mode
83
+ manual_arch = request.data.get("manual_archetype")
84
+ if manual_arch:
85
+ settings["manual_archetype"] = manual_arch
86
+ intensity = request.data.get("intensity_multiplier")
87
+ if intensity is not None:
88
+ try:
89
+ settings["intensity_multiplier"] = float(intensity)
90
+ except ValueError:
91
+ pass
92
+ features = request.data.get("features")
93
+ if isinstance(features, dict):
94
+ settings["features"] = {
95
+ "aura": bool(features.get("aura", True)),
96
+ "font": bool(features.get("font", True)),
97
+ "accent": bool(features.get("accent", True)),
98
+ }
99
+ profile.personalization_settings = settings
100
+ profile.save()
101
+
102
+ # Clear cache for middleware
103
+ from django.core.cache import cache
104
+
105
+ cache.delete(f"personalization_drift_user_{request.user.id}")
106
+
107
+ service = container.core.synaptic_plasticity_simulator()
108
+ tau_plus = request.data.get("tau_plus")
109
+ if tau_plus is not None:
110
+ try:
111
+ service.tau_plus = float(tau_plus)
112
+ except ValueError:
113
+ pass
114
+ tau_minus = request.data.get("tau_minus")
115
+ if tau_minus is not None:
116
+ try:
117
+ service.tau_minus = float(tau_minus)
118
+ except ValueError:
119
+ pass
120
+
121
+ service.save_checkpoint()
122
+ return self.get(request)
123
+
124
+ if action == "compile":
125
+ deduct_berrix(
126
+ request.user,
127
+ FEATURE_BX_COSTS["singularity_basic"],
128
+ "Singularity: Compilation de Kernel",
129
+ )
130
+ function_name = request.data.get(
131
+ "function_name", "cosine_similarity"
132
+ ).strip()
133
+ allowed_functions = [
134
+ "cosine_similarity",
135
+ "euclidean_distance",
136
+ "vector_norm",
137
+ ]
138
+ if function_name not in allowed_functions:
139
+ return Response(
140
+ {"error": f'Function "{function_name}" not allowed.'}, status=400
141
+ )
142
+ try:
143
+ compiler = container.core.self_evolving_compiler()
144
+ optimized_fn = compiler.analyze_and_optimize(function_name)
145
+ a = np.array([1.0, 2.0, 3.0])
146
+ b = np.array([1.0, 2.0, 3.0])
147
+ test_val = optimized_fn(a, b)
148
+ return Response(
149
+ {
150
+ "status": "success",
151
+ "message": f"Kernel '{function_name}' compilé dynamiquement !",
152
+ "test_output": f"Result: {test_val:.4f}",
153
+ "mode": compiler.mode,
154
+ }
155
+ )
156
+ except Exception:
157
+ logger.exception("Error in singularity compile action")
158
+ return Response({"error": "Internal server error"}, status=500)
159
+
160
+ elif action == "plasticity":
161
+ deduct_berrix(
162
+ request.user,
163
+ FEATURE_BX_COSTS["singularity_basic"],
164
+ "Singularity: Plasticité Synaptique",
165
+ )
166
+ activations = request.data.get(
167
+ "activations", [1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
168
+ )
169
+ active_indices = request.data.get("trigger_spikes", [0, 1])
170
+ learning_rate = float(request.data.get("learning_rate", 0.05))
171
+ try:
172
+ service = container.core.synaptic_plasticity_simulator()
173
+ now = time.time()
174
+ service.trigger_spikes(active_indices, now)
175
+ updated_W = service.update_hebbian(
176
+ activations, learning_rate=learning_rate
177
+ )
178
+
179
+ # Check for STDP if two specific indices provided
180
+ stdp_log = []
181
+ if len(active_indices) >= 2:
182
+ pre, post = active_indices[0], active_indices[1]
183
+ dW = service.update_stdp(pre, post, learning_rate=learning_rate)
184
+ stdp_log.append(f"STDP Update: {pre} -> {post} (dW: {dW:+.4f})")
185
+
186
+ return Response(
187
+ {
188
+ "status": "success",
189
+ "message": "Plasticité synaptique synchronisée !",
190
+ "weights": updated_W.tolist(),
191
+ "weights_mean": float(np.mean(updated_W)),
192
+ "stdp_log": stdp_log,
193
+ }
194
+ )
195
+ except Exception:
196
+ logger.exception("Error in singularity plasticity action")
197
+ return Response({"error": "Internal server error"}, status=500)
198
+
199
+ elif action == "quantum":
200
+ deduct_berrix(
201
+ request.user,
202
+ FEATURE_BX_COSTS["singularity_basic"],
203
+ "Singularity: Effondrement Quantique",
204
+ )
205
+ theme = request.data.get("theme", "shonen").lower()
206
+ try:
207
+ model = container.core.quantum_cognitive_model()
208
+ prob, outcome = model.measure_preference(theme)
209
+ return Response(
210
+ {
211
+ "status": "success",
212
+ "theme": theme,
213
+ "probability": float(prob),
214
+ "outcome": outcome,
215
+ "state_vector": [str(x) for x in model.state],
216
+ "message": f"Effondrement quantique sur '{theme}' réussi.",
217
+ }
218
+ )
219
+ except Exception:
220
+ logger.exception("Error in singularity quantum action")
221
+ return Response({"error": "Internal server error"}, status=500)
222
+
223
+ elif action == "evolve_dynamic":
224
+ deduct_berrix(
225
+ request.user,
226
+ FEATURE_BX_COSTS["singularity_mid"],
227
+ "Singularity: Évolution LLM Dynamique",
228
+ )
229
+ task = request.data.get("task", "dot_product")
230
+ try:
231
+ compiler = container.core.self_evolving_compiler()
232
+ llm = container.agentic.llm_service()
233
+ # Évolution dynamique réelle via LLM
234
+ fn = compiler.evolve_with_llm(task, llm_proxy=llm)
235
+
236
+ # Test du kernel avec des données bidon
237
+ a = np.array([1.0, 2.0, 3.0, 4.0])
238
+ b = np.array([5.0, 6.0, 7.0, 8.0])
239
+ try:
240
+ res = fn(a, b)
241
+ except Exception:
242
+ res = "Kernel compiled but execution failed (check input types)."
243
+
244
+ return Response(
245
+ {
246
+ "status": "success",
247
+ "result": str(res),
248
+ "kernel_name": fn.__name__,
249
+ "message": f"Nouveau kernel '{fn.__name__}' généré par LLM et injecté.",
250
+ }
251
+ )
252
+ except Exception:
253
+ logger.exception("Error in singularity evolve_dynamic action")
254
+ return Response({"error": "Internal server error"}, status=500)
255
+
256
+ elif action == "swarm":
257
+ deduct_berrix(
258
+ request.user,
259
+ FEATURE_BX_COSTS["singularity_mid"],
260
+ "Singularity: Consensus Swarm P2P",
261
+ )
262
+ fact = request.data.get("fact")
263
+ media = request.data.get("media")
264
+ if not fact or not media:
265
+ return Response({"error": "fact and media are required"}, status=400)
266
+
267
+ try:
268
+ orchestrator = container.core.swarm_consensus_orchestrator()
269
+ # On utilise les diagnostics pour exposer Paxos-sémantique
270
+ diagnostics = orchestrator.get_paxos_diagnostics(
271
+ fact=fact, media_title=media
272
+ )
273
+ return Response(diagnostics)
274
+ except Exception:
275
+ logger.exception("Error in singularity swarm action")
276
+ return Response({"error": "Internal server error"}, status=500)
277
+
278
+ elif action == "synthesize":
279
+ deduct_berrix(
280
+ request.user,
281
+ FEATURE_BX_COSTS["multiverse_synth"],
282
+ "Singularity: Synthèse Multivers",
283
+ )
284
+ universe_name = request.data.get("universe_name", "Unnamed Universe")
285
+ genre = request.data.get("genre", "Cyberpunk")
286
+
287
+ try:
288
+ synthesizer = container.core.autonomous_domain_synthesizer()
289
+ universe_data = synthesizer.synthesize_multiverse(
290
+ universe_name=universe_name, primary_genre=genre
291
+ )
292
+
293
+ # CRITICAL: Persist for HITL validation
294
+ persisted = synthesizer.persist_universe_to_graph(universe_data)
295
+
296
+ evaluation = synthesizer.evaluate_coherence_and_interest(universe_data)
297
+
298
+ return Response(
299
+ {
300
+ "status": "success",
301
+ "universe": universe_data,
302
+ "evaluation": evaluation,
303
+ "persisted": persisted,
304
+ "message": (
305
+ f"Univers '{universe_name}' synthétisé et stagé pour validation."
306
+ if persisted
307
+ else f"Univers '{universe_name}' synthétisé mais rejeté par les filtres de cohérence."
308
+ ),
309
+ }
310
+ )
311
+ except Exception:
312
+ logger.exception("Error in singularity synthesize action")
313
+ return Response({"error": "Internal server error"}, status=500)
314
+
315
+ return Response({"error": "Action inconnue"}, status=400)
316
+
317
+
318
+ class LiquidNeuralNetworkLabView(APIView):
319
+ """Simulateur neuromorphique de réseaux de neurones liquides (LNN)."""
320
+
321
+ permission_classes = [permissions.IsAuthenticated]
322
+
323
+ def post(self, request):
324
+ container = get_container()
325
+ input_signal = request.data.get("signal", [[0.5, 0.2]])
326
+ dt = float(request.data.get("dt", 0.05))
327
+ try:
328
+ lnn = container.core.liquid_neural_network()
329
+ state_history = lnn.process_continuous_signal(input_signal, dt=dt)
330
+ return Response(
331
+ {
332
+ "status": "success",
333
+ "state_history": state_history,
334
+ "final_state": lnn.state.tolist(),
335
+ }
336
+ )
337
+ except Exception:
338
+ logger.exception("Error in LiquidNeuralNetworkLabView")
339
+ return Response({"error": "Internal server error"}, status=500)
340
+
341
+
342
+ class TreeOfThoughtsLabView(APIView):
343
+ # GPU-backed reasoning (multi-node ToT) → requires login and consumes Berrix.
344
+ permission_classes = [permissions.IsAuthenticated]
345
+
346
+ @inject
347
+ def __init__(
348
+ self, tot_service=Provide[Container.core.tree_of_thoughts_service], **kwargs
349
+ ):
350
+ super().__init__(**kwargs)
351
+ self.tot_service = tot_service
352
+
353
+ def post(self, request):
354
+ query = request.data.get("query")
355
+ if not query:
356
+ return Response(
357
+ {"error": "Query required"}, status=status.HTTP_400_BAD_REQUEST
358
+ )
359
+ # GPU → consume Berrix (before the try so a 402 isn't swallowed into a 500).
360
+ deduct_berrix(
361
+ request.user, FEATURE_BX_COSTS["tree_of_thoughts"], "Tree-of-Thoughts (IA)"
362
+ )
363
+ try:
364
+ result = self.tot_service.solve_with_tree_of_thoughts(query)
365
+ return Response(result)
366
+ except Exception:
367
+ logger.exception("Error in TreeOfThoughtsLabView")
368
+ return Response(
369
+ {"error": "Internal server error"},
370
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
371
+ )
372
+
373
+
374
+ class SingularityCommandCenterView(APIView):
375
+ """
376
+ Vue unifiée pour le monitoring et le contrôle des expériences IA avancées.
377
+ Agrège les données de santé de Quantum, Plasticity, Swarm, LNN, etc.
378
+ """
379
+
380
+ permission_classes = [permissions.IsAuthenticated]
381
+
382
+ def get(self, request):
383
+ get_container()
384
+
385
+ # 1. État des services (Simulation de santé système)
386
+ services = []
387
+ try:
388
+ # Quantum
389
+ services.append(
390
+ {
391
+ "id": "quantum",
392
+ "name": "Quantum Cognitive",
393
+ "status": "online",
394
+ "load": random.randint(5, 25), # nosec B311
395
+ "metrics": {"coherence": 0.98, "dimension": 4},
396
+ }
397
+ )
398
+
399
+ # Plasticity
400
+ services.append(
401
+ {
402
+ "id": "plasticity",
403
+ "name": "Synaptic Plasticity",
404
+ "status": "online",
405
+ "load": random.randint(10, 40),
406
+ "metrics": {"learning_rate": 0.05, "synapses": 1024},
407
+ }
408
+ )
409
+
410
+ # Swarm
411
+ services.append(
412
+ {
413
+ "id": "swarm",
414
+ "name": "P2P Swarm Consensus",
415
+ "status": "online",
416
+ "load": random.randint(15, 60),
417
+ "metrics": {"nodes": 12, "consensus_rate": 0.95},
418
+ }
419
+ )
420
+
421
+ # LNN
422
+ services.append(
423
+ {
424
+ "id": "lnn",
425
+ "name": "Liquid Neural Network",
426
+ "status": "online",
427
+ "load": random.randint(20, 80),
428
+ "metrics": {"stability": 0.92, "dt": 0.05},
429
+ }
430
+ )
431
+ except Exception as e:
432
+ logger.error(f"Failed to aggregate health metrics for Ghost Labs: {e}")
433
+
434
+ # 2. Événements récents (Simulés pour le dashboard)
435
+ events = [
436
+ {
437
+ "time": (
438
+ datetime.datetime.now() - datetime.timedelta(minutes=2)
439
+ ).isoformat(),
440
+ "type": "QUANTUM",
441
+ "msg": "Effondrement de fonction d'onde détecté (Shonen).",
442
+ },
443
+ {
444
+ "time": (
445
+ datetime.datetime.now() - datetime.timedelta(minutes=5)
446
+ ).isoformat(),
447
+ "type": "PLASTICITY",
448
+ "msg": "Apprentissage Hebbien complété pour user_77.",
449
+ },
450
+ {
451
+ "time": (
452
+ datetime.datetime.now() - datetime.timedelta(minutes=12)
453
+ ).isoformat(),
454
+ "type": "SWARM",
455
+ "msg": "Consensus Paxos-sémantique atteint sur 'One Piece Timeline'.",
456
+ },
457
+ ]
458
+
459
+ return Response(
460
+ {
461
+ "status": "operational",
462
+ "services": services,
463
+ "events": events,
464
+ "system_load": random.randint(30, 50), # nosec B311
465
+ }
466
+ )
467
+
468
+
469
+ class NeuralDiagnosticsLabView(APIView):
470
+ """
471
+ Expose AI diagnostics for the Neural Diagnostics Dashboard.
472
+ Uses native logprobs and XAI service.
473
+ """
474
+
475
+ # GPU-backed (LLM generate + logprobs) → requires login and consumes Berrix.
476
+ permission_classes = [permissions.IsAuthenticated]
477
+
478
+ def post(self, request):
479
+ prompt = request.data.get("prompt")
480
+ if not prompt:
481
+ return Response(
482
+ {"error": "Prompt is required."}, status=status.HTTP_400_BAD_REQUEST
483
+ )
484
+
485
+ # GPU → consume Berrix (before the try so a 402 isn't swallowed into a 500).
486
+ deduct_berrix(
487
+ request.user,
488
+ FEATURE_BX_COSTS["neural_diagnostics"],
489
+ "Neural Diagnostics (IA)",
490
+ )
491
+
492
+ container = get_container()
493
+ try:
494
+ inference_engine = container.inference.inference_engine()
495
+ xai_service = container.core.xai_service()
496
+
497
+ # 1. Generate response with logprobs
498
+ response = inference_engine.generate(prompt, include_logprobs=True)
499
+
500
+ # 2. Get rich diagnostics report
501
+ report = xai_service.get_diagnostics_report(prompt, response)
502
+
503
+ return Response(report)
504
+ except Exception:
505
+ logger.exception("Neural Diagnostics Error")
506
+ return Response(
507
+ {"error": "Internal server error"},
508
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
509
+ )
backend/api/animetix/api/labs/spatial.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spatial-computing labs: image/video to 3D reconstruction."""
2
+
3
+ from animetix_project.logging_config import get_logger
4
+ from rest_framework import permissions
5
+ from rest_framework.response import Response
6
+ from rest_framework.views import APIView
7
+
8
+ from ...containers import get_container # noqa: E402
9
+
10
+ logger = get_logger("animetix." + __name__)
11
+
12
+ from core.domain.services.berrix_economy import FEATURE_BX_COSTS # noqa: E402
13
+
14
+ from animetix.api.billing import deduct_berrix # noqa: E402
15
+
16
+
17
+ class SpatialLabDataView(APIView):
18
+ """Métadonnées pour les outils de Calcul Spatial."""
19
+
20
+ permission_classes = [permissions.AllowAny]
21
+
22
+ def get(self, request):
23
+ return Response(
24
+ {
25
+ "status": "active",
26
+ "tools": [
27
+ {
28
+ "id": "generate-3d",
29
+ "name": "Image-to-3D",
30
+ "description": "Generate a navigable 3D scene from a single image (Gaussian Splatting).",
31
+ "endpoint": "/api/v1/labs/spatial/generate-3d/",
32
+ },
33
+ {
34
+ "id": "cinematic",
35
+ "name": "Cinematic Reconstruction",
36
+ "description": "Reconstruct dynamic volumetric 3D scenes from video clips.",
37
+ "endpoint": "/api/v1/labs/spatial/cinematic/",
38
+ },
39
+ ],
40
+ }
41
+ )
42
+
43
+
44
+ class Generate3DDataView(APIView):
45
+ """Génère une scène 3D (PLY) à partir d'une image."""
46
+
47
+ permission_classes = [permissions.IsAuthenticated]
48
+
49
+ def post(self, request):
50
+ container = get_container()
51
+ image_file = request.FILES.get("image")
52
+ title = request.data.get("title", "Poster 3D")
53
+
54
+ if not image_file:
55
+ return Response({"error": "Image file is required."}, status=400)
56
+
57
+ # Déduction des Berrix (150 Bx pour reconstruction 3D Gaussian Splatting)
58
+ deduct_berrix(
59
+ request.user, FEATURE_BX_COSTS["image_to_3d"], f"Image-to-3D: {title}"
60
+ )
61
+
62
+ try:
63
+ image_bytes = image_file.read()
64
+ service = container.core.spatial_computing_service()
65
+ result = service.reconstruct_3d_scene(image_bytes, title)
66
+ return Response(result)
67
+ except Exception:
68
+ logger.exception("Error in Generate3DDataView")
69
+ return Response({"error": "Internal server error"}, status=500)
70
+
71
+
72
+ class CinematicReconstructionView(APIView):
73
+ """Génère une séquence de scènes 3D à partir d'une vidéo."""
74
+
75
+ permission_classes = [permissions.IsAuthenticated]
76
+
77
+ def post(self, request):
78
+ container = get_container()
79
+ video_file = request.FILES.get("video")
80
+ title = request.data.get("title", "Cinematic 3D")
81
+
82
+ if not video_file:
83
+ return Response({"error": "Video file is required."}, status=400)
84
+
85
+ # Déduction des Berrix (500 Bx pour reconstruction volumétrique dynamique)
86
+ deduct_berrix(
87
+ request.user,
88
+ FEATURE_BX_COSTS["cinematic_3d"],
89
+ f"Cinematic 3D Reconstruction: {title}",
90
+ )
91
+
92
+ try:
93
+ video_bytes = video_file.read()
94
+ service = container.core.cinematic_volumetric_reconstruction_service()
95
+ result = service.reconstruct_dynamic_cinematic_scene(video_bytes, title)
96
+ return Response(result)
97
+ except Exception:
98
+ logger.exception("Error in CinematicReconstructionView")
99
+ return Response({"error": "Internal server error"}, status=500)
backend/api/animetix/api/labs/video.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Video labs: style transfer (FateZero) and Video-RAG indexing/search."""
2
+
3
+ from animetix_project.logging_config import get_logger
4
+ from rest_framework import permissions
5
+ from rest_framework.response import Response
6
+ from rest_framework.throttling import ScopedRateThrottle
7
+ from rest_framework.views import APIView
8
+
9
+ from ...containers import get_container # noqa: E402
10
+
11
+ logger = get_logger("animetix." + __name__)
12
+
13
+ from core.domain.services.berrix_economy import FEATURE_BX_COSTS # noqa: E402
14
+
15
+ from animetix.api.billing import deduct_berrix # noqa: E402
16
+
17
+
18
+ class VideoFateZeroLabView(APIView):
19
+ """Transforme une vidéo avec transfert de style SOTA (FateZero)."""
20
+
21
+ permission_classes = [permissions.IsAuthenticated]
22
+ throttle_scope = "gpu"
23
+ throttle_classes = [ScopedRateThrottle]
24
+
25
+ def post(self, request):
26
+ container = get_container()
27
+ video_file = request.FILES.get("video")
28
+ studio_style = request.data.get("studio_style", "Ufotable")
29
+
30
+ if not video_file:
31
+ return Response({"error": "Video file is required."}, status=400)
32
+
33
+ # Déduction des Berrix (500 Bx pour transfert de style vidéo - Très lourd)
34
+ deduct_berrix(
35
+ request.user,
36
+ FEATURE_BX_COSTS["style_transfer"],
37
+ f"FateZero Style Transfer: {studio_style}",
38
+ )
39
+
40
+ try:
41
+ video_bytes = video_file.read()
42
+ service = container.core.studio_transform_service()
43
+
44
+ result_url = service.transform_video_to_anime_sota(
45
+ video_bytes, studio_style
46
+ )
47
+
48
+ return Response(
49
+ {
50
+ "status": "success",
51
+ "video_url": result_url,
52
+ "message": f"Transformation {studio_style} (FateZero) réussie.",
53
+ }
54
+ )
55
+ except Exception:
56
+ logger.exception("Error in VideoFateZeroLabView")
57
+ return Response({"error": "Internal server error"}, status=500)
58
+
59
+
60
+ class VideoRAGIndexView(APIView):
61
+ """Endpoint pour indexer une vidéo dans le Video-RAG."""
62
+
63
+ permission_classes = [permissions.IsAdminUser]
64
+
65
+ def post(self, request):
66
+ video_file = request.FILES.get("video")
67
+ video_id = request.data.get("video_id")
68
+
69
+ if not video_file or not video_id:
70
+ return Response({"error": "video and video_id are required"}, status=400)
71
+
72
+ container = get_container()
73
+ video_rag = container.agentic.video_rag_service()
74
+
75
+ try:
76
+ video_data = video_file.read()
77
+ count = video_rag.index_video(video_id, video_data)
78
+ return Response({"status": "success", "indexed_segments": count})
79
+ except Exception:
80
+ logger.exception("VideoRAGIndex Error")
81
+ return Response({"error": "Internal server error"}, status=500)
82
+
83
+
84
+ class VideoRAGSearchView(APIView):
85
+ """Endpoint pour rechercher des moments précis dans les vidéos indexées.
86
+
87
+ GPU/RAG : requiert login + consomme des Berrix (règle « GPU = Bx »).
88
+ """
89
+
90
+ permission_classes = [permissions.IsAuthenticated]
91
+ throttle_scope = "gpu"
92
+ throttle_classes = [ScopedRateThrottle]
93
+
94
+ def get(self, request):
95
+ query = request.GET.get("q")
96
+ if not query:
97
+ return Response({"error": "query q is required"}, status=400)
98
+
99
+ container = get_container()
100
+ usage_port = container.infrastructure.usage_port()
101
+ tier = getattr(request, "user_tier", "free")
102
+ if not usage_port.check_quota(request.user.id, tier):
103
+ return Response({"error": "Daily AI quota exceeded."}, status=403)
104
+ deduct_berrix(
105
+ request.user, FEATURE_BX_COSTS["video_rag"], "VideoRAG — recherche vidéo"
106
+ )
107
+
108
+ video_rag = container.agentic.video_rag_service()
109
+ try:
110
+ results = video_rag.search_video_segment(query, limit=10)
111
+ usage_port.log_usage(engine="video-rag", units=1, user_id=request.user.id)
112
+ return Response({"status": "success", "results": results})
113
+ except Exception:
114
+ logger.exception("VideoRAGSearch Error")
115
+ return Response({"error": "Internal server error"}, status=500)
116
+
117
+
118
+ class VideoLabDataView(APIView):
119
+ """Métadonnées pour les outils du Video Lab."""
120
+
121
+ permission_classes = [permissions.AllowAny]
122
+
123
+ def get(self, request):
124
+ return Response(
125
+ {
126
+ "status": "active",
127
+ "tools": [
128
+ {
129
+ "id": "fatezero",
130
+ "name": "FateZero Style Transfer",
131
+ "description": "Temporally consistent anime style transfer for real videos.",
132
+ "endpoint": "/api/v1/labs/video/fatezero/",
133
+ "supported_styles": ["Shaft", "Ufotable", "Kyoto", "Ghibli"],
134
+ },
135
+ {
136
+ "id": "video-rag",
137
+ "name": "Video-RAG Search",
138
+ "description": "Semantic search for precise moments in anime videos.",
139
+ "endpoint": "/api/v1/labs/video/search/",
140
+ },
141
+ ],
142
+ }
143
+ )
backend/api/animetix/api/mlops.py CHANGED
@@ -137,9 +137,11 @@ class AIFeedbackAPIView(APIView):
137
 
138
  def get(self, request):
139
  """Récupère l'historique des feedbacks de l'utilisateur connecté."""
140
- feedbacks = AIFeedback.objects.filter(user=request.user).order_by(
141
- "-created_at"
142
- )[:100]
 
 
143
  serializer = AIFeedbackSerializer(feedbacks, many=True)
144
  return Response(serializer.data)
145
 
 
137
 
138
  def get(self, request):
139
  """Récupère l'historique des feedbacks de l'utilisateur connecté."""
140
+ feedbacks = (
141
+ AIFeedback.objects.filter(user=request.user)
142
+ .select_related("user")
143
+ .order_by("-created_at")[:100]
144
+ )
145
  serializer = AIFeedbackSerializer(feedbacks, many=True)
146
  return Response(serializer.data)
147
 
backend/api/animetix/api/social.py CHANGED
@@ -534,7 +534,7 @@ class ProfileDetailView(APIView):
534
  serializer = ProfileSerializer(profile)
535
  # On ajoute les succès et les fusions récentes
536
  data = serializer.data
537
- data["achievements_count"] = user.user_achievements.count()
538
  data["collection_count"] = CreativeFusion.objects.filter(creator=user).count()
539
  data["recent_achievements"] = AchievementSerializer(
540
  Achievement.objects.filter(userachievement__user=user).order_by(
@@ -542,12 +542,17 @@ class ProfileDetailView(APIView):
542
  )[:5],
543
  many=True,
544
  ).data
 
 
 
 
 
545
  data["top_fusions"] = CreativeFusionSerializer(
546
- CreativeFusion.objects.filter(creator=user).order_by("-likes")[:4],
547
  many=True,
548
  ).data
549
  data["recent_fusions"] = CreativeFusionSerializer(
550
- CreativeFusion.objects.filter(creator=user).order_by("-created_at")[:5],
551
  many=True,
552
  ).data
553
  return Response(data)
 
534
  serializer = ProfileSerializer(profile)
535
  # On ajoute les succès et les fusions récentes
536
  data = serializer.data
537
+ data["achievements_count"] = user.userachievement_set.count()
538
  data["collection_count"] = CreativeFusion.objects.filter(creator=user).count()
539
  data["recent_achievements"] = AchievementSerializer(
540
  Achievement.objects.filter(userachievement__user=user).order_by(
 
542
  )[:5],
543
  many=True,
544
  ).data
545
+ fusions_qs = (
546
+ CreativeFusion.objects.filter(creator=user)
547
+ .select_related("creator")
548
+ .prefetch_related("likes")
549
+ )
550
  data["top_fusions"] = CreativeFusionSerializer(
551
+ fusions_qs.order_by("-likes")[:4],
552
  many=True,
553
  ).data
554
  data["recent_fusions"] = CreativeFusionSerializer(
555
+ fusions_qs.order_by("-created_at")[:5],
556
  many=True,
557
  ).data
558
  return Response(data)
backend/api/animetix/apps.py CHANGED
@@ -34,9 +34,18 @@ class AnimetixConfig(AppConfig):
34
  "animetix.api.games.duel",
35
  "animetix.api.games.world_boss",
36
  "animetix.api.explore",
37
- "animetix.api.core",
 
 
 
 
38
  "animetix.api.social",
39
- "animetix.api.labs",
 
 
 
 
 
40
  "animetix.api.mlops",
41
  "animetix.api.graph",
42
  "animetix.api.companion",
 
34
  "animetix.api.games.duel",
35
  "animetix.api.games.world_boss",
36
  "animetix.api.explore",
37
+ "animetix.api.core.accounts",
38
+ "animetix.api.core.config",
39
+ "animetix.api.core.manga",
40
+ "animetix.api.core.media",
41
+ "animetix.api.core.suwayomi",
42
  "animetix.api.social",
43
+ "animetix.api.labs.audio",
44
+ "animetix.api.labs.dashboards",
45
+ "animetix.api.labs.manga",
46
+ "animetix.api.labs.singularity",
47
+ "animetix.api.labs.spatial",
48
+ "animetix.api.labs.video",
49
  "animetix.api.mlops",
50
  "animetix.api.graph",
51
  "animetix.api.companion",
backend/api/animetix/middleware.py CHANGED
@@ -215,3 +215,93 @@ class TracingMiddleware:
215
  span.record_exception(e)
216
  span.set_status(Status(StatusCode.ERROR, description=str(e)))
217
  raise e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  span.record_exception(e)
216
  span.set_status(Status(StatusCode.ERROR, description=str(e)))
217
  raise e
218
+
219
+
220
+ class MaintenanceModeMiddleware:
221
+ """Mode maintenance global (flag ``SiteConfiguration.maintenance_mode``).
222
+
223
+ Quand le flag est ON, les requêtes ``/api/…`` reçoivent 503
224
+ ``{"maintenance": true}`` pour que le frontend bascule sur la page dédiée.
225
+ Exemptions : l'endpoint de config (le poll de sortie doit passer), le
226
+ monitoring (health checks) et le staff — session admin ou token Firebase.
227
+ Hors ``/api/`` (SPA, statiques, /admin/), rien n'est bloqué : le gating UX
228
+ est fait côté frontend.
229
+ """
230
+
231
+ sync_capable = True
232
+ async_capable = True
233
+
234
+ EXEMPT_PREFIXES = (
235
+ "/api/v1/config/",
236
+ "/api/monitoring/",
237
+ )
238
+
239
+ def __init__(self, get_response):
240
+ self.get_response = get_response
241
+ self.async_mode = iscoroutinefunction(self.get_response)
242
+
243
+ # --- Helpers (sync : appelés via sync_to_async en mode ASGI) ---
244
+
245
+ def _guarded(self, request) -> bool:
246
+ path = request.path
247
+ if not path.startswith("/api/"):
248
+ return False
249
+ return not path.startswith(self.EXEMPT_PREFIXES)
250
+
251
+ @staticmethod
252
+ def _maintenance_on() -> bool:
253
+ from .models import SiteConfiguration
254
+
255
+ return SiteConfiguration.get_solo().maintenance_mode
256
+
257
+ @staticmethod
258
+ def _is_staff(request) -> bool:
259
+ """Session admin, ou token Firebase d'un compte staff.
260
+
261
+ L'authentification DRF n'est tentée que sur le chemin bloquant (mode
262
+ ON + non exempt) : coût nul en fonctionnement normal.
263
+ """
264
+ if getattr(request, "user", None) is not None and request.user.is_staff:
265
+ return True
266
+ try:
267
+ from .auth import GoogleIdentityAuthentication
268
+
269
+ result = GoogleIdentityAuthentication().authenticate(request)
270
+ return bool(result and result[0].is_staff)
271
+ except Exception:
272
+ return False
273
+
274
+ @staticmethod
275
+ def _blocked_response():
276
+ from django.http import JsonResponse
277
+
278
+ return JsonResponse(
279
+ {
280
+ "detail": "Animetix est en maintenance. Réessayez dans quelques instants.",
281
+ "maintenance": True,
282
+ },
283
+ status=503,
284
+ )
285
+
286
+ def _should_block(self, request) -> bool:
287
+ return (
288
+ self._guarded(request)
289
+ and self._maintenance_on()
290
+ and not self._is_staff(request)
291
+ )
292
+
293
+ # --- Entrées sync / async ---
294
+
295
+ def __call__(self, request):
296
+ if self.async_mode:
297
+ return self.__acall__(request)
298
+ if self._should_block(request):
299
+ return self._blocked_response()
300
+ return self.get_response(request)
301
+
302
+ async def __acall__(self, request):
303
+ from asgiref.sync import sync_to_async
304
+
305
+ if await sync_to_async(self._should_block)(request):
306
+ return self._blocked_response()
307
+ return await self.get_response(request)
backend/api/animetix/migrations/0050_siteconfiguration.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated by Django 5.2.14 on 2026-07-09 22:30
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("animetix", "0001_squashed_0049_remove_profile_stripe_customer_id_and_more"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.CreateModel(
14
+ name="SiteConfiguration",
15
+ fields=[
16
+ (
17
+ "id",
18
+ models.AutoField(
19
+ auto_created=True,
20
+ primary_key=True,
21
+ serialize=False,
22
+ verbose_name="ID",
23
+ ),
24
+ ),
25
+ (
26
+ "maintenance_mode",
27
+ models.BooleanField(
28
+ default=False,
29
+ help_text="ON : les visiteurs voient la page de maintenance ; le staff navigue normalement.",
30
+ ),
31
+ ),
32
+ (
33
+ "maintenance_message",
34
+ models.TextField(
35
+ blank=True,
36
+ default="",
37
+ help_text="Message optionnel affiché sur la page de maintenance.",
38
+ ),
39
+ ),
40
+ (
41
+ "maintenance_until",
42
+ models.DateTimeField(
43
+ blank=True,
44
+ help_text="Heure de retour estimée (informatif, affiché sur la page).",
45
+ null=True,
46
+ ),
47
+ ),
48
+ ("updated_at", models.DateTimeField(auto_now=True)),
49
+ ],
50
+ options={
51
+ "verbose_name": "Configuration du site",
52
+ "verbose_name_plural": "Configuration du site",
53
+ },
54
+ ),
55
+ ]
backend/api/animetix/models.py CHANGED
@@ -886,3 +886,43 @@ class VoiceProfile(models.Model):
886
  )
887
  return self.origin_detail
888
  return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
886
  )
887
  return self.origin_detail
888
  return ""
889
+
890
+
891
+ # --- SITE CONFIGURATION (singleton) ---
892
+ class SiteConfiguration(models.Model):
893
+ """Réglages globaux du site, togglables depuis l'admin sans redéploiement.
894
+
895
+ Singleton (pk=1) : toujours passer par ``get_solo()``.
896
+ """
897
+
898
+ maintenance_mode = models.BooleanField(
899
+ default=False,
900
+ help_text="ON : les visiteurs voient la page de maintenance ; le staff navigue normalement.",
901
+ )
902
+ maintenance_message = models.TextField(
903
+ blank=True,
904
+ default="",
905
+ help_text="Message optionnel affiché sur la page de maintenance.",
906
+ )
907
+ maintenance_until = models.DateTimeField(
908
+ null=True,
909
+ blank=True,
910
+ help_text="Heure de retour estimée (informatif, affiché sur la page).",
911
+ )
912
+ updated_at = models.DateTimeField(auto_now=True)
913
+
914
+ class Meta:
915
+ verbose_name = "Configuration du site"
916
+ verbose_name_plural = "Configuration du site"
917
+
918
+ def __str__(self):
919
+ return "Configuration du site"
920
+
921
+ def save(self, *args, **kwargs):
922
+ self.pk = 1 # Singleton : toute sauvegarde écrase l'unique ligne.
923
+ super().save(*args, **kwargs)
924
+
925
+ @classmethod
926
+ def get_solo(cls) -> "SiteConfiguration":
927
+ obj, _ = cls.objects.get_or_create(pk=1)
928
+ return obj
backend/api/animetix/serializers.py CHANGED
@@ -384,6 +384,11 @@ class VsBattleSerializer(serializers.ModelSerializer):
384
  def get_is_liked(self, obj):
385
  user = self.context["request"].user if "request" in self.context else None
386
  if user and user.is_authenticated:
 
 
 
 
 
387
  return obj.likes.filter(id=user.id).exists()
388
  return False
389
 
 
384
  def get_is_liked(self, obj):
385
  user = self.context["request"].user if "request" in self.context else None
386
  if user and user.is_authenticated:
387
+ if (
388
+ hasattr(obj, "_prefetched_objects_cache")
389
+ and "likes" in obj._prefetched_objects_cache
390
+ ):
391
+ return user in obj.likes.all()
392
  return obj.likes.filter(id=user.id).exists()
393
  return False
394