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 @@ -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
Expand Down
28 changes: 28 additions & 0 deletions core/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -346,6 +347,7 @@ class ProjectCustomPostTypeForm(forms.ModelForm):
}
),
)
remove_logo = forms.BooleanField(required=False)

class Meta:
model = ProjectCustomPostType
Expand Down Expand Up @@ -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(
Expand All @@ -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

109 changes: 107 additions & 2 deletions core/tests/test_custom_post_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@
views.ProjectCustomPostTypePostsView.as_view(),
name="project_custom_post_type_posts",
),
path(
"project/<int:pk>/posts/custom/<int:post_type_pk>/edit/",
views.ProjectCustomPostTypeEditView.as_view(),
name="project_custom_post_type_edit",
),
path(
"project/<int:pk>/posts/custom/<int:post_type_pk>/update/",
views.ProjectCustomPostTypeUpdateView.as_view(),
Expand Down
59 changes: 59 additions & 0 deletions core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
64 changes: 64 additions & 0 deletions frontend/templates/project/project_custom_post_type_edit.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{% extends "base_project.html" %}

{% block meta %}
<title>TuxSEO - {{ project.name }} - Edit {{ custom_post_type.name }}</title>
<meta name="description" content="{{ project.summary }}" />
{% endblock meta %}

{% block project_content %}
<section class="p-6 bg-white rounded-lg border border-gray-200 shadow-sm">
<div class="flex gap-3 items-center">
{% if custom_post_type.logo %}
<img src="{{ custom_post_type.logo.url }}" alt="{{ custom_post_type.name }} logo" class="object-cover w-10 h-10 rounded" />
{% else %}
<div class="flex justify-center items-center w-10 h-10 text-gray-500 bg-gray-100 rounded">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v12m6-6H6" />
</svg>
</div>
{% endif %}
<div>
<h2 class="text-xl font-semibold text-gray-900">Edit custom post type</h2>
<p class="text-sm text-gray-600">Changes apply immediately to new title/content generation for this type.</p>
</div>
</div>

<form method="post" enctype="multipart/form-data" class="mt-6 space-y-4">
{% csrf_token %}
<div>
<label class="block mb-1 text-sm font-medium text-gray-700" for="id_name">Name</label>
{{ form.name }}
{% for error in form.name.errors %}<p class="mt-1 text-sm text-red-600">{{ error }}</p>{% endfor %}
</div>

<div>
<label class="block mb-1 text-sm font-medium text-gray-700" for="id_prompt_guidance">Prompt guidance</label>
{{ form.prompt_guidance }}
{% for error in form.prompt_guidance.errors %}<p class="mt-1 text-sm text-red-600">{{ error }}</p>{% endfor %}
</div>

<div>
<label class="block mb-1 text-sm font-medium text-gray-700" for="id_logo">Logo (optional)</label>
{{ form.logo }}
<p class="mt-1 text-xs text-gray-500">Accepted: PNG, JPG, WEBP, GIF · max 2MB.</p>
{% for error in form.logo.errors %}<p class="mt-1 text-sm text-red-600">{{ error }}</p>{% endfor %}
</div>

{% if custom_post_type.logo %}
<div class="flex gap-2 items-center">
<input id="id_remove_logo" name="remove_logo" type="checkbox" value="true" {% if form.remove_logo.value %}checked{% endif %} class="w-4 h-4 text-gray-900 rounded border-gray-300 focus:ring-gray-500" />
<label for="id_remove_logo" class="text-sm text-gray-700">Remove current logo</label>
</div>
{% for error in form.remove_logo.errors %}<p class="mt-1 text-sm text-red-600">{{ error }}</p>{% endfor %}
{% endif %}
Comment on lines +47 to +53
Copy link
Contributor

Choose a reason for hiding this comment

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

P2 Stale header logo when form re-renders after a remove_logo + error

Both the header preview image (line 12) and the "Remove current logo" checkbox visibility (line 47) are driven by custom_post_type.logo, which is the database state — not the form submission state. If a user checks "Remove current logo", also triggers another validation error (unlikely but possible), and the form re-renders, they will see the old logo in the header and the checkbox still available, even though their intent was to remove it. The checkbox will be pre-checked (because form.remove_logo.value is True) so re-submitting works correctly — but the stale logo preview can look confusing.

This is a minor UX polish item: consider updating the header preview to use the form's current state rather than the DB instance logo, e.g. hiding it when form.remove_logo.value is truthy.


{% for error in form.non_field_errors %}<p class="mt-1 text-sm text-red-600">{{ error }}</p>{% endfor %}

<div class="flex gap-3 items-center">
<button type="submit" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-gray-900 rounded-md border border-gray-900 hover:bg-gray-800">Save changes</button>
<a href="{% url 'project_custom_post_types' project.id %}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white rounded-md border border-gray-300 hover:bg-gray-50">Cancel</a>
<a href="{% url 'project_custom_post_type_posts' project.id custom_post_type.id %}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white rounded-md border border-gray-300 hover:bg-gray-50">Open posts</a>
</div>
</form>
</section>
{% endblock project_content %}
47 changes: 17 additions & 30 deletions frontend/templates/project/project_custom_post_types.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,38 +40,25 @@ <h3 class="text-lg font-semibold text-gray-900">Existing custom post types</h3>
<div class="mt-4 space-y-6">
{% for post_type in custom_post_types %}
<article class="p-4 rounded-md border border-gray-200">
<form method="post" enctype="multipart/form-data" action="{% url 'project_custom_post_type_update' project.id post_type.id %}" class="space-y-3">
{% csrf_token %}
<div class="flex gap-3 items-center">
{% if post_type.logo %}
<img src="{{ post_type.logo.url }}" alt="{{ post_type.name }} logo" class="object-cover w-8 h-8 rounded" />
{% else %}
<div class="flex justify-center items-center w-8 h-8 text-gray-500 bg-gray-100 rounded">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v12m6-6H6" />
</svg>
</div>
{% endif %}
<p class="text-sm font-medium text-gray-700">{{ post_type.name }}</p>
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700">Name</label>
<input type="text" name="name" value="{{ post_type.name }}" maxlength="80" required class="block px-3 py-2 w-full text-sm text-gray-900 bg-white rounded-md border border-gray-300 focus:ring-gray-500 focus:border-gray-500" />
</div>
<div class="flex gap-3 items-center">
{% if post_type.logo %}
<img src="{{ post_type.logo.url }}" alt="{{ post_type.name }} logo" class="object-cover w-8 h-8 rounded" />
{% else %}
<div class="flex justify-center items-center w-8 h-8 text-gray-500 bg-gray-100 rounded">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v12m6-6H6" />
</svg>
</div>
{% endif %}
<div>
<label class="block mb-1 text-sm font-medium text-gray-700">Prompt guidance</label>
<textarea name="prompt_guidance" rows="4" maxlength="1200" required class="block px-3 py-2 w-full text-sm text-gray-900 bg-white rounded-md border border-gray-300 focus:ring-gray-500 focus:border-gray-500">{{ post_type.prompt_guidance }}</textarea>
</div>
<div>
<label class="block mb-1 text-sm font-medium text-gray-700">Logo (optional)</label>
<input type="file" name="logo" accept="image/png,image/jpeg,image/webp,image/gif" class="block w-full text-sm text-gray-900 rounded-md border border-gray-300 cursor-pointer bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:border-gray-500" />
<p class="mt-1 text-xs text-gray-500">Accepted: PNG, JPG, WEBP, GIF · max 2MB.</p>
</div>
<div class="flex gap-3 items-center">
<button type="submit" 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">Save</button>
<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>
<p class="text-sm font-medium text-gray-700">{{ post_type.name }}</p>
<p class="text-xs text-gray-500">{{ post_type.prompt_guidance|truncatechars:140 }}</p>
</div>
</form>
</div>
<div class="flex gap-3 items-center mt-4">
<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>
Expand Down
Loading