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..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, @@ -346,6 +347,7 @@ class ProjectCustomPostTypeForm(forms.ModelForm): } ), ) + remove_logo = forms.BooleanField(required=False) class Meta: model = ProjectCustomPostType @@ -379,6 +381,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( @@ -390,3 +397,24 @@ def clean_logo(self): return logo + def clean(self): + cleaned_data = super().clean() + 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 + + def save(self, commit=True): + instance = super().save(commit=False) + + old_logo = None + if self.cleaned_data.get("remove_logo") and instance.logo: + 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 f6425f8..8c8e72c 100644 --- a/core/tests/test_custom_post_types.py +++ b/core/tests/test_custom_post_types.py @@ -175,20 +175,125 @@ 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 +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): + 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_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) + 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}), + { + "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") + post_type.refresh_from_db() + assert post_type.logo.name == original_logo_name + + +@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..40d7a8f 100644 --- a/core/views.py +++ b/core/views.py @@ -1544,6 +1544,65 @@ def post(self, request, *args, **kwargs): return self.render_to_response(context) +class ProjectCustomPostTypeEditView(LoginRequiredMixin, TemplateView): + template_name = "project/project_custom_post_type_edit.html" + + 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) + 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): + return self.render_to_response(self.get_context_data()) + + def post(self, request, *args, **kwargs): + form = ProjectCustomPostTypeForm( + request.POST, + request.FILES, + instance=self.get_custom_post_type(), + ) + if form.is_valid(): + try: + updated_custom_post_type = form.save() + except ValidationError as error: + for field, messages_list in error.message_dict.items(): + 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)) + + messages.success( + request, + f"Updated custom post type '{updated_custom_post_type.name}'.", + ) + 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)) + + class ProjectCustomPostTypeUpdateView(LoginRequiredMixin, View): def post(self, request, pk, post_type_pk): project = get_object_or_404(Project, pk=pk, profile=request.user.profile) 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 %}