From e84af3cb3f089ef37d0070c47f80f20401ca9b76 Mon Sep 17 00:00:00 2001 From: Forge Date: Thu, 19 Mar 2026 05:26:18 +0000 Subject: [PATCH 1/4] feat(posts): add dedicated custom post type edit flow --- CHANGELOG.md | 1 + core/forms.py | 18 +++++ core/tests/test_custom_post_types.py | 60 +++++++++++++++- core/urls.py | 5 ++ core/views.py | 70 ++++++++++++------- .../project_custom_post_type_edit.html | 64 +++++++++++++++++ .../project/project_custom_post_types.html | 47 +++++-------- 7 files changed, 209 insertions(+), 56 deletions(-) create mode 100644 frontend/templates/project/project_custom_post_type_edit.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 67fc218..a66cab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - custom post types can be selected in navigation and applied as generation guidance for title suggestions - custom post-type guidance now also propagates into full article generation, with regression coverage for both title and content generation paths - custom post type create/edit flow now supports optional logo uploads (PNG/JPG/WEBP/GIF up to 2MB), persists logos on the type record, renders logos across post-type UI surfaces, and falls back to default icons when no logo is set + - dedicated custom post type edit page with validated updates for name/prompt/logo, explicit logo removal, and regression coverage for edit persistence + validation failures - Emails - Feedback email (for profiles with one product) - create project reminder for signed up users without project diff --git a/core/forms.py b/core/forms.py index 77c0db3..caa5a47 100644 --- a/core/forms.py +++ b/core/forms.py @@ -346,6 +346,7 @@ class ProjectCustomPostTypeForm(forms.ModelForm): } ), ) + remove_logo = forms.BooleanField(required=False) class Meta: model = ProjectCustomPostType @@ -390,3 +391,20 @@ def clean_logo(self): return logo + def clean(self): + cleaned_data = super().clean() + if cleaned_data.get("remove_logo") and cleaned_data.get("logo"): + self.add_error("remove_logo", "Choose either remove logo or upload a new logo, not both.") + return cleaned_data + + def save(self, commit=True): + instance = super().save(commit=False) + + if self.cleaned_data.get("remove_logo") and instance.logo: + instance.logo.delete(save=False) + instance.logo = None + + if commit: + instance.save() + return instance + diff --git a/core/tests/test_custom_post_types.py b/core/tests/test_custom_post_types.py index f6425f8..9757a4f 100644 --- a/core/tests/test_custom_post_types.py +++ b/core/tests/test_custom_post_types.py @@ -175,20 +175,76 @@ def test_update_duplicate_custom_post_type_name_does_not_500(client): client.force_login(user) response = client.post( reverse( - "project_custom_post_type_update", + "project_custom_post_type_edit", kwargs={"pk": project.id, "post_type_pk": second_type.id}, ), { "name": " technical ", "prompt_guidance": "Updated guidance", }, - follow=True, ) assert response.status_code == 200 second_type.refresh_from_db() assert second_type.name == "Beginner" assert first_type.name == "Technical" + assert "Could not update custom post type" in response.content.decode("utf-8") + + +@pytest.mark.django_db +@override_settings(MEDIA_ROOT="/tmp/tuxseo-test-media") +def test_edit_custom_post_type_updates_name_prompt_and_logo(client): + user = User.objects.create_user("owner-edit", "owner-edit@example.com", "secret") + project = Project.objects.create(profile=user.profile, name="Site", url="https://site.test") + post_type = ProjectCustomPostType.objects.create( + project=project, + name="Tutorial", + prompt_guidance="Step-by-step instructional style.", + ) + + client.force_login(user) + response = client.post( + reverse("project_custom_post_type_edit", kwargs={"pk": project.id, "post_type_pk": post_type.id}), + { + "name": "Case Study", + "prompt_guidance": "Lead with outcomes and quantified impact.", + "logo": SimpleUploadedFile("logo.png", TINY_PNG_BYTES, content_type="image/png"), + }, + ) + + assert response.status_code == 302 + post_type.refresh_from_db() + assert post_type.name == "Case Study" + assert post_type.prompt_guidance == "Lead with outcomes and quantified impact." + assert post_type.logo.name.startswith("custom_post_type_logos/") + + +@pytest.mark.django_db +@override_settings(MEDIA_ROOT="/tmp/tuxseo-test-media") +def test_edit_custom_post_type_can_remove_existing_logo(client): + user = User.objects.create_user("owner-remove-logo", "owner-remove-logo@example.com", "secret") + project = Project.objects.create(profile=user.profile, name="Site", url="https://site.test") + post_type = ProjectCustomPostType.objects.create( + project=project, + name="Tutorial", + prompt_guidance="Step-by-step instructional style.", + logo=SimpleUploadedFile("logo.png", TINY_PNG_BYTES, content_type="image/png"), + ) + + client.force_login(user) + response = client.post( + reverse("project_custom_post_type_edit", kwargs={"pk": project.id, "post_type_pk": post_type.id}), + { + "name": "Tutorial", + "prompt_guidance": "Updated instructional style.", + "remove_logo": "true", + }, + ) + + assert response.status_code == 302 + post_type.refresh_from_db() + assert post_type.prompt_guidance == "Updated instructional style." + assert not post_type.logo @pytest.mark.django_db diff --git a/core/urls.py b/core/urls.py index 9106309..e7427d0 100644 --- a/core/urls.py +++ b/core/urls.py @@ -51,6 +51,11 @@ views.ProjectCustomPostTypePostsView.as_view(), name="project_custom_post_type_posts", ), + path( + "project//posts/custom//edit/", + views.ProjectCustomPostTypeEditView.as_view(), + name="project_custom_post_type_edit", + ), path( "project//posts/custom//update/", views.ProjectCustomPostTypeUpdateView.as_view(), diff --git a/core/views.py b/core/views.py index d22f6cd..11377be 100644 --- a/core/views.py +++ b/core/views.py @@ -1544,36 +1544,58 @@ def post(self, request, *args, **kwargs): return self.render_to_response(context) -class ProjectCustomPostTypeUpdateView(LoginRequiredMixin, View): - def post(self, request, pk, post_type_pk): - project = get_object_or_404(Project, pk=pk, profile=request.user.profile) - custom_post_type = get_object_or_404(ProjectCustomPostType, pk=post_type_pk, project=project) +class ProjectCustomPostTypeEditView(LoginRequiredMixin, TemplateView): + template_name = "project/project_custom_post_type_edit.html" + + def dispatch(self, request, *args, **kwargs): + self.project = get_object_or_404(Project, pk=kwargs["pk"], profile=request.user.profile) + self.custom_post_type = get_object_or_404( + ProjectCustomPostType, + pk=kwargs["post_type_pk"], + project=self.project, + ) + return super().dispatch(request, *args, **kwargs) - form = ProjectCustomPostTypeForm(request.POST, request.FILES, instance=custom_post_type) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["project"] = self.project + context["custom_post_type"] = self.custom_post_type + context["form"] = kwargs.get("form") or ProjectCustomPostTypeForm(instance=self.custom_post_type) + return context + + def get(self, request, *args, **kwargs): + return self.render_to_response(self.get_context_data()) + + def post(self, request, *args, **kwargs): + form = ProjectCustomPostTypeForm( + request.POST, + request.FILES, + instance=self.custom_post_type, + ) if form.is_valid(): try: - form.save() - messages.success(request, f"Updated custom post type '{custom_post_type.name}'.") + updated_custom_post_type = form.save() except ValidationError as error: - validation_messages = [] for field, messages_list in error.message_dict.items(): - joined_messages = ", ".join(messages_list) - validation_messages.append(f"{field}: {joined_messages}") - messages.error( - request, - "Could not update custom post type. " + " ".join(validation_messages), - ) - else: - messages.error(request, "Could not update custom post type. Please check the form.") - for field_name, field_errors in form.errors.items(): - if field_name == "__all__": - label = "Form" - else: - label = form.fields[field_name].label or field_name.replace("_", " ").title() - for field_error in field_errors: - messages.error(request, f"{label}: {field_error}") + target_field = field if field in form.fields else None + for message in messages_list: + form.add_error(target_field, message) + messages.error(request, "Could not update custom post type. Please check the form.") + return self.render_to_response(self.get_context_data(form=form)) - return redirect("project_custom_post_types", pk=project.pk) + messages.success( + request, + f"Updated custom post type '{updated_custom_post_type.name}'.", + ) + return redirect("project_custom_post_types", pk=self.project.pk) + + messages.error(request, "Could not update custom post type. Please check the form.") + return self.render_to_response(self.get_context_data(form=form)) + + +class ProjectCustomPostTypeUpdateView(LoginRequiredMixin, View): + def post(self, request, pk, post_type_pk): + return ProjectCustomPostTypeEditView.as_view()(request, pk=pk, post_type_pk=post_type_pk) class ProjectCustomPostTypeDeleteView(LoginRequiredMixin, View): diff --git a/frontend/templates/project/project_custom_post_type_edit.html b/frontend/templates/project/project_custom_post_type_edit.html new file mode 100644 index 0000000..8a43aaa --- /dev/null +++ b/frontend/templates/project/project_custom_post_type_edit.html @@ -0,0 +1,64 @@ +{% extends "base_project.html" %} + +{% block meta %} +TuxSEO - {{ project.name }} - Edit {{ custom_post_type.name }} + +{% endblock meta %} + +{% block project_content %} +
+
+ {% if custom_post_type.logo %} + {{ custom_post_type.name }} logo + {% else %} +
+ + + +
+ {% endif %} +
+

Edit custom post type

+

Changes apply immediately to new title/content generation for this type.

+
+
+ +
+ {% csrf_token %} +
+ + {{ form.name }} + {% for error in form.name.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.prompt_guidance }} + {% for error in form.prompt_guidance.errors %}

