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 @@ -55,6 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- custom post types per project with validated name + prompt guidance, CRUD management UI, and Posts navigation integration
- 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
- Emails
- Feedback email (for profiles with one product)
- create project reminder for signed up users without project
Expand Down
29 changes: 28 additions & 1 deletion core/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,9 +337,19 @@ def clean_body(self):


class ProjectCustomPostTypeForm(forms.ModelForm):
logo = forms.FileField(
required=False,
widget=forms.FileInput(
attrs={
"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",
"accept": "image/png,image/jpeg,image/webp,image/gif",
}
),
)

class Meta:
model = ProjectCustomPostType
fields = ["name", "prompt_guidance"]
fields = ["name", "prompt_guidance", "logo"]
Comment on lines +340 to +352
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd "models.py" | head -20

Repository: rasulkireev/TuxSEO

Length of output: 181


🏁 Script executed:

rg "class ProjectCustomPostType" -A 30 --type py

Repository: rasulkireev/TuxSEO

Length of output: 10648


🏁 Script executed:

rg -B 5 -A 15 "logo.*=.*ImageField\|logo.*=.*FileField" core/forms.py

Repository: rasulkireev/TuxSEO

Length of output: 44


🏁 Script executed:

# Get the full ProjectCustomPostTypeForm to understand the structure
sed -n '320,410p' core/forms.py

Repository: rasulkireev/TuxSEO

Length of output: 3023


🏁 Script executed:

# Check if Pillow is in dependencies and if ImageField validation actually happens
rg -i "pillow|image" pyproject.toml requirements.txt setup.py setup.cfg 2>/dev/null | head -20

Repository: rasulkireev/TuxSEO

Length of output: 181


🏁 Script executed:

# Verify the model field definition and understand the validators
rg -B 5 -A 5 "class ProjectCustomPostType" core/models.py

Repository: rasulkireev/TuxSEO

Length of output: 336


Don't downgrade the model ImageField to a plain FileField.

The explicit logo = forms.FileField override removes Django's built-in image validation. The form's clean_logo() method only checks the content_type header attribute (client-supplied, spoofable) and file size. A crafted request can submit arbitrary bytes with a PNG/JPEG extension and matching Content-Type header, bypassing validation and breaking the security guarantee provided by Pillow's ImageField validation.

🖼️ Proposed fix
 class ProjectCustomPostTypeForm(forms.ModelForm):
