Files
Forge-Engine/routes/scenes/scene_object_add.py
T
williamandClaude Sonnet 5 55e81c0fbb Ajoute CODE_QUALITY.md et corrige S7632 a la racine (helper assert_not_none)
- Nouveau db/assert_not_none.py : centralise l'unique suppression
  Bandit/Ruff (# nosec B101 / # noqa: S101) de narrowing de type dans
  tout le moteur. Remplace 11 sites disperses (ai/chat.py, ai/tools.py,
  routes/scenes/scene_object_{add,collision,geometry,personnage_data,
  quiz_config}.py, screens/payload/full_game_payload.py,
  scripts/build_demo_dialogues.py) qui repetaient chacun le meme
  commentaire empile - Sonar (python:S7632) ne parse pas deux
  commentaires # sur une ligne, meme si Ruff et Bandit les acceptent
  chacun tres bien (contrainte structurelle documentee dans
  CODE_QUALITY.md : chaque outil exige son propre mot-cle immediatement
  apres un #, aucun format a un seul # ne peut satisfaire les deux a la
  fois). Les 6 sites # nosec B608 (SQL dynamique) restent inchanges,
  nature differente, hors perimetre de ce refactor.
  Verifie : mypy --strict propre (389 fichiers), ruff/bandit/import-linter
  clean, suite complete verte (591 tests), scan SonarQube local relance
  confirmant S7632 a 7 (1 seul site restant dans le helper lui-meme + les
  6 B608), 0 bug (une regression S8371 trouvee et corrigee en route).

- 4 sites |safe repositionnes sur leur ligne exacte (scene_edit.html,
  register_2fa.html, onboarding_new.html, play.html) - le marqueur
  NOSONAR etait sur la ligne precedente par erreur (meme piege que celui
  documente pour S8371 ci-dessus). Confirme par scan que meme corrige,
  l'analyseur Web de Sonar ne supporte aucune syntaxe de suppression
  inline testee pour la regle Web:S5247 - documente comme limitation
  technique connue dans CODE_QUALITY.md plutot que force.

- CODE_QUALITY.md (nouveau) : reference complete du dispositif qualite -
  vue d'ensemble par outil, configuration de chacun, commandes de lancement
  local, procedure de justification d'une exception (avec le format exact
  attendu par Bandit/Ruff/Sonar, verifie empiriquement), table des 10
  exceptions documentees, backlog (djLint H021, code smells Sonar).

djLint (H021, styles inline) volontairement saute pour ce commit - meme
backlog assume que les commits precedents, aucun rapport avec ce changement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 12:41:02 +02:00

