diff --git a/aidd_docs/tasks/2026_07/2026_07_21-148-fiche-perso-pr2-edition.md b/aidd_docs/tasks/2026_07/2026_07_21-148-fiche-perso-pr2-edition.md new file mode 100644 index 0000000..63fb11f --- /dev/null +++ b/aidd_docs/tasks/2026_07/2026_07_21-148-fiche-perso-pr2-edition.md @@ -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- » 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-`** (`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//card/`, `.../sets//edit/`, `.../traits//edit/`, `.../actions//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-`** 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. diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 59b9b30..90284c6 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -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 "" diff --git a/locale/fr/LC_MESSAGES/django.mo b/locale/fr/LC_MESSAGES/django.mo index 824be80..46bf3dd 100644 Binary files a/locale/fr/LC_MESSAGES/django.mo and b/locale/fr/LC_MESSAGES/django.mo differ diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 2d1827f..cfc7843 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -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" diff --git a/suddenly/characters/front_urls.py b/suddenly/characters/front_urls.py index 57fbc80..71da32a 100644 --- a/suddenly/characters/front_urls.py +++ b/suddenly/characters/front_urls.py @@ -80,11 +80,26 @@ trait_views.trait_set_delete, name="trait_set_delete", ), + path( + "/traits/sets//card/", + trait_views.trait_set_card, + name="trait_set_card", + ), + path( + "/traits/sets//edit/", + trait_views.trait_set_edit, + name="trait_set_edit", + ), path( "/traits/sets//traits/add/", trait_views.trait_create, name="trait_create", ), + path( + "/traits/traits//edit/", + trait_views.trait_edit, + name="trait_edit", + ), path( "/traits/traits//delete/", trait_views.trait_delete, @@ -95,6 +110,11 @@ trait_views.action_create, name="action_create", ), + path( + "/traits/actions//edit/", + trait_views.action_edit, + name="action_edit", + ), path( "/traits/actions//delete/", trait_views.action_delete, diff --git a/suddenly/characters/trait_views.py b/suddenly/characters/trait_views.py index fd05b93..7c7cc6b 100644 --- a/suddenly/characters/trait_views.py +++ b/suddenly/characters/trait_views.py @@ -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, ) @@ -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- 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: diff --git a/templates/characters/partials/trait_set.html b/templates/characters/partials/trait_set.html index e4c1a9e..4e077eb 100644 --- a/templates/characters/partials/trait_set.html +++ b/templates/characters/partials/trait_set.html @@ -2,42 +2,98 @@ {% comment %} One trait set: traits then actions. Used read-only on the public sheet (editable=False) and with inline HTMX controls in the editor (editable=True). + Inline edit (#148): editing_kind ∈ "set"/"trait"/"action" + editing_pk put one + row into edit mode; every control swaps the single #set- target. Suddenly displays — it never evaluates. No "roll"/"resolve"/"compute" button. {% endcomment %}
-
-

{{ set.label }}

- {% if editable %} - +
+ {% if editable and editing_kind == 'set' and editing_pk == set.pk %} +
+ + + +
+ {% else %} +

{{ set.label }}

