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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions core/tests/test_custom_post_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
16 changes: 16 additions & 0 deletions core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Only active ideas are checked; archived ideas with the post type may be silently nulled

The guard only prevents deletion while archived=False ideas reference the type. If a post type has only archived ideas, deletion proceeds and those archived ideas will have their custom_post_type FK silently set to NULL (via on_delete=models.SET_NULL). This is likely the intended behaviour given the PR description, but the user-facing modal copy says "Deletion is blocked if this type is still used by active ideas" — which matches. Just worth confirming this silent nullification of archived idea references is intentional and expected downstream (e.g., in analytics or listing views that may previously filter by post type).

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}'.")
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
45 changes: 40 additions & 5 deletions frontend/templates/project/project_custom_post_types.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ <h2 class="text-xl font-semibold text-gray-900">Create custom post type</h2>
</form>
</section>

<section class="p-6 bg-white rounded-lg border border-gray-200 shadow-sm">
<section class="p-6 bg-white rounded-lg border border-gray-200 shadow-sm" data-controller="custom-post-type-delete-modal">
<h3 class="text-lg font-semibold text-gray-900">Existing custom post types</h3>
{% if custom_post_types %}
<div class="mt-4 space-y-6">
Expand All @@ -59,10 +59,45 @@ <h3 class="text-lg font-semibold text-gray-900">Existing custom post types</h3>
<a href="{% url 'project_custom_post_type_edit' project.id post_type.id %}" class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white rounded-md border border-gray-300 hover:bg-gray-50">Edit</a>
<a href="{% url 'project_custom_post_type_posts' project.id post_type.id %}" class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white rounded-md border border-gray-300 hover:bg-gray-50">Open</a>
</div>
<form method="post" action="{% url 'project_custom_post_type_delete' project.id post_type.id %}" class="mt-3" onsubmit="return confirm('Delete this custom post type? Existing generated ideas stay but type will be removed.');">
{% csrf_token %}
<button type="submit" class="text-sm font-medium text-red-600 hover:text-red-700">Delete</button>
</form>
<div class="mt-3">
<button
type="button"
class="text-sm font-medium text-red-600 hover:text-red-700"
data-action="click->custom-post-type-delete-modal#open"
data-delete-modal-id="delete-post-type-modal-{{ post_type.id }}"
>
Delete
</button>
</div>

<dialog id="delete-post-type-modal-{{ post_type.id }}" class="p-0 w-full max-w-md rounded-lg border border-gray-200 shadow-xl backdrop:bg-black/50" data-action="click->custom-post-type-delete-modal#closeOnBackdrop">
<div class="p-6 space-y-4">
<div>
<h4 class="text-lg font-semibold text-gray-900">Delete "{{ post_type.name }}"?</h4>
<p class="mt-2 text-sm text-gray-600">
This action cannot be undone. Deletion is blocked if this type is still used by active ideas.
</p>
</div>

<form method="post" action="{% url 'project_custom_post_type_delete' project.id post_type.id %}" class="space-y-4">
{% csrf_token %}
<input type="hidden" name="confirm_delete" value="yes" />
<label class="flex gap-2 items-start text-sm text-gray-700">
<input type="checkbox" required class="mt-0.5 rounded border-gray-300" />
<span>I understand this will permanently delete this custom post type if it is safe to do so.</span>
</label>

<div class="flex gap-3 justify-end items-center">
<button type="button" class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white rounded-md border border-gray-300 hover:bg-gray-50" data-action="click->custom-post-type-delete-modal#close">
Cancel
</button>
<button type="submit" class="inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-red-600 rounded-md border border-red-600 hover:bg-red-700">
Delete post type
</button>
</div>
</form>
</div>
</dialog>
</article>
{% endfor %}
</div>
Expand Down
Loading