{{ error }}

{% endfor %} +
+ +
+ + {{ form.logo }} +

Accepted: PNG, JPG, WEBP, GIF · max 2MB.

+ {% for error in form.logo.errors %}

{{ error }}

{% endfor %} +
+ + {% if custom_post_type.logo %} +
+ + +
+ {% for error in form.remove_logo.errors %}

{{ error }}

{% endfor %} + {% endif %} + + {% for error in form.non_field_errors %}

{{ error }}

{% endfor %} + +
+ + Cancel + Open posts +
+
+
+{% endblock project_content %} diff --git a/frontend/templates/project/project_custom_post_types.html b/frontend/templates/project/project_custom_post_types.html index 2608a68..31acbc9 100644 --- a/frontend/templates/project/project_custom_post_types.html +++ b/frontend/templates/project/project_custom_post_types.html @@ -40,38 +40,25 @@

Existing custom post types

{% for post_type in custom_post_types %}
-
- {% csrf_token %} -
- {% if post_type.logo %} - {{ post_type.name }} logo - {% else %} -
- - - -
- {% endif %} -

{{ post_type.name }}

-
-
- - -
+
+ {% if post_type.logo %} + {{ post_type.name }} logo + {% else %} +
+ + + +
+ {% endif %}
- - -
-
- - -

