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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions aidd_docs/tasks/2026_07/2026_07_21-148-fiche-perso-pr2-edition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
objective: >
#148 — PR 2 : éditer les traits/actions existants (③). L'éditeur ne faisait que créer +
supprimer ; on ajoute l'édition en place (lot, trait, action) via HTMX inline, sur le même
patron « swap du bloc #set-<pk> » que l'existant.
success_condition: >
cd app && python manage.py check
&& pytest tests/characters/test_trait_views.py tests/core/test_i18n.py::TestNoFuzzyTranslations -q --no-cov
&& ruff check . && ruff format --check . && mypy suddenly/
&& node design/lint/lint-files.mjs templates/characters/partials/trait_set.html
plan_kind: simple
confidence: 9
iteration: 1
created_at: 2026-07-21
---

# #148 fiche personnage — PR 2 : édition des traits/actions (③)

## Objectif

L'éditeur (`traits_editor` + `trait_set.html`) ne proposait que **créer** et **supprimer** un lot/
trait/action — impossible de modifier après coup (il fallait supprimer puis recréer). PR 2 ajoute
l'**édition en place**, prérequis à la persistance par ligne à la création (② PR 3, décision
utilisateur).

## Contexte technique vérifié

Patron existant : chaque action HTMX **swappe le bloc entier `#set-<pk>`** (`hx-target`/`outerHTML`)
— jamais un layout cassé. On garde ce patron pour l'édition : un bouton crayon charge un formulaire
inline dans le bloc, la sauvegarde re-render le bloc « propre », « Annuler » recharge le bloc.

| Site | Action |
|------|--------|
| `trait_views.py` `_render_set` | Params `editing_kind`/`editing_pk` → met une ligne en mode édition |
| `trait_views.py` (nouveau) | `trait_set_card` (GET, cible « Annuler ») · `trait_set_edit` · `trait_edit` · `action_edit` (GET charge le form, POST sauve) |
| `front_urls.py` | 4 routes : `.../sets/<pk>/card/`, `.../sets/<pk>/edit/`, `.../traits/<pk>/edit/`, `.../actions/<pk>/edit/` |
| `partials/trait_set.html` | En-tête, chaque trait, chaque action : affichage + crayon, OU formulaire inline si en édition. Réutilise `TraitSetForm`/`TraitForm`/`ActionForm` |

Dual GET/POST → garde interne `if request.method != "POST"` (le GET sert le formulaire), pas de
`@require_POST` (règle htmx-patterns, exception endpoint double).

## Décisions

- **Valeur du trait à l'édition** : input `number` simple (optionnel → `None` si vide), plutôt que
le sélecteur −5/+5/free/none du formulaire de création — plus simple, sans Alpine, même résultat
(`TraitForm.value` = `IntegerField(required=False)`).
- **Frontière d'archi respectée** : rien n'est évalué ; on édite du texte affiché.

## Tests
- `trait_edit` GET rend le form inline (valeur pré-remplie) ; POST met à jour name/value/note ;
vidage de valeur → `None`.
- `trait_set_edit` renomme le lot ; `action_edit` met à jour name/condition/outcome/traits.
- `trait_set_card` rend le bloc propre ; étranger interdit (403, pas de modif).

## Vérification
- `manage.py check` ; tests ciblés verts (26 + i18n) ; `ruff check .` + `format --check .` ;
`mypy suddenly/` ; design lint `trait_set.html`. Aucune migration (pas de champ modèle).

## Points de vigilance
- **Swap `#set-<pk>`** partout — cohérent avec create/delete existants ; « Annuler » via
`trait_set_card` (GET propre).
- **i18n** : nouveaux msgids « Rename », « Concept name », « Trait name », « Value », « Action
name » ; fr sur une ligne ; `.mo` recompilés.
- **Reste #148** : ② création identity-first / persistance par ligne (PR 3) ; ⑤ conséquences
conditionnelles (PR 4).

## Évaluation de confiance : 9/10
Ajout mécanique sur un patron éprouvé, réutilise les forms, aucune migration.
20 changes: 20 additions & 0 deletions locale/en/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -3696,6 +3696,26 @@ msgstr ""
msgid "No pending reports."
msgstr ""

#: .\templates\characters\partials\trait_set.html:34
msgid "Rename"
msgstr ""

#: .\templates\characters\partials\trait_set.html:16
msgid "Concept name"
msgstr ""

#: .\templates\characters\partials\trait_set.html:62
msgid "Trait name"
msgstr ""

#: .\templates\characters\partials\trait_set.html:64
msgid "Value"
msgstr ""

#: .\templates\characters\partials\trait_set.html:158
msgid "Action name"
msgstr ""

#: .\templates\core\report_content_form.html:4
msgid "Report %(label)s"
msgstr ""
Expand Down
Binary file modified locale/fr/LC_MESSAGES/django.mo
Binary file not shown.
20 changes: 20 additions & 0 deletions locale/fr/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -3972,6 +3972,26 @@ msgstr "Visible par vous seul (créateur/propriétaire). Jamais affiché publiqu
msgid "Only you (creator/owner) can see this."
msgstr "Vous seul (créateur/propriétaire) pouvez voir ceci."

#: .\templates\characters\partials\trait_set.html:34
msgid "Rename"
msgstr "Renommer"

#: .\templates\characters\partials\trait_set.html:16
msgid "Concept name"
msgstr "Nom du concept"

#: .\templates\characters\partials\trait_set.html:62
msgid "Trait name"
msgstr "Nom du trait"

#: .\templates\characters\partials\trait_set.html:64
msgid "Value"
msgstr "Valeur"

#: .\templates\characters\partials\trait_set.html:158
msgid "Action name"
msgstr "Nom de l'action"

