diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a578b..67fc218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/core/forms.py b/core/forms.py index 135a910..77c0db3 100644 --- a/core/forms.py +++ b/core/forms.py @@ -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"] widgets = { "name": forms.TextInput( attrs={ @@ -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 + diff --git a/core/migrations/0065_projectcustomposttype_logo.py b/core/migrations/0065_projectcustomposttype_logo.py new file mode 100644 index 0000000..f802a11 --- /dev/null +++ b/core/migrations/0065_projectcustomposttype_logo.py @@ -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/'), + ), + ] diff --git a/core/models.py b/core/models.py index e4bb5d8..2a8231b 100644 --- a/core/models.py +++ b/core/models.py @@ -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"])], + ) 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) diff --git a/core/tests/test_custom_post_types.py b/core/tests/test_custom_post_types.py index 3d530b0..f6425f8 100644 --- a/core/tests/test_custom_post_types.py +++ b/core/tests/test_custom_post_types.py @@ -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") diff --git a/core/views.py b/core/views.py index e81dd89..d22f6cd 100644 --- a/core/views.py +++ b/core/views.py @@ -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) @@ -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() @@ -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) diff --git a/frontend/templates/components/project_navigation.html b/frontend/templates/components/project_navigation.html index 352d8ad..b8c83ed 100644 --- a/frontend/templates/components/project_navigation.html +++ b/frontend/templates/components/project_navigation.html @@ -69,9 +69,13 @@

- - - + {% if post_type.logo %} + {{ post_type.name }} logo + {% else %} + + + + {% endif %} {{ post_type.name }} diff --git a/frontend/templates/project/project_custom_post_type_posts.html b/frontend/templates/project/project_custom_post_type_posts.html index 600dd96..18af08c 100644 --- a/frontend/templates/project/project_custom_post_type_posts.html +++ b/frontend/templates/project/project_custom_post_type_posts.html @@ -15,7 +15,18 @@ data-content-idea-post-type-id-value="{{ custom_post_type.id }}">
-

{{ custom_post_type.name }} Posts

+
+ {% if custom_post_type.logo %} + {{ custom_post_type.name }} logo + {% else %} +
+ + + +
+ {% endif %} +

{{ custom_post_type.name }} Posts

+

{{ custom_post_type.prompt_guidance }}

diff --git a/frontend/templates/project/project_custom_post_types.html b/frontend/templates/project/project_custom_post_types.html index 9a9e7fa..2608a68 100644 --- a/frontend/templates/project/project_custom_post_types.html +++ b/frontend/templates/project/project_custom_post_types.html @@ -11,7 +11,7 @@

Create custom post type

Define a reusable post style with prompt guidance used during idea generation.

-
+ {% csrf_token %}
@@ -23,6 +23,12 @@

Create custom post type

{{ create_form.prompt_guidance }} {% for error in create_form.prompt_guidance.errors %}

{{ error }}

{% endfor %}
+
+ + {{ create_form.logo }} +

Accepted: PNG, JPG, WEBP, GIF · max 2MB.

+ {% for error in create_form.logo.errors %}

{{ error }}

{% endfor %} +
{% for error in create_form.non_field_errors %}

{{ error }}

{% endfor %}
@@ -34,8 +40,20 @@

Existing custom post types

{% for post_type in custom_post_types %}
-
+ {% csrf_token %} +
+ {% if post_type.logo %} + {{ post_type.name }} logo + {% else %} +
+ + + +
+ {% endif %} +

{{ post_type.name }}

+
@@ -44,6 +62,11 @@

Existing custom post types

+
+ + +

Accepted: PNG, JPG, WEBP, GIF · max 2MB.

+
Open