+ {% if editable %} +
+ + +
+ {% endif %} {% endif %}
{# ── Traits ────────────────────────────────────────────── #}
    {% for trait in set.traits.all %} -
  • - - {{ trait.name }} - {% if trait.value is not None %} - {{ trait.value|stringformat:"+d" }} + {% if editable and editing_kind == 'trait' and editing_pk == trait.pk %} +
  • +
    + + + + + +
    +
  • + {% else %} +
  • + + {{ trait.name }} + {% if trait.value is not None %} + {{ trait.value|stringformat:"+d" }} + {% endif %} + {% if trait.note %}— {{ trait.note }}{% endif %} + + {% if editable %} + + + + {% endif %} - {% if trait.note %}— {{ trait.note }}{% endif %} - - {% if editable %} - - {% endif %} -
  • + + {% endif %} {% empty %}
  • {% trans "No trait yet." %}
  • {% endfor %} @@ -91,36 +147,76 @@

    {% for action in set.actions.all %} -
    -
    - - {{ action.name }} - {% for t in action.traits.all %} - - {{ t.name }}{% if t.value is not None %} {{ t.value|stringformat:"+d" }}{% endif %} - - {% endfor %} - - {% if editable %} + {% if editable and editing_kind == 'action' and editing_pk == action.pk %} +
    +
    + + {% if set.traits.all %} + + {% endif %} +
    + + +
    + + class="btn-ghost btn-sm">{% trans "Cancel" %} +
    +
    + {% else %} +
    +
    + + {{ action.name }} + {% for t in action.traits.all %} + + {{ t.name }}{% if t.value is not None %} {{ t.value|stringformat:"+d" }}{% endif %} + + {% endfor %} + + {% if editable %} + + + + + {% endif %} +
    + {% if action.condition %} +

    + {% trans "When" %}: {{ action.condition }} +

    + {% endif %} + {% if action.outcome %} +

    + {% trans "Then" %}: {{ action.outcome }} +

    {% endif %}
    - {% if action.condition %} -

    - {% trans "When" %}: {{ action.condition }} -

    - {% endif %} - {% if action.outcome %} -

    - {% trans "Then" %}: {{ action.outcome }} -

    - {% endif %} -
    + {% endif %} {% endfor %}
    diff --git a/tests/characters/test_trait_views.py b/tests/characters/test_trait_views.py index 195d392..d7ab0b5 100644 --- a/tests/characters/test_trait_views.py +++ b/tests/characters/test_trait_views.py @@ -175,6 +175,111 @@ def test_delete_action(self, logged_client: Client, character: Character) -> Non assert not Action.objects.filter(pk=action.pk).exists() +@pytest.mark.django_db +class TestInlineEdit: + """#148 — edit existing sets/traits/actions in place (not delete + recreate).""" + + def test_get_trait_edit_returns_inline_form( + self, logged_client: Client, character: Character + ) -> None: + ts = TraitSet.objects.create(character=character) + trait = Trait.objects.create(trait_set=ts, name="Casse-cou", value=3) + url = reverse( + "characters:trait_edit", kwargs={"slug": character.slug, "trait_pk": trait.pk} + ) + resp = logged_client.get(url) + assert resp.status_code == 200 + # The current value is pre-filled in an editable input (edit mode, not display). + assert b'value="Casse-cou"' in resp.content + assert ( + reverse( + "characters:trait_edit", kwargs={"slug": character.slug, "trait_pk": trait.pk} + ).encode() + in resp.content + ) + + def test_post_trait_edit_updates_fields( + self, logged_client: Client, character: Character + ) -> None: + ts = TraitSet.objects.create(character=character) + trait = Trait.objects.create(trait_set=ts, name="Old", value=1, note="old note") + url = reverse( + "characters:trait_edit", kwargs={"slug": character.slug, "trait_pk": trait.pk} + ) + resp = logged_client.post(url, {"name": "New", "value": "-2", "note": "new note"}) + trait.refresh_from_db() + assert resp.status_code == 200 + assert trait.name == "New" + assert trait.value == -2 + assert trait.note == "new note" + + def test_post_trait_edit_clears_value( + self, logged_client: Client, character: Character + ) -> None: + ts = TraitSet.objects.create(character=character) + trait = Trait.objects.create(trait_set=ts, name="Tagged", value=4) + url = reverse( + "characters:trait_edit", kwargs={"slug": character.slug, "trait_pk": trait.pk} + ) + resp = logged_client.post(url, {"name": "Tagged", "value": "", "note": ""}) + trait.refresh_from_db() + assert resp.status_code == 200 + assert trait.value is None # valueless tag after edit + + def test_post_set_edit_renames_label(self, logged_client: Client, character: Character) -> None: + ts = TraitSet.objects.create(character=character, label="Corps") + url = reverse("characters:trait_set_edit", kwargs={"slug": character.slug, "set_pk": ts.pk}) + resp = logged_client.post(url, {"label": "Esprit"}) + ts.refresh_from_db() + assert resp.status_code == 200 + assert ts.label == "Esprit" + + def test_post_action_edit_updates_fields_and_traits( + self, logged_client: Client, character: Character + ) -> None: + ts = TraitSet.objects.create(character=character) + a = Trait.objects.create(trait_set=ts, name="A", value=1) + b = Trait.objects.create(trait_set=ts, name="B") + action = Action.objects.create(trait_set=ts, character=character, name="Old", condition="c") + action.traits.set([a]) + url = reverse( + "characters:action_edit", kwargs={"slug": character.slug, "action_pk": action.pk} + ) + resp = logged_client.post( + url, + {"name": "Combo", "traits": [str(a.pk), str(b.pk)], "condition": "X", "outcome": "Y"}, + ) + action.refresh_from_db() + assert resp.status_code == 200 + assert action.name == "Combo" + assert action.condition == "X" + assert action.outcome == "Y" + assert action.traits.count() == 2 + + def test_set_card_returns_plain_block( + self, logged_client: Client, character: Character + ) -> None: + ts = TraitSet.objects.create(character=character, label="Corps") + url = reverse("characters:trait_set_card", kwargs={"slug": character.slug, "set_pk": ts.pk}) + resp = logged_client.get(url) + assert resp.status_code == 200 + assert f'id="set-{ts.pk}"'.encode() in resp.content + + def test_stranger_cannot_edit( + self, client: Client, other_user: User, character: Character + ) -> None: + ts = TraitSet.objects.create(character=character) + trait = Trait.objects.create(trait_set=ts, name="Secret", value=1) + client.force_login(other_user) + url = reverse( + "characters:trait_edit", kwargs={"slug": character.slug, "trait_pk": trait.pk} + ) + resp = client.post(url, {"name": "Hacked", "value": "9", "note": ""}) + trait.refresh_from_db() + assert resp.status_code == 403 + assert trait.name == "Secret" + + @pytest.mark.django_db class TestSheetDisplay: def test_traits_render_on_public_sheet(