-
Notifications
You must be signed in to change notification settings - Fork 9
[Forge][P1] Add logo selection to custom Post Type creation flow #245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1a8928c
596f386
70de995
88ebecf
b2f33d1
71f712a
d785d98
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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/'), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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, | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Consider replacing it with a custom validator that also checks the declared MIME type, or relying solely on the |
||
|
|
||
| class Meta: | ||
| ordering = ["name"] | ||
|
|
@@ -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) | ||
|
|
||
| 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 | ||
|
|
@@ -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") | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The 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 # 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,
},
)
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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> | ||||||||||||||||||
|
|
@@ -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> | ||||||||||||||||||
|
|
@@ -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" /> | ||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| </div> | ||||||||||||||||||
|
Comment on lines
+65
to
+69
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The edit form only renders a blank A common Django pattern is to add a Additionally, the edit form renders the file input as raw HTML rather than through the form widget ( |
||||||||||||||||||
| <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> | ||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: rasulkireev/TuxSEO
Length of output: 181
🏁 Script executed:
rg "class ProjectCustomPostType" -A 30 --type pyRepository: rasulkireev/TuxSEO
Length of output: 10648
🏁 Script executed:
rg -B 5 -A 15 "logo.*=.*ImageField\|logo.*=.*FileField" core/forms.pyRepository: rasulkireev/TuxSEO
Length of output: 44
🏁 Script executed:
Repository: rasulkireev/TuxSEO
Length of output: 3023
🏁 Script executed:
Repository: rasulkireev/TuxSEO
Length of output: 181
🏁 Script executed:
Repository: rasulkireev/TuxSEO
Length of output: 336
Don't downgrade the model
ImageFieldto a plainFileField.The explicit
logo = forms.FileFieldoverride removes Django's built-in image validation. The form'sclean_logo()method only checks thecontent_typeheader 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'sImageFieldvalidation.🖼️ Proposed fix
🧰 Tools
🪛 Ruff (0.15.6)
[warning] 353-353: Mutable default value for class attribute
(RUF012)
🤖 Prompt for AI Agents