Accepted: PNG, JPG, WEBP, GIF · max 2MB.

-
-
- - Open +

{{ post_type.name }}

+

{{ post_type.prompt_guidance|truncatechars:140 }}

- +
+
+ Edit + Open +
{% csrf_token %} From 2f604c79cd8793230480acdffb9e65196d5127c0 Mon Sep 17 00:00:00 2001 From: Forge Date: Thu, 19 Mar 2026 05:32:10 +0000 Subject: [PATCH 2/4] test(posts): handle logo remove/update conflict correctly --- core/forms.py | 3 ++- core/tests/test_custom_post_types.py | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/core/forms.py b/core/forms.py index caa5a47..2fafbb4 100644 --- a/core/forms.py +++ b/core/forms.py @@ -393,7 +393,8 @@ def clean_logo(self): def clean(self): cleaned_data = super().clean() - if cleaned_data.get("remove_logo") and cleaned_data.get("logo"): + has_new_logo_upload = bool(self.files.get("logo")) + if cleaned_data.get("remove_logo") and has_new_logo_upload: self.add_error("remove_logo", "Choose either remove logo or upload a new logo, not both.") return cleaned_data diff --git a/core/tests/test_custom_post_types.py b/core/tests/test_custom_post_types.py index 9757a4f..5ccdb10 100644 --- a/core/tests/test_custom_post_types.py +++ b/core/tests/test_custom_post_types.py @@ -219,6 +219,33 @@ def test_edit_custom_post_type_updates_name_prompt_and_logo(client): assert post_type.logo.name.startswith("custom_post_type_logos/") +@pytest.mark.django_db +@override_settings(MEDIA_ROOT="/tmp/tuxseo-test-media") +def test_edit_custom_post_type_rejects_remove_logo_and_new_upload_together(client): + user = User.objects.create_user("owner-replace-logo", "owner-replace-logo@example.com", "secret") + project = Project.objects.create(profile=user.profile, name="Site", url="https://site.test") + post_type = ProjectCustomPostType.objects.create( + project=project, + name="Tutorial", + prompt_guidance="Step-by-step instructional style.", + logo=SimpleUploadedFile("logo.png", TINY_PNG_BYTES, content_type="image/png"), + ) + + client.force_login(user) + response = client.post( + reverse("project_custom_post_type_edit", kwargs={"pk": project.id, "post_type_pk": post_type.id}), + { + "name": "Tutorial", + "prompt_guidance": "Updated instructional style.", + "remove_logo": "true", + "logo": SimpleUploadedFile("new-logo.png", TINY_PNG_BYTES, content_type="image/png"), + }, + ) + + assert response.status_code == 200 + assert "Choose either remove logo or upload a new logo" in response.content.decode("utf-8") + + @pytest.mark.django_db @override_settings(MEDIA_ROOT="/tmp/tuxseo-test-media") def test_edit_custom_post_type_can_remove_existing_logo(client): From 5c21824515742cdeeadfa69e4ec429d21c20cb01 Mon Sep 17 00:00:00 2001 From: Forge Date: Thu, 19 Mar 2026 05:36:17 +0000 Subject: [PATCH 3/4] fix(posts): allow editing post type without reuploading existing logo --- core/forms.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/forms.py b/core/forms.py index 2fafbb4..f85227c 100644 --- a/core/forms.py +++ b/core/forms.py @@ -380,6 +380,11 @@ def clean_logo(self): if not logo: return logo + # Existing persisted files (when editing without uploading a new file) + # should pass through untouched. + if not hasattr(logo, "content_type"): + return logo + content_type = getattr(logo, "content_type", "") if content_type not in ProjectCustomPostType.logo_allowed_content_types: raise forms.ValidationError( From 204f9775dd0dcf90617d78e8df2d950a5d7b1a67 Mon Sep 17 00:00:00 2001 From: Forge Date: Thu, 19 Mar 2026 05:43:12 +0000 Subject: [PATCH 4/4] fix(posts): harden edit auth flow and logo removal persistence --- core/forms.py | 6 ++- core/tests/test_custom_post_types.py | 22 ++++++++++ core/views.py | 65 ++++++++++++++++++++++------ 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/core/forms.py b/core/forms.py index f85227c..472d1eb 100644 --- a/core/forms.py +++ b/core/forms.py @@ -3,6 +3,7 @@ import requests from allauth.account.forms import LoginForm, SignupForm from django import forms +from django.db import transaction from core.abuse_prevention import ( get_request_ip_address, @@ -406,11 +407,14 @@ def clean(self): def save(self, commit=True): instance = super().save(commit=False) + old_logo = None if self.cleaned_data.get("remove_logo") and instance.logo: - instance.logo.delete(save=False) + old_logo = instance.logo instance.logo = None if commit: instance.save() + if old_logo: + transaction.on_commit(lambda: old_logo.delete(save=False)) return instance diff --git a/core/tests/test_custom_post_types.py b/core/tests/test_custom_post_types.py index 5ccdb10..8c8e72c 100644 --- a/core/tests/test_custom_post_types.py +++ b/core/tests/test_custom_post_types.py @@ -191,6 +191,24 @@ def test_update_duplicate_custom_post_type_name_does_not_500(client): assert "Could not update custom post type" in response.content.decode("utf-8") +@pytest.mark.django_db +def test_edit_custom_post_type_requires_authentication(client): + user = User.objects.create_user("owner-edit-auth", "owner-edit-auth@example.com", "secret") + project = Project.objects.create(profile=user.profile, name="Site", url="https://site.test") + post_type = ProjectCustomPostType.objects.create( + project=project, + name="Tutorial", + prompt_guidance="Step-by-step instructional style.", + ) + + response = client.get( + reverse("project_custom_post_type_edit", kwargs={"pk": project.id, "post_type_pk": post_type.id}) + ) + + assert response.status_code == 302 + assert "/accounts/login/" in response.url + + @pytest.mark.django_db @override_settings(MEDIA_ROOT="/tmp/tuxseo-test-media") def test_edit_custom_post_type_updates_name_prompt_and_logo(client): @@ -232,6 +250,8 @@ def test_edit_custom_post_type_rejects_remove_logo_and_new_upload_together(clien ) client.force_login(user) + original_logo_name = post_type.logo.name + response = client.post( reverse("project_custom_post_type_edit", kwargs={"pk": project.id, "post_type_pk": post_type.id}), { @@ -244,6 +264,8 @@ def test_edit_custom_post_type_rejects_remove_logo_and_new_upload_together(clien assert response.status_code == 200 assert "Choose either remove logo or upload a new logo" in response.content.decode("utf-8") + post_type.refresh_from_db() + assert post_type.logo.name == original_logo_name @pytest.mark.django_db diff --git a/core/views.py b/core/views.py index 11377be..40d7a8f 100644 --- a/core/views.py +++ b/core/views.py @@ -1547,20 +1547,30 @@ def post(self, request, *args, **kwargs): class ProjectCustomPostTypeEditView(LoginRequiredMixin, TemplateView): template_name = "project/project_custom_post_type_edit.html" - def dispatch(self, request, *args, **kwargs): - self.project = get_object_or_404(Project, pk=kwargs["pk"], profile=request.user.profile) - self.custom_post_type = get_object_or_404( - ProjectCustomPostType, - pk=kwargs["post_type_pk"], - project=self.project, - ) - return super().dispatch(request, *args, **kwargs) + def get_project(self): + if not hasattr(self, "_project"): + self._project = get_object_or_404( + Project, + pk=self.kwargs["pk"], + profile=self.request.user.profile, + ) + return self._project + + def get_custom_post_type(self): + if not hasattr(self, "_custom_post_type"): + self._custom_post_type = get_object_or_404( + ProjectCustomPostType, + pk=self.kwargs["post_type_pk"], + project=self.get_project(), + ) + return self._custom_post_type def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["project"] = self.project - context["custom_post_type"] = self.custom_post_type - context["form"] = kwargs.get("form") or ProjectCustomPostTypeForm(instance=self.custom_post_type) + custom_post_type = self.get_custom_post_type() + context["project"] = self.get_project() + context["custom_post_type"] = custom_post_type + context["form"] = kwargs.get("form") or ProjectCustomPostTypeForm(instance=custom_post_type) return context def get(self, request, *args, **kwargs): @@ -1570,7 +1580,7 @@ def post(self, request, *args, **kwargs): form = ProjectCustomPostTypeForm( request.POST, request.FILES, - instance=self.custom_post_type, + instance=self.get_custom_post_type(), ) if form.is_valid(): try: @@ -1587,7 +1597,7 @@ def post(self, request, *args, **kwargs): request, f"Updated custom post type '{updated_custom_post_type.name}'.", ) - return redirect("project_custom_post_types", pk=self.project.pk) + return redirect("project_custom_post_types", pk=self.get_project().pk) messages.error(request, "Could not update custom post type. Please check the form.") return self.render_to_response(self.get_context_data(form=form)) @@ -1595,7 +1605,34 @@ def post(self, request, *args, **kwargs): class ProjectCustomPostTypeUpdateView(LoginRequiredMixin, View): def post(self, request, pk, post_type_pk): - return ProjectCustomPostTypeEditView.as_view()(request, pk=pk, post_type_pk=post_type_pk) + project = get_object_or_404(Project, pk=pk, profile=request.user.profile) + custom_post_type = get_object_or_404(ProjectCustomPostType, pk=post_type_pk, project=project) + + form = ProjectCustomPostTypeForm(request.POST, request.FILES, instance=custom_post_type) + if form.is_valid(): + try: + form.save() + messages.success(request, f"Updated custom post type '{custom_post_type.name}'.") + except ValidationError as error: + validation_messages = [] + for field, messages_list in error.message_dict.items(): + joined_messages = ", ".join(messages_list) + validation_messages.append(f"{field}: {joined_messages}") + messages.error( + request, + "Could not update custom post type. " + " ".join(validation_messages), + ) + else: + messages.error(request, "Could not update custom post type. Please check the form.") + for field_name, field_errors in form.errors.items(): + if field_name == "__all__": + label = "Form" + else: + label = form.fields[field_name].label or field_name.replace("_", " ").title() + for field_error in field_errors: + messages.error(request, f"{label}: {field_error}") + + return redirect("project_custom_post_types", pk=project.pk) class ProjectCustomPostTypeDeleteView(LoginRequiredMixin, View):