#~ msgid "published reports"
#~ msgstr "comptes-rendus publiés"

Expand Down
20 changes: 20 additions & 0 deletions suddenly/characters/front_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,26 @@
trait_views.trait_set_delete,
name="trait_set_delete",
),
path(
"<slug:slug>/traits/sets/<uuid:set_pk>/card/",
trait_views.trait_set_card,
name="trait_set_card",
),
path(
"<slug:slug>/traits/sets/<uuid:set_pk>/edit/",
trait_views.trait_set_edit,
name="trait_set_edit",
),
path(
"<slug:slug>/traits/sets/<uuid:set_pk>/traits/add/",
trait_views.trait_create,
name="trait_create",
),
path(
"<slug:slug>/traits/traits/<uuid:trait_pk>/edit/",
trait_views.trait_edit,
name="trait_edit",
),
path(
"<slug:slug>/traits/traits/<uuid:trait_pk>/delete/",
trait_views.trait_delete,
Expand All @@ -95,6 +110,11 @@
trait_views.action_create,
name="action_create",
),
path(
"<slug:slug>/traits/actions/<uuid:action_pk>/edit/",
trait_views.action_edit,
name="action_edit",
),
path(
"<slug:slug>/traits/actions/<uuid:action_pk>/delete/",
trait_views.action_delete,
Expand Down
99 changes: 97 additions & 2 deletions suddenly/characters/trait_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,25 @@ def _get_editable_character(request: AuthenticatedRequest, slug: str) -> Charact
return None


def _render_set(request: AuthenticatedRequest, trait_set: TraitSet) -> str:
def _render_set(
request: AuthenticatedRequest,
trait_set: TraitSet,
*,
editing_kind: str = "",
editing_pk: object = None,
) -> str:
"""Re-render a set block. ``editing_kind``/``editing_pk`` put one row
(``"set"`` / ``"trait"`` / ``"action"``) into inline-edit mode (#148)."""
trait_set = TraitSet.objects.prefetch_related("traits", "actions__traits").get(pk=trait_set.pk)
return render_to_string(
"characters/partials/trait_set.html",
{"set": trait_set, "character": trait_set.character, "editable": True},
{
"set": trait_set,
"character": trait_set.character,
"editable": True,
"editing_kind": editing_kind,
"editing_pk": editing_pk,
},
request=request,
)

Expand Down Expand Up @@ -153,6 +167,87 @@ def action_create(request: AuthenticatedRequest, slug: str, set_pk: str) -> Http
return HttpResponse(_render_set(request, trait_set), status=422)


# ── Inline edit (#148) ─────────────────────────────────────────────────────
# GET loads an inline edit form inside the set block; POST saves and re-renders
# the plain block. All swap the single #set-<pk> target (never a broken layout).
# Dual GET/POST → internal method guard, not @require_POST (GET serves the form).


@login_required
def trait_set_card(request: AuthenticatedRequest, slug: str, set_pk: str) -> HttpResponse:
"""Plain (non-editing) re-render of a set block — the "Cancel" target."""
character = _get_editable_character(request, slug)
if character is None:
return HttpResponseForbidden()
trait_set = get_object_or_404(TraitSet, pk=set_pk, character=character)
return HttpResponse(_render_set(request, trait_set))


@login_required
def trait_set_edit(request: AuthenticatedRequest, slug: str, set_pk: str) -> HttpResponse:
character = _get_editable_character(request, slug)
if character is None:
return HttpResponseForbidden()
trait_set = get_object_or_404(TraitSet, pk=set_pk, character=character)
if request.method != "POST":
return HttpResponse(
_render_set(request, trait_set, editing_kind="set", editing_pk=trait_set.pk)
)
form = TraitSetForm(request.POST, instance=trait_set)
if form.is_valid():
form.save()
return HttpResponse(_render_set(request, trait_set))
return HttpResponse(
_render_set(request, trait_set, editing_kind="set", editing_pk=trait_set.pk), status=422
)


@login_required
def trait_edit(request: AuthenticatedRequest, slug: str, trait_pk: str) -> HttpResponse:
character = _get_editable_character(request, slug)
if character is None:
return HttpResponseForbidden()
trait = get_object_or_404(
Trait.objects.select_related("trait_set"), pk=trait_pk, trait_set__character=character
)
trait_set = trait.trait_set
if request.method != "POST":
return HttpResponse(
_render_set(request, trait_set, editing_kind="trait", editing_pk=trait.pk)
)
form = TraitForm(request.POST, instance=trait)
if form.is_valid():
form.save()
return HttpResponse(_render_set(request, trait_set))
return HttpResponse(
_render_set(request, trait_set, editing_kind="trait", editing_pk=trait.pk), status=422
)


@login_required
def action_edit(request: AuthenticatedRequest, slug: str, action_pk: str) -> HttpResponse:
character = _get_editable_character(request, slug)
if character is None:
return HttpResponseForbidden()
action = get_object_or_404(
Action.objects.select_related("trait_set"), pk=action_pk, trait_set__character=character
)
trait_set = action.trait_set
if trait_set is None:
return HttpResponseForbidden()
if request.method != "POST":
return HttpResponse(
_render_set(request, trait_set, editing_kind="action", editing_pk=action.pk)
)
form = ActionForm(request.POST, instance=action, trait_set=trait_set)
if form.is_valid():
form.save()
return HttpResponse(_render_set(request, trait_set))
return HttpResponse(
_render_set(request, trait_set, editing_kind="action", editing_pk=action.pk), status=422
)


@require_POST
@login_required
def action_delete(request: AuthenticatedRequest, slug: str, action_pk: str) -> HttpResponse:
Expand Down
Loading
Loading