diff --git a/CHANGELOG.md b/CHANGELOG.md index a66cab5..adfe7c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 + - safe custom post type deletion flow with explicit confirmation modal, backend dependency guardrails that block deletion while active ideas still reference the type, and regression tests for confirmed-delete + blocked-delete paths - Emails - Feedback email (for profiles with one product) - create project reminder for signed up users without project diff --git a/core/tests/test_custom_post_types.py b/core/tests/test_custom_post_types.py index 8c8e72c..6de45d1 100644 --- a/core/tests/test_custom_post_types.py +++ b/core/tests/test_custom_post_types.py @@ -426,3 +426,83 @@ def test_create_custom_post_type_rejects_oversized_logo(client): assert response.status_code == 200 assert "Logo must be 2MB or smaller" in response.content.decode("utf-8") + + +@pytest.mark.django_db +def test_delete_custom_post_type_requires_confirmation(client): + user = User.objects.create_user("owner-delete-confirm", "owner-delete-confirm@example.com", "secret") + project = Project.objects.create(profile=user.profile, name="Site", url="https://site.test") + post_type = ProjectCustomPostType.objects.create( + project=project, + name="Roundup", + prompt_guidance="Summarize weekly updates.", + ) + + client.force_login(user) + response = client.post( + reverse("project_custom_post_type_delete", kwargs={"pk": project.id, "post_type_pk": post_type.id}), + {}, + follow=True, + ) + + assert response.status_code == 200 + assert ProjectCustomPostType.objects.filter(id=post_type.id).exists() + assert "Delete not confirmed" in response.content.decode("utf-8") + + +@pytest.mark.django_db +def test_delete_custom_post_type_blocks_when_in_active_use(client): + user = User.objects.create_user("owner-delete-block", "owner-delete-block@example.com", "secret") + project = Project.objects.create(profile=user.profile, name="Site", url="https://site.test") + post_type = ProjectCustomPostType.objects.create( + project=project, + name="Case Study", + prompt_guidance="Lead with outcomes.", + ) + BlogPostTitleSuggestion.objects.create( + project=project, + custom_post_type=post_type, + title="How we improved conversions", + description="desc", + category="General Audience", + content_type=ContentType.SHARING, + archived=False, + ) + + client.force_login(user) + response = client.post( + reverse("project_custom_post_type_delete", kwargs={"pk": project.id, "post_type_pk": post_type.id}), + {"confirm_delete": "yes"}, + follow=True, + ) + + assert response.status_code == 200 + assert ProjectCustomPostType.objects.filter(id=post_type.id).exists() + page = response.content.decode("utf-8") + assert "Cannot delete" in page + assert "active idea(s)" in page + + +@pytest.mark.django_db +def test_delete_custom_post_type_removes_it_from_custom_post_type_lists(client): + user = User.objects.create_user("owner-delete-success", "owner-delete-success@example.com", "secret") + project = Project.objects.create(profile=user.profile, name="Site", url="https://site.test") + post_type = ProjectCustomPostType.objects.create( + project=project, + name="Checklist", + prompt_guidance="Use concise checklists.", + ) + + client.force_login(user) + response = client.post( + reverse("project_custom_post_type_delete", kwargs={"pk": project.id, "post_type_pk": post_type.id}), + {"confirm_delete": "yes"}, + follow=True, + ) + + assert response.status_code == 200 + assert not ProjectCustomPostType.objects.filter(id=post_type.id).exists() + assert ( + reverse("project_custom_post_type_posts", kwargs={"pk": project.id, "post_type_pk": post_type.id}) + not in response.content.decode("utf-8") + ) diff --git a/core/views.py b/core/views.py index 40d7a8f..6713590 100644 --- a/core/views.py +++ b/core/views.py @@ -1639,6 +1639,22 @@ class ProjectCustomPostTypeDeleteView(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) + + if request.POST.get("confirm_delete") != "yes": + messages.error(request, "Delete not confirmed. Custom post type was not deleted.") + return redirect("project_custom_post_types", pk=project.pk) + + active_dependency_count = custom_post_type.title_suggestions.filter(archived=False).count() + if active_dependency_count > 0: + messages.error( + request, + ( + f"Cannot delete '{custom_post_type.name}' because it is used by " + f"{active_dependency_count} active idea(s). Archive those ideas first, then retry." + ), + ) + return redirect("project_custom_post_types", pk=project.pk) + deleted_name = custom_post_type.name custom_post_type.delete() messages.success(request, f"Deleted custom post type '{deleted_name}'.") diff --git a/frontend/src/controllers/custom_post_type_delete_modal_controller.js b/frontend/src/controllers/custom_post_type_delete_modal_controller.js new file mode 100644 index 0000000..fa623d7 --- /dev/null +++ b/frontend/src/controllers/custom_post_type_delete_modal_controller.js @@ -0,0 +1,27 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + open(event) { + const modalId = event.currentTarget.dataset.deleteModalId; + if (!modalId) { + return; + } + + const modal = this.element.querySelector(`#${CSS.escape(modalId)}`); + if (modal?.showModal) { + modal.showModal(); + } + } + + close(event) { + const modal = event.currentTarget.closest("dialog"); + modal?.close(); + } + + closeOnBackdrop(event) { + const modal = event.currentTarget; + if (event.target === modal) { + modal.close(); + } + } +} diff --git a/frontend/templates/project/project_custom_post_types.html b/frontend/templates/project/project_custom_post_types.html index 31acbc9..7572e9a 100644 --- a/frontend/templates/project/project_custom_post_types.html +++ b/frontend/templates/project/project_custom_post_types.html @@ -34,7 +34,7 @@