138 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from flask import abort, g, jsonify, redirect, request, url_for
from werkzeug.wrappers import Response
import auth
import db
import screens
from core.flask_app import app
from core.sprite_gate import forbid_admin_only_background, forbid_admin_only_character
@app.route("/game/<slug>/screens/<int:screen_id>/scene-objects/add", methods=["POST"])
def scene_object_add(slug: str, screen_id: int) -> Response:
"""Pose un nouvel objet sur la scène — mirror de routes/elements/
element_add.py, mais pour _scene_objects (voir screens/scenes/
add_scene_object.py).
Appelée en AJAX (voir addSceneObject(), static/js/scenes/scene-editor.js)
plutôt qu'en soumission de formulaire classique — sans ça, poser un
objet rechargeait TOUTE la page : perceptible comme un "vidage" de
l'éditeur de collision/quêtes le temps du rechargement, et perdait tout
état JS en cours (modale ouverte, etc.). Répond en JSON avec de quoi
ajouter l'objet au canevas SANS recharger — jamais sélectionné/ouvert
dans le panneau de propriétés pour autant (ça, voir
onSceneObjectMouseDown, reste un rechargement pour l'instant). La liste
"🧩 Collision" n'est PAS incluse ici : addSceneObject() la redemande
ensuite à routes/scenes/scene_collision_rule_objects.py (même endpoint
que pour un changement de rôle/nom, ou une suppression) plutôt que de
dupliquer ici la logique de build_collision_rule_objects()."""
kind = request.form.get("kind", "personnage")
forge_character = request.form.get("forge_character")
background_slug = request.form.get("background_slug")
asset_id = request.form.get("asset_id", type=int)
# Vrai glisser-déposer depuis la galerie (demande explicite : "n'importe
# où sur la scène", voir static/js/scenes/scene-editor.js::onCanvasDrop)
# — position EXACTE du dépôt, en pixels "monde" déjà calculés côté
# client (pas de zoom sur #canvas, voir onCanvasDrop). Absents pour un
# simple clic sur la galerie (comportement par défaut inchangé).
drop_x = request.form.get("x", type=float)
drop_y = request.form.get("y", type=float)
if forge_character:
forbid_admin_only_character(forge_character)
if background_slug:
forbid_admin_only_background(background_slug)
image_url = None
image_width = image_height = None
if asset_id:
# "Mes assets" (voir auth/user_assets_dir.py) : isolation stricte
# par propriétaire, même esprit que les projets (core/auth_guard.py)
# — jamais poser l'asset d'un autre compte, même en devinant son id.
asset = auth.get_user_asset(asset_id)
if not asset or asset["user_id"] != g.current_user["id"]:
abort(403)
image_url = url_for("serve_user_asset", user_id=asset["user_id"], filename=asset["filename"])
if kind == "fond":
# Sans ça, un "fond" importé (contrairement à un fond du
# catalogue Forge, voir background_library.py) retombait
# TOUJOURS sur 128×128 (les valeurs par défaut du schéma) —
# trop petit pour agrandir le "monde" au-delà du champ de la
# caméra, rendant impossible d'y poser un objet en dehors de
# ce champ (voir screens.resolve_scene_world_size). Un "decor"
# n'en a pas besoin (demande explicite : toujours 200×200 à la
# pose, voir add_scene_object.py::_DECOR_DEFAULT_SIZE).
import os
path = os.path.join(auth.user_assets_dir(asset["user_id"]), asset["filename"])
try:
with open(path, "rb") as fh:
dims = auth.image_dimensions(fh.read())
except OSError:
dims = None
if dims:
image_width, image_height = dims
# Un "fond" reste TOUJOURS ancré au coin haut-gauche du monde (0,0),
# jamais à la position du dépôt (bug corrigé : "décalage" — le vrai
# glisser-déposer envoie une position EXACTE même pour un fond, ce qui
# le décalait du coin de la scène et laissait un bandeau de canevas vide
# visible sur le dessus/le côté). Un "decor"/"personnage" garde lui sa
# position de dépôt normale.
new_id = screens.add_scene_object(
slug,
screen_id,
kind=kind,
forge_character=forge_character,
background_slug=background_slug,
image_url=image_url,
image_width=image_width,
image_height=image_height,
x=(None if kind == "fond" else drop_x),
y=(None if kind == "fond" else drop_y),
)
if drop_x is not None and drop_y is not None and kind != "fond":
# Ramène dans le cadre de la caméra si le dépôt était trop proche
# d'un bord (l'objet, une fois sa taille réelle connue, pourrait
# légèrement déborder) — même esprit que ai/tools.py::
# _clamp_to_camera pour l'IA, ici pour un dépôt manuel.
obj_for_clamp = screens.get_scene_object(slug, new_id)
screen = screens.get_screen(slug, screen_id)
obj_for_clamp = db.assert_not_none(
obj_for_clamp, "new_id vient d'etre cree par screens.add_scene_object juste au-dessus"
)
screen = db.assert_not_none(
screen, "screen_id valide : add_scene_object aurait leve IntegrityError sinon (FK scene_id)"
)
max_x = max(0, screen["scene_width"] - obj_for_clamp["width"])
max_y = max(0, screen["scene_height"] - obj_for_clamp["height"])
clamped_x = min(max(drop_x, 0), max_x)
clamped_y = min(max(drop_y, 0), max_y)
if (clamped_x, clamped_y) != (obj_for_clamp["x"], obj_for_clamp["y"]):
screens.update_scene_object_geometry(
slug, new_id, clamped_x, clamped_y, obj_for_clamp["width"], obj_for_clamp["height"]
)
if request.headers.get("X-Requested-With") == "fetch":
obj = screens.get_scene_object(slug, new_id)
obj = db.assert_not_none(obj, "new_id vient d'etre cree par screens.add_scene_object plus haut")
return jsonify(
{
"id": new_id,
"kind": obj["kind"],
"x": obj["x"],
"y": obj["y"],
"width": obj["width"],
"height": obj["height"],
"rendered_html": screens.render_scene_object(obj),
# Relance l'aperçu animé tout de suite (voir personnage-preview.js
# ::restartPersonnageIdlePreview) — sans ça, un personnage
# fraîchement posé restait figé sur sa première frame jusqu'au
# prochain rechargement de page (qui n'arrive plus jamais, voir
# addSceneObject() côté JS).
"animations": screens.resolve_personnage_animations(obj) if obj["kind"] == "personnage" else None,
}
)
# Même route que l'éditeur document (/game/<slug>/screens/<id>/edit,
# endpoint "screen_edit") — c'est CE dispatcher qui bascule vers la
# vue de scène selon game_type, voir routes/screens/screen_edit.py.
# Conservé pour tout appelant non-JS (aucun connu actuellement, mais
# ne coûte rien à garder en secours).
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, selected=new_id))