Spaces:
Build error
Build error
Upload folder using huggingface_hub
Browse files- .gitignore +8 -0
- TODO.md +10 -113
- backend/api/animetix/admin.py +15 -0
- backend/api/animetix/api/core/__init__.py +12 -0
- backend/api/animetix/api/core/accounts.py +113 -0
- backend/api/animetix/api/core/config.py +210 -0
- backend/api/animetix/api/core/manga.py +462 -0
- backend/api/animetix/api/core/media.py +255 -0
- backend/api/animetix/api/core/suwayomi.py +264 -0
- backend/api/animetix/api/games/blindtest.py +24 -1
- backend/api/animetix/api/games/vs_battle.py +6 -1
- backend/api/animetix/api/labs/__init__.py +13 -0
- backend/api/animetix/api/labs/audio.py +270 -0
- backend/api/animetix/api/labs/dashboards.py +132 -0
- backend/api/animetix/api/labs/manga.py +197 -0
- backend/api/animetix/api/labs/singularity.py +509 -0
- backend/api/animetix/api/labs/spatial.py +99 -0
- backend/api/animetix/api/labs/video.py +143 -0
- backend/api/animetix/api/mlops.py +5 -3
- backend/api/animetix/api/social.py +8 -3
- backend/api/animetix/apps.py +11 -2
- backend/api/animetix/middleware.py +90 -0
- backend/api/animetix/migrations/0050_siteconfiguration.py +55 -0
- backend/api/animetix/models.py +40 -0
- backend/api/animetix/serializers.py +5 -0
.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 |
-
|
| 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 |
-
- [
|
| 31 |
-
-
|
| 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 —
|
| 46 |
-
-
|
| 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 |
-
-
|
| 57 |
-
- [
|
| 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 ;
|
| 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 |
-
- [
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
| 547 |
many=True,
|
| 548 |
).data
|
| 549 |
data["recent_fusions"] = CreativeFusionSerializer(
|
| 550 |
-
|
| 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 |
|