-    logo = forms.FileField(
-        required=False,
-        widget=forms.FileInput(
-            attrs={
-                "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",
-                "accept": "image/png,image/jpeg,image/webp,image/gif",
-            }
-        ),
-    )
     clear_logo = forms.BooleanField(required=False)
 
     class Meta:
         model = ProjectCustomPostType
         fields = ["name", "prompt_guidance", "logo"]
         widgets = {
+            "logo": forms.FileInput(
+                attrs={
+                    "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",
+                    "accept": "image/png,image/jpeg,image/webp,image/gif",
+                }
+            ),
             "name": forms.TextInput(
🧰 Tools
🪛 Ruff (0.15.6)

[warning] 353-353: Mutable default value for class attribute

(RUF012)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/forms.py` around lines 340 - 353, Replace the explicit logo =
forms.FileField override with Django's forms.ImageField so Pillow-based image
validation is preserved (keep the existing widget attrs and optional
required=False setting), and retain any size checks in clean_logo() but stop
relying solely on client-supplied content_type; ensure the form continues to use
the ProjectCustomPostType model and fields in Meta and leave clear_logo
untouched—update references to logo and clean_logo accordingly so
ImageField.clean handles actual image verification while you still enforce
max-size server-side.

widgets = {
"name": forms.TextInput(
attrs={
Expand All @@ -363,3 +373,20 @@ def clean_name(self):

def clean_prompt_guidance(self):
return (self.cleaned_data.get("prompt_guidance") or "").strip()

def clean_logo(self):
logo = self.cleaned_data.get("logo")
if not logo:
return logo

content_type = getattr(logo, "content_type", "")
if content_type not in ProjectCustomPostType.logo_allowed_content_types:
raise forms.ValidationError(
"Unsupported logo format. Use PNG, JPG, WEBP, or GIF."
)

if logo.size > ProjectCustomPostType.logo_max_file_size_bytes:
raise forms.ValidationError("Logo must be 2MB or smaller.")

return logo

18 changes: 18 additions & 0 deletions core/migrations/0065_projectcustomposttype_logo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2026-03-19 04:42

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0064_projectpageanalysisrun_and_more'),
]

operations = [
migrations.AddField(
model_name='projectcustomposttype',
name='logo',
field=models.ImageField(blank=True, upload_to='custom_post_type_logos/'),
),
]
17 changes: 16 additions & 1 deletion core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.validators import MaxLengthValidator
from django.core.validators import FileExtensionValidator, MaxLengthValidator
from django.db import models, transaction
from django.urls import reverse
from django.utils import timezone
Expand Down Expand Up @@ -769,6 +769,9 @@ class Meta:


class ProjectCustomPostType(BaseModel):
logo_max_file_size_bytes = 2 * 1024 * 1024
logo_allowed_content_types = {"image/png", "image/jpeg", "image/webp", "image/gif"}

project = models.ForeignKey(
Project,
on_delete=models.CASCADE,
Expand All @@ -777,6 +780,11 @@ class ProjectCustomPostType(BaseModel):
name = models.CharField(max_length=80)
normalized_name = models.CharField(max_length=80, editable=False)
prompt_guidance = models.TextField(validators=[MaxLengthValidator(1200)])
logo = models.ImageField(
upload_to="custom_post_type_logos/",
blank=True,
validators=[FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "webp", "gif"])],
)
Comment on lines +783 to +787
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 FileExtensionValidator is weaker than the form's content-type check and could be misleading

FileExtensionValidator only inspects the file's extension (e.g., ".png"), which an attacker can trivially spoof. The form's clean_logo already enforces a stricter check via the MIME content_type attribute. Having the extension validator on the model could give a false sense of security for code paths that bypass the form (e.g., tests or management commands that create records directly), while still allowing a file named evil.png with non-image content to pass through the model-level guard.

Consider replacing it with a custom validator that also checks the declared MIME type, or relying solely on the forms.ImageField PIL validation (which is already the strongest layer). If the intent is defence-in-depth at the model layer, the validator should at least mirror what the form checks.


class Meta:
ordering = ["name"]
Expand Down Expand Up @@ -818,6 +826,13 @@ def clean(self):

self.prompt_guidance = prompt_guidance

if self.logo:
logo_file_size = getattr(self.logo, "size", 0) or 0
if logo_file_size > self.logo_max_file_size_bytes:
raise ValidationError({
"logo": "Logo must be 2MB or smaller.",
})

def save(self, *args, **kwargs):
self.full_clean()
return super().save(*args, **kwargs)
Expand Down
91 changes: 91 additions & 0 deletions core/tests/test_custom_post_types.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import base64
from types import SimpleNamespace

import pytest
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from django.urls import reverse

from core.api.schemas import GenerateTitleSuggestionsIn
Expand All @@ -11,6 +14,11 @@
from core.models import BlogPostTitleSuggestion, Project, ProjectCustomPostType


TINY_PNG_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+WZ0AAAAASUVORK5CYII="
)


@pytest.mark.django_db
def test_custom_post_type_name_is_unique_per_project_case_insensitive():
user = User.objects.create_user("owner-custom-type", "owner-custom@example.com", "secret")
Expand Down Expand Up @@ -230,3 +238,86 @@ def test_build_content_generation_prompt_does_not_duplicate_custom_post_type_gui
generation_prompt = suggestion.build_content_generation_prompt()

assert post_type.prompt_guidance not in generation_prompt


@pytest.mark.django_db
@override_settings(MEDIA_ROOT="/tmp/tuxseo-test-media")
def test_create_custom_post_type_with_logo_upload(client):
user = User.objects.create_user("owner-logo", "owner-logo@example.com", "secret")
project = Project.objects.create(profile=user.profile, name="Site", url="https://site.test")

client.force_login(user)
response = client.post(
reverse("project_custom_post_types", kwargs={"pk": project.id}),
{
"name": "Case Study",
"prompt_guidance": "Use concrete outcomes and metrics.",
"logo": SimpleUploadedFile("logo.png", TINY_PNG_BYTES, content_type="image/png"),
},
)

assert response.status_code == 302
created_type = ProjectCustomPostType.objects.get(project=project, name="Case Study")
assert created_type.logo.name.startswith("custom_post_type_logos/")


@pytest.mark.django_db
@override_settings(MEDIA_ROOT="/tmp/tuxseo-test-media")
def test_create_custom_post_type_without_logo_works(client):
user = User.objects.create_user("owner-no-logo", "owner-no-logo@example.com", "secret")
project = Project.objects.create(profile=user.profile, name="Site", url="https://site.test")

client.force_login(user)
response = client.post(
reverse("project_custom_post_types", kwargs={"pk": project.id}),
{
"name": "Roundup",
"prompt_guidance": "Summarize notable weekly updates.",
},
)

assert response.status_code == 302
created_type = ProjectCustomPostType.objects.get(project=project, name="Roundup")
assert not created_type.logo


@pytest.mark.django_db
@override_settings(MEDIA_ROOT="/tmp/tuxseo-test-media")
def test_create_custom_post_type_rejects_unsupported_logo_type(client):
user = User.objects.create_user("owner-bad-logo", "owner-bad-logo@example.com", "secret")
project = Project.objects.create(profile=user.profile, name="Site", url="https://site.test")

client.force_login(user)
response = client.post(
reverse("project_custom_post_types", kwargs={"pk": project.id}),
{
"name": "Tutorial",
"prompt_guidance": "Instructional tone.",
"logo": SimpleUploadedFile("logo.txt", b"not-image", content_type="text/plain"),
},
)

assert response.status_code == 200
assert "Unsupported logo format" in response.content.decode("utf-8")


@pytest.mark.django_db
@override_settings(MEDIA_ROOT="/tmp/tuxseo-test-media")
def test_create_custom_post_type_rejects_oversized_logo(client):
user = User.objects.create_user("owner-big-logo", "owner-big-logo@example.com", "secret")
project = Project.objects.create(profile=user.profile, name="Site", url="https://site.test")

oversized = b"0" * (ProjectCustomPostType.logo_max_file_size_bytes + 1)

client.force_login(user)
response = client.post(
reverse("project_custom_post_types", kwargs={"pk": project.id}),
{
"name": "News",
"prompt_guidance": "Fast-paced updates.",
"logo": SimpleUploadedFile("logo.png", oversized, content_type="image/png"),
},
)

assert response.status_code == 200
assert "Logo must be 2MB or smaller" in response.content.decode("utf-8")
Comment on lines +310 to +323
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.

P1 Oversized logo test will fail — PIL rejects the payload before size check runs

The oversized data (b"0" * 2097153) is not a valid PNG file. Django's forms.ImageField (the form field type produced from a model ImageField) calls PIL's Image.open() + image.verify() in to_python() before the form's clean_logo method executes. PIL will reject the raw bytes with a "Upload a valid image…" error, so the "Logo must be 2MB or smaller" assertion will never be true and the test will fail.

To properly test the size path you need a structurally valid image whose byte-length exceeds 2 MB. One common approach is to embed the 1×1 PNG bytes inside a large-enough payload by stuffing a PNG comment chunk, or to mock logo.size at the form layer. For example:

# Build a >2 MB payload that PIL still accepts by wrapping TINY_PNG_BYTES in
# an in-memory file but overriding the reported size.
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile

oversized_file = SimpleUploadedFile("logo.png", TINY_PNG_BYTES, content_type="image/png")
oversized_file._size = ProjectCustomPostType.logo_max_file_size_bytes + 1

client.force_login(user)
response = client.post(
    reverse("project_custom_post_types", kwargs={"pk": project.id}),
    {
        "name": "News",
        "prompt_guidance": "Fast-paced updates.",
        "logo": oversized_file,
    },
)

SimpleUploadedFile exposes a writable _size attribute that the form reads via logo.size, letting you simulate an oversized file without actually allocating 2 MB or crafting a real large image.

11 changes: 9 additions & 2 deletions core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1520,7 +1520,7 @@ def get_context_data(self, **kwargs):

def post(self, request, *args, **kwargs):
self.object = self.get_object()
create_form = ProjectCustomPostTypeForm(request.POST)
create_form = ProjectCustomPostTypeForm(request.POST, request.FILES)

if create_form.is_valid():
custom_post_type = create_form.save(commit=False)
Expand Down Expand Up @@ -1549,7 +1549,7 @@ 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)

form = ProjectCustomPostTypeForm(request.POST, instance=custom_post_type)
form = ProjectCustomPostTypeForm(request.POST, request.FILES, instance=custom_post_type)
if form.is_valid():
try:
form.save()
Expand All @@ -1565,6 +1565,13 @@ def post(self, request, pk, post_type_pk):
)
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)

Expand Down
10 changes: 7 additions & 3 deletions frontend/templates/components/project_navigation.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,13 @@ <h2 class="px-3 mb-2 text-xs font-semibold tracking-wider text-gray-500 uppercas
href="{% url 'project_custom_post_type_posts' project.id post_type.id %}"
class="flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors {% if request.resolver_match.url_name == 'project_custom_post_type_posts' and request.resolver_match.kwargs.post_type_pk == post_type.id %}bg-gray-100 text-gray-900{% else %}text-gray-700 hover:bg-gray-50 hover:text-gray-900{% endif %}"
>
<svg class="mr-2 w-4 h-4 {% if request.resolver_match.url_name == 'project_custom_post_type_posts' and request.resolver_match.kwargs.post_type_pk == post_type.id %}text-gray-900{% else %}text-gray-400{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v12m6-6H6" />
</svg>
{% if post_type.logo %}
<img src="{{ post_type.logo.url }}" alt="{{ post_type.name }} logo" class="object-cover mr-2 w-4 h-4 rounded" />
{% else %}
<svg class="mr-2 w-4 h-4 {% if request.resolver_match.url_name == 'project_custom_post_type_posts' and request.resolver_match.kwargs.post_type_pk == post_type.id %}text-gray-900{% else %}text-gray-400{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v12m6-6H6" />
</svg>
{% endif %}
{{ post_type.name }}
</a>
</li>
Expand Down
13 changes: 12 additions & 1 deletion frontend/templates/project/project_custom_post_type_posts.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,18 @@
data-content-idea-post-type-id-value="{{ custom_post_type.id }}">

<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-900">{{ custom_post_type.name }} Posts</h2>
<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 %}
<h2 class="text-2xl font-bold text-gray-900">{{ custom_post_type.name }} Posts</h2>
</div>
<p class="mt-2 text-gray-600">{{ custom_post_type.prompt_guidance }}</p>
</div>

Expand Down
27 changes: 25 additions & 2 deletions frontend/templates/project/project_custom_post_types.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<h2 class="text-xl font-semibold text-gray-900">Create custom post type</h2>
<p class="mt-2 text-sm text-gray-600">Define a reusable post style with prompt guidance used during idea generation.</p>

<form method="post" class="mt-6 space-y-4">
<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>
Expand All @@ -23,6 +23,12 @@ <h2 class="text-xl font-semibold text-gray-900">Create custom post type</h2>
{{ create_form.prompt_guidance }}
{% for error in create_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>
{{ create_form.logo }}
<p class="mt-1 text-xs text-gray-500">Accepted: PNG, JPG, WEBP, GIF · max 2MB.</p>
{% for error in create_form.logo.errors %}<p class="mt-1 text-sm text-red-600">{{ error }}</p>{% endfor %}
</div>
{% for error in create_form.non_field_errors %}<p class="mt-1 text-sm text-red-600">{{ error }}</p>{% endfor %}
<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">Create post type</button>
</form>
Expand All @@ -34,8 +40,20 @@ <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" action="{% url 'project_custom_post_type_update' project.id post_type.id %}" class="space-y-3">
<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" />
Expand All @@ -44,6 +62,11 @@ <h3 class="text-lg font-semibold text-gray-900">Existing custom post types</h3>
<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>
Comment on lines +65 to +68
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.

⚠️ Potential issue | 🟡 Minor

Bind the update-form label to its file input.

The new logo label is not associated with the file input, so screen readers will not announce the control name correctly and clicking the label will not focus the picker.

♿ Proposed fix
-                <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" />
+                <label class="block mb-1 text-sm font-medium text-gray-700" for="id_logo_{{ post_type.id }}">Logo (optional)</label>
+                <input id="id_logo_{{ post_type.id }}" 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" />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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>
<label class="block mb-1 text-sm font-medium text-gray-700" for="id_logo_{{ post_type.id }}">Logo (optional)</label>
<input id="id_logo_{{ post_type.id }}" 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>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/templates/project/project_custom_post_types.html` around lines 65 -
68, The label for the logo file input is not bound to the input, so add an id to
the input (e.g., id="logo") and set the label's for attribute to that id to
associate them (look for the <label> and <input name="logo"> elements in
project_custom_post_types.html); optionally ensure the helper paragraph is
referenced via aria-describedby on the input if you want screen readers to read
the "Accepted: ..." text (use the helper's id and set input aria-describedby to
that id).

</div>
Comment on lines +65 to +69
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.

P1 No way to remove an existing logo once set

The edit form only renders a blank <input type="file">. When the form is submitted without selecting a new file, Django's FileField preserves the existing value from the instance — which is correct — but it also means there is no mechanism for a user to clear a logo they previously uploaded. They can replace it but never remove it.

A common Django pattern is to add a ClearableFileInput widget or a companion boolean checkbox clear_logo that, when checked, sets logo = None in the view before form.save(). Even a simple "Remove logo" button that POSTs to a dedicated endpoint would unblock users.

Additionally, the edit form renders the file input as raw HTML rather than through the form widget ({{ edit_form.logo }}), which means any error messages from clean_logo (wrong format, oversized) are silently dropped when the update view redirects — the user only sees the generic "Could not update custom post type. Please check the form." flash message with no indication of which field failed or why.

<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>
Expand Down
Loading