initial commit
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
from django.contrib import admin
|
||||
from django.http import FileResponse, Http404, HttpResponseRedirect
|
||||
from django.urls import path, reverse
|
||||
from django.utils.html import format_html
|
||||
|
||||
from apps.core.admin_video import video_admin_preview
|
||||
from apps.core.video import VIDEO_ADMIN_FIELDSET
|
||||
|
||||
from .models import (
|
||||
AboutSection,
|
||||
AboutSectionItem,
|
||||
ContactSubmission,
|
||||
ContactSubmissionAttachment,
|
||||
CustomPage,
|
||||
CustomPageSection,
|
||||
CustomPageSectionItem,
|
||||
DownloadItem,
|
||||
FAQEntry,
|
||||
HeroSection,
|
||||
HomepageSection,
|
||||
HomepageSectionItem,
|
||||
PageVideo,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(HeroSection)
|
||||
class HeroSectionAdmin(admin.ModelAdmin):
|
||||
fieldsets = (
|
||||
("Badge", {"fields": ("badge",)}),
|
||||
("Title", {"fields": ("title", "title_highlight")}),
|
||||
("Text", {"fields": ("subtitle", "description")}),
|
||||
("Primary Button", {"fields": ("primary_cta_text", "primary_cta_url")}),
|
||||
("Secondary Button", {"fields": ("secondary_cta_text", "secondary_cta_url")}),
|
||||
("Image", {"fields": ("image", "image_alt")}),
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return not HeroSection.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
hero = HeroSection.objects.first()
|
||||
if hero:
|
||||
return HttpResponseRedirect(
|
||||
reverse("admin:pages_herosection_change", args=[hero.pk])
|
||||
)
|
||||
return super().changelist_view(request, extra_context)
|
||||
|
||||
|
||||
class HomepageSectionItemInline(admin.TabularInline):
|
||||
model = HomepageSectionItem
|
||||
extra = 1
|
||||
fields = (
|
||||
"icon",
|
||||
"title",
|
||||
"content",
|
||||
"url",
|
||||
"image",
|
||||
"image_alt",
|
||||
"tags",
|
||||
"project_status",
|
||||
"order",
|
||||
)
|
||||
ordering = ("order",)
|
||||
|
||||
|
||||
@admin.register(HomepageSection)
|
||||
class HomepageSectionAdmin(admin.ModelAdmin):
|
||||
list_display = ("section_type", "title", "badge", "order", "is_active")
|
||||
list_filter = ("section_type", "is_active")
|
||||
list_editable = ("order", "is_active")
|
||||
inlines = [HomepageSectionItemInline]
|
||||
readonly_fields = ("video_preview",)
|
||||
fieldsets = (
|
||||
(None, {"fields": ("section_type", "badge", "title", "title_highlight", "description_format", "description")}),
|
||||
("CTA Link (About Strip)", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}),
|
||||
VIDEO_ADMIN_FIELDSET,
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Preview")
|
||||
def video_preview(self, obj):
|
||||
return video_admin_preview(obj)
|
||||
|
||||
|
||||
class AboutSectionItemInline(admin.TabularInline):
|
||||
model = AboutSectionItem
|
||||
extra = 1
|
||||
fields = ("icon", "title", "content", "url", "image", "image_alt", "badge", "is_featured", "order")
|
||||
ordering = ("order",)
|
||||
|
||||
|
||||
@admin.register(AboutSection)
|
||||
class AboutSectionAdmin(admin.ModelAdmin):
|
||||
list_display = ("section_type", "badge", "title", "order", "is_active")
|
||||
list_filter = ("section_type", "is_active")
|
||||
list_editable = ("order", "is_active")
|
||||
inlines = [AboutSectionItemInline]
|
||||
readonly_fields = ("video_preview",)
|
||||
fieldsets = (
|
||||
(None, {"fields": ("section_type", "badge", "title", "subtitle")}),
|
||||
("Content", {"fields": ("content_format", "content")}),
|
||||
VIDEO_ADMIN_FIELDSET,
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Preview")
|
||||
def video_preview(self, obj):
|
||||
return video_admin_preview(obj)
|
||||
|
||||
|
||||
class ContactSubmissionAttachmentInline(admin.TabularInline):
|
||||
model = ContactSubmissionAttachment
|
||||
extra = 0
|
||||
can_delete = False
|
||||
fields = ("original_filename", "attachment_link", "uploaded_at")
|
||||
readonly_fields = ("original_filename", "attachment_link", "uploaded_at")
|
||||
|
||||
@admin.display(description="File")
|
||||
def attachment_link(self, obj):
|
||||
if not obj.file:
|
||||
return "—"
|
||||
url = reverse("admin:pages_contactattachment_download", args=[obj.pk])
|
||||
return format_html('<a href="{}">Download</a>', url)
|
||||
|
||||
|
||||
@admin.register(ContactSubmission)
|
||||
class ContactSubmissionAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "title", "email", "submitted_at", "is_read")
|
||||
list_filter = ("is_read", "submitted_at")
|
||||
search_fields = ("name", "title", "description", "email")
|
||||
list_editable = ("is_read",)
|
||||
readonly_fields = ("name", "title", "description", "email", "submitted_at")
|
||||
inlines = [ContactSubmissionAttachmentInline]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "title", "email", "description")}),
|
||||
("Meta", {"fields": ("submitted_at", "is_read")}),
|
||||
)
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path(
|
||||
"attachment/<int:attachment_id>/download/",
|
||||
self.admin_site.admin_view(self.download_attachment),
|
||||
name="pages_contactattachment_download",
|
||||
),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def download_attachment(self, request, attachment_id):
|
||||
try:
|
||||
attachment = ContactSubmissionAttachment.objects.get(pk=attachment_id)
|
||||
except ContactSubmissionAttachment.DoesNotExist as exc:
|
||||
raise Http404 from exc
|
||||
if not attachment.file:
|
||||
raise Http404
|
||||
return FileResponse(
|
||||
attachment.file.open("rb"),
|
||||
as_attachment=True,
|
||||
filename=attachment.original_filename,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(FAQEntry)
|
||||
class FAQEntryAdmin(admin.ModelAdmin):
|
||||
list_display = ("question", "order", "is_active", "created_at")
|
||||
list_filter = ("is_active",)
|
||||
search_fields = ("question", "answer")
|
||||
list_editable = ("order", "is_active")
|
||||
fieldsets = (
|
||||
(None, {"fields": ("question",)}),
|
||||
("Answer", {"fields": ("answer_format", "answer")}),
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
|
||||
class CustomPageSectionItemInline(admin.TabularInline):
|
||||
model = CustomPageSectionItem
|
||||
extra = 1
|
||||
fields = (
|
||||
"icon",
|
||||
"badge",
|
||||
"title",
|
||||
"content_format",
|
||||
"content",
|
||||
"url",
|
||||
"image",
|
||||
"image_alt",
|
||||
"is_featured",
|
||||
"order",
|
||||
)
|
||||
ordering = ("order",)
|
||||
|
||||
|
||||
class CustomPageSectionInline(admin.StackedInline):
|
||||
model = CustomPageSection
|
||||
extra = 1
|
||||
fields = (
|
||||
"section_type",
|
||||
"badge",
|
||||
"title",
|
||||
"subtitle",
|
||||
"description_format",
|
||||
"description",
|
||||
"content_format",
|
||||
"content",
|
||||
"link_text",
|
||||
"link_url",
|
||||
"order",
|
||||
"is_active",
|
||||
)
|
||||
ordering = ("order",)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class CustomPageSectionItemOnPageInline(admin.TabularInline):
|
||||
model = CustomPageSectionItem
|
||||
fk_name = "page"
|
||||
extra = 1
|
||||
verbose_name = "Section item"
|
||||
verbose_name_plural = "Section items (subsections)"
|
||||
fields = (
|
||||
"section",
|
||||
"icon",
|
||||
"badge",
|
||||
"title",
|
||||
"content_format",
|
||||
"content",
|
||||
"url",
|
||||
"image",
|
||||
"order",
|
||||
)
|
||||
ordering = ("section", "order")
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
if db_field.name == "section":
|
||||
page_id = request.resolver_match.kwargs.get("object_id") if request.resolver_match else None
|
||||
if page_id:
|
||||
kwargs["queryset"] = CustomPageSection.objects.filter(page_id=page_id).order_by("order")
|
||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||
|
||||
|
||||
class CustomPageSectionAdmin(admin.ModelAdmin):
|
||||
list_display = ("page", "section_type", "title", "order", "is_active")
|
||||
list_filter = ("section_type", "is_active", "page")
|
||||
list_editable = ("order", "is_active")
|
||||
inlines = [CustomPageSectionItemInline]
|
||||
readonly_fields = ("video_preview",)
|
||||
fieldsets = (
|
||||
(None, {"fields": ("page", "section_type", "badge", "title", "subtitle")}),
|
||||
("Text", {"fields": ("description_format", "description", "content_format", "content")}),
|
||||
("CTA Link", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}),
|
||||
VIDEO_ADMIN_FIELDSET,
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Preview")
|
||||
def video_preview(self, obj):
|
||||
return video_admin_preview(obj)
|
||||
|
||||
def save_formset(self, request, form, formset, change):
|
||||
instances = formset.save(commit=False)
|
||||
for instance in instances:
|
||||
if isinstance(instance, CustomPageSectionItem):
|
||||
instance.page = form.instance.page
|
||||
instance.save()
|
||||
for obj in formset.deleted_objects:
|
||||
obj.delete()
|
||||
formset.save_m2m()
|
||||
|
||||
|
||||
@admin.register(CustomPage)
|
||||
class CustomPageAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "slug", "show_in_nav", "menu_order", "is_published", "updated_at")
|
||||
list_filter = ("show_in_nav", "is_published")
|
||||
search_fields = ("title", "slug", "menu_label")
|
||||
list_editable = ("show_in_nav", "menu_order", "is_published")
|
||||
prepopulated_fields = {"slug": ("title",)}
|
||||
inlines = [CustomPageSectionInline, CustomPageSectionItemOnPageInline]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("title", "slug", "menu_label", "meta_description")}),
|
||||
("Navigation", {"fields": ("show_in_nav", "menu_order")}),
|
||||
("Publishing", {"fields": ("is_published",)}),
|
||||
)
|
||||
|
||||
def save_formset(self, request, form, formset, change):
|
||||
instances = formset.save(commit=False)
|
||||
for instance in instances:
|
||||
if isinstance(instance, CustomPageSectionItem):
|
||||
instance.page = form.instance
|
||||
instance.save()
|
||||
for obj in formset.deleted_objects:
|
||||
obj.delete()
|
||||
formset.save_m2m()
|
||||
|
||||
|
||||
admin.site.register(CustomPageSection, CustomPageSectionAdmin)
|
||||
|
||||
|
||||
@admin.register(PageVideo)
|
||||
class PageVideoAdmin(admin.ModelAdmin):
|
||||
list_display = ("page", "title", "video_source", "video_size", "order", "is_active")
|
||||
list_filter = ("page", "video_source", "is_active")
|
||||
list_editable = ("order", "is_active")
|
||||
search_fields = ("title", "description", "video_url")
|
||||
readonly_fields = ("video_preview",)
|
||||
fieldsets = (
|
||||
(None, {"fields": ("page", "badge", "title", "description_format", "description")}),
|
||||
VIDEO_ADMIN_FIELDSET,
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Preview")
|
||||
def video_preview(self, obj):
|
||||
return video_admin_preview(obj)
|
||||
|
||||
|
||||
@admin.register(DownloadItem)
|
||||
class DownloadItemAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "platform", "version", "is_active", "order")
|
||||
list_filter = ("platform", "is_active")
|
||||
search_fields = ("name", "description")
|
||||
list_editable = ("order", "is_active")
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "platform", "version", "download_url", "description")}),
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PagesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.pages"
|
||||
verbose_name = "Pages"
|
||||
@@ -0,0 +1,188 @@
|
||||
import os
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.utils.text import get_valid_filename
|
||||
from PIL import Image
|
||||
|
||||
ALLOWED_EXTENSIONS = frozenset(
|
||||
{
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".bmp",
|
||||
".zip",
|
||||
".log",
|
||||
".txt",
|
||||
}
|
||||
)
|
||||
|
||||
IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"})
|
||||
TEXT_EXTENSIONS = frozenset({".log", ".txt"})
|
||||
ARCHIVE_EXTENSIONS = frozenset({".zip"})
|
||||
|
||||
MAX_FILE_SIZE = getattr(settings, "CONTACT_ATTACHMENT_MAX_SIZE", 10 * 1024 * 1024)
|
||||
MAX_ATTACHMENTS = getattr(settings, "CONTACT_ATTACHMENT_MAX_COUNT", 3)
|
||||
MAX_ZIP_ENTRIES = 100
|
||||
MAX_ZIP_UNCOMPRESSED_SIZE = 50 * 1024 * 1024
|
||||
|
||||
|
||||
class ContactAttachmentStorage(FileSystemStorage):
|
||||
def __init__(self):
|
||||
super().__init__(location=settings.CONTACT_UPLOAD_ROOT)
|
||||
|
||||
|
||||
contact_attachment_storage = ContactAttachmentStorage()
|
||||
|
||||
|
||||
def contact_attachment_upload_to(instance, _filename):
|
||||
ext = os.path.splitext(instance.original_filename)[1].lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
ext = ".bin"
|
||||
subdir = str(instance.submission_id) if instance.submission_id else "pending"
|
||||
return f"{subdir}/{uuid.uuid4().hex}{ext}"
|
||||
|
||||
|
||||
def _extension(filename):
|
||||
return os.path.splitext(filename)[1].lower()
|
||||
|
||||
|
||||
def _read_header(uploaded_file, size=32):
|
||||
uploaded_file.seek(0)
|
||||
header = uploaded_file.read(size)
|
||||
uploaded_file.seek(0)
|
||||
return header
|
||||
|
||||
|
||||
def _validate_magic(header, ext):
|
||||
if ext == ".png" and not header.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
raise ValidationError("File content does not match its type.")
|
||||
if ext in (".jpg", ".jpeg") and not header.startswith(b"\xff\xd8\xff"):
|
||||
raise ValidationError("File content does not match its type.")
|
||||
if ext == ".gif" and not (
|
||||
header.startswith(b"GIF87a") or header.startswith(b"GIF89a")
|
||||
):
|
||||
raise ValidationError("File content does not match its type.")
|
||||
if ext == ".webp" and not (
|
||||
len(header) >= 12 and header[0:4] == b"RIFF" and header[8:12] == b"WEBP"
|
||||
):
|
||||
raise ValidationError("File content does not match its type.")
|
||||
if ext == ".bmp" and not header.startswith(b"BM"):
|
||||
raise ValidationError("File content does not match its type.")
|
||||
if ext == ".zip" and not (
|
||||
header.startswith(b"PK\x03\x04")
|
||||
or header.startswith(b"PK\x05\x06")
|
||||
or header.startswith(b"PK\x07\x08")
|
||||
):
|
||||
raise ValidationError("File content does not match its type.")
|
||||
|
||||
|
||||
def _validate_image(uploaded_file):
|
||||
try:
|
||||
uploaded_file.seek(0)
|
||||
with Image.open(uploaded_file) as img:
|
||||
img.verify()
|
||||
uploaded_file.seek(0)
|
||||
with Image.open(uploaded_file) as img:
|
||||
img.load()
|
||||
except Exception as exc:
|
||||
raise ValidationError("Invalid or corrupted image file.") from exc
|
||||
finally:
|
||||
uploaded_file.seek(0)
|
||||
|
||||
|
||||
def _validate_text(uploaded_file):
|
||||
uploaded_file.seek(0)
|
||||
data = uploaded_file.read()
|
||||
uploaded_file.seek(0)
|
||||
if b"\x00" in data:
|
||||
raise ValidationError("Text files must not contain binary data.")
|
||||
try:
|
||||
data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
data.decode("latin-1")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValidationError("Text file is not valid UTF-8 or Latin-1.") from exc
|
||||
|
||||
|
||||
def _validate_zip(uploaded_file):
|
||||
uploaded_file.seek(0)
|
||||
if not zipfile.is_zipfile(uploaded_file):
|
||||
raise ValidationError("Invalid ZIP archive.")
|
||||
uploaded_file.seek(0)
|
||||
|
||||
total_uncompressed = 0
|
||||
entry_count = 0
|
||||
|
||||
with zipfile.ZipFile(uploaded_file, "r") as archive:
|
||||
for info in archive.infolist():
|
||||
entry_count += 1
|
||||
if entry_count > MAX_ZIP_ENTRIES:
|
||||
raise ValidationError("ZIP archive contains too many files.")
|
||||
|
||||
name = info.filename
|
||||
if name.startswith("/") or ".." in PurePosixPath(name).parts:
|
||||
raise ValidationError("ZIP archive contains unsafe paths.")
|
||||
|
||||
if info.flag_bits & 0x1:
|
||||
raise ValidationError("Encrypted ZIP archives are not allowed.")
|
||||
|
||||
total_uncompressed += info.file_size
|
||||
if total_uncompressed > MAX_ZIP_UNCOMPRESSED_SIZE:
|
||||
raise ValidationError("ZIP archive uncompressed size is too large.")
|
||||
|
||||
uploaded_file.seek(0)
|
||||
|
||||
|
||||
def validate_contact_attachment(uploaded_file):
|
||||
if uploaded_file.size > MAX_FILE_SIZE:
|
||||
max_mb = MAX_FILE_SIZE // (1024 * 1024)
|
||||
raise ValidationError(f"File exceeds the maximum size of {max_mb} MB.")
|
||||
|
||||
name = get_valid_filename(os.path.basename(uploaded_file.name))
|
||||
if not name:
|
||||
raise ValidationError("Invalid file name.")
|
||||
|
||||
ext = _extension(name)
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise ValidationError(
|
||||
"File type not allowed. Permitted: images (PNG, JPG, GIF, WebP, BMP), "
|
||||
"ZIP, LOG, or TXT."
|
||||
)
|
||||
|
||||
header = _read_header(uploaded_file)
|
||||
if ext in IMAGE_EXTENSIONS or ext in ARCHIVE_EXTENSIONS:
|
||||
_validate_magic(header, ext)
|
||||
|
||||
if ext in IMAGE_EXTENSIONS:
|
||||
_validate_image(uploaded_file)
|
||||
elif ext in TEXT_EXTENSIONS:
|
||||
_validate_text(uploaded_file)
|
||||
elif ext in ARCHIVE_EXTENSIONS:
|
||||
_validate_zip(uploaded_file)
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def validate_contact_attachments(files):
|
||||
if not files:
|
||||
return []
|
||||
|
||||
if len(files) > MAX_ATTACHMENTS:
|
||||
raise ValidationError(f"You can attach at most {MAX_ATTACHMENTS} files.")
|
||||
|
||||
validated = []
|
||||
for uploaded_file in files:
|
||||
if not isinstance(uploaded_file, UploadedFile):
|
||||
raise ValidationError("Invalid upload.")
|
||||
original_name = validate_contact_attachment(uploaded_file)
|
||||
validated.append((uploaded_file, original_name))
|
||||
return validated
|
||||
@@ -0,0 +1,41 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from .contact_uploads import validate_contact_attachments
|
||||
from .models import ContactSubmission
|
||||
|
||||
|
||||
class ContactForm(forms.ModelForm):
|
||||
attachments = forms.Field(required=False)
|
||||
|
||||
class Meta:
|
||||
model = ContactSubmission
|
||||
fields = ["name", "title", "description", "email"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(
|
||||
attrs={"placeholder": "Your full name", "autocomplete": "name"}
|
||||
),
|
||||
"title": forms.TextInput(attrs={"placeholder": "Subject / topic"}),
|
||||
"description": forms.Textarea(
|
||||
attrs={"placeholder": "Write your message here…", "rows": 5}
|
||||
),
|
||||
"email": forms.EmailInput(
|
||||
attrs={
|
||||
"placeholder": "your@email.com (optional)",
|
||||
"autocomplete": "email",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, file_list=None, **kwargs):
|
||||
self.file_list = file_list
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
try:
|
||||
cleaned_data["attachments"] = validate_contact_attachments(self.file_list)
|
||||
except ValidationError as exc:
|
||||
self.add_error("attachments", exc)
|
||||
cleaned_data["attachments"] = []
|
||||
return cleaned_data
|
||||
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 5.2.13 on 2026-04-27 09:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DownloadItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('platform', models.CharField(choices=[('windows', 'Windows'), ('macos', 'macOS'), ('linux', 'Linux')], max_length=20)),
|
||||
('version', models.CharField(max_length=50)),
|
||||
('download_url', models.URLField()),
|
||||
('description', models.TextField(blank=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Download Item',
|
||||
'verbose_name_plural': 'Download Items',
|
||||
'ordering': ['order', 'platform'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FAQEntry',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('question', models.CharField(max_length=500)),
|
||||
('answer', models.TextField()),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'FAQ Entry',
|
||||
'verbose_name_plural': 'FAQ Entries',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-04 09:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ContactSubmission',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('title', models.CharField(max_length=300)),
|
||||
('description', models.TextField()),
|
||||
('email', models.EmailField(blank=True, max_length=254)),
|
||||
('submitted_at', models.DateTimeField(auto_now_add=True)),
|
||||
('is_read', models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Contact Submission',
|
||||
'verbose_name_plural': 'Contact Submissions',
|
||||
'ordering': ['-submitted_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-14 05:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0002_contactsubmission'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='faqentry',
|
||||
name='answer_format',
|
||||
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,103 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pages", "0003_faqentry_answer_format"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AboutSection",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
(
|
||||
"section_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("hero", "Hero"),
|
||||
("intro", "Intro Card"),
|
||||
("grid", "Grid Cards"),
|
||||
("history", "History Block"),
|
||||
("custom", "Custom Content"),
|
||||
],
|
||||
default="custom",
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
("badge", models.CharField(blank=True, max_length=100)),
|
||||
("title", models.CharField(blank=True, max_length=300)),
|
||||
(
|
||||
"subtitle",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Used as subtitle in Hero and year in History.",
|
||||
max_length=500,
|
||||
),
|
||||
),
|
||||
("content", models.TextField(blank=True)),
|
||||
(
|
||||
"content_format",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("plain", "Plain Text"),
|
||||
("markdown", "Markdown"),
|
||||
("html", "HTML"),
|
||||
],
|
||||
default="markdown",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("order", models.PositiveIntegerField(default=0)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "About Section",
|
||||
"verbose_name_plural": "About Sections",
|
||||
"ordering": ["order"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AboutSectionItem",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
(
|
||||
"section",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="items",
|
||||
to="pages.aboutsection",
|
||||
),
|
||||
),
|
||||
("badge", models.CharField(blank=True, max_length=100)),
|
||||
("title", models.CharField(blank=True, max_length=300)),
|
||||
(
|
||||
"content",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Description text or link label for History links.",
|
||||
),
|
||||
),
|
||||
("url", models.URLField(blank=True, help_text="Used for History block links.")),
|
||||
("image", models.ImageField(blank=True, null=True, upload_to="about/")),
|
||||
("image_alt", models.CharField(blank=True, max_length=200)),
|
||||
(
|
||||
"is_featured",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Mark as featured item (e.g. large screenshot).",
|
||||
),
|
||||
),
|
||||
("order", models.PositiveIntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "About Section Item",
|
||||
"verbose_name_plural": "About Section Items",
|
||||
"ordering": ["order"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-17 14:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0004_aboutsection_aboutsectionitem'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='HomepageSection',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('section_type', models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip')], max_length=30, unique=True)),
|
||||
('badge', models.CharField(blank=True, max_length=100)),
|
||||
('title', models.CharField(blank=True, max_length=300)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('link_text', models.CharField(blank=True, help_text='CTA button label (About Strip).', max_length=100)),
|
||||
('link_url', models.CharField(blank=True, help_text='CTA button URL (About Strip).', max_length=300)),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Homepage Section',
|
||||
'verbose_name_plural': 'Homepage Sections',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HomepageSectionItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('icon', models.CharField(blank=True, help_text='Emoji or short symbol (e.g. ⚗️).', max_length=20)),
|
||||
('title', models.CharField(blank=True, max_length=300)),
|
||||
('content', models.TextField(blank=True)),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='pages.homepagesection')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Homepage Section Item',
|
||||
'verbose_name_plural': 'Homepage Section Items',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-17 14:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0005_homepage_sections'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homepagesectionitem',
|
||||
name='image',
|
||||
field=models.ImageField(blank=True, help_text='Logo or image (used for Supporters cards).', null=True, upload_to='homepage/items/'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='homepagesection',
|
||||
name='section_type',
|
||||
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters')], max_length=30, unique=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-20 09:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0006_homepagesectionitem_image_supporters'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='HeroSection',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('badge', models.CharField(blank=True, help_text="Small label above the title (e.g. 'Developing since 2021').", max_length=200)),
|
||||
('title', models.CharField(blank=True, help_text="Main title line (e.g. 'Radiuma,').", max_length=300)),
|
||||
('title_highlight', models.CharField(blank=True, help_text="Second title line shown in gradient colour (e.g. 'A Powerful Workflow Generator').", max_length=300)),
|
||||
('subtitle', models.CharField(blank=True, help_text="Short line below the title (e.g. 'for Standardized Radiomics Analysis…').", max_length=500)),
|
||||
('description', models.TextField(blank=True, help_text='Longer paragraph below the subtitle.')),
|
||||
('primary_cta_text', models.CharField(blank=True, help_text='Primary button label.', max_length=100)),
|
||||
('primary_cta_url', models.CharField(blank=True, help_text='Primary button URL (relative or absolute).', max_length=300)),
|
||||
('secondary_cta_text', models.CharField(blank=True, help_text='Secondary (ghost) button label.', max_length=100)),
|
||||
('secondary_cta_url', models.CharField(blank=True, help_text='Secondary (ghost) button URL.', max_length=300)),
|
||||
('image', models.ImageField(blank=True, help_text='App preview screenshot shown on the right.', null=True, upload_to='hero/')),
|
||||
('image_alt', models.CharField(blank=True, help_text='Alt text for the preview image.', max_length=300)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Hero Section',
|
||||
'verbose_name_plural': 'Hero Section',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-25 12:55
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0007_hero_section'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomPage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('slug', models.SlugField(max_length=200, unique=True)),
|
||||
('menu_label', models.CharField(blank=True, help_text='Nav label when shown in menu. Defaults to title.', max_length=100)),
|
||||
('meta_description', models.CharField(blank=True, max_length=300)),
|
||||
('show_in_nav', models.BooleanField(default=True)),
|
||||
('menu_order', models.PositiveIntegerField(default=0)),
|
||||
('is_published', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Custom Page',
|
||||
'verbose_name_plural': 'Custom Pages',
|
||||
'ordering': ['menu_order', 'title'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CustomPageSection',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('section_type', models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products Grid'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion')], default='custom', max_length=30)),
|
||||
('badge', models.CharField(blank=True, max_length=100)),
|
||||
('title', models.CharField(blank=True, max_length=300)),
|
||||
('subtitle', models.CharField(blank=True, max_length=500)),
|
||||
('description', models.TextField(blank=True, help_text='Short intro text (homepage-style sections).')),
|
||||
('content', models.TextField(blank=True)),
|
||||
('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='markdown', max_length=20)),
|
||||
('link_text', models.CharField(blank=True, max_length=100)),
|
||||
('link_url', models.CharField(blank=True, max_length=300)),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='pages.custompage')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Custom Page Section',
|
||||
'verbose_name_plural': 'Custom Page Sections',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CustomPageSectionItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('icon', models.CharField(blank=True, max_length=20)),
|
||||
('badge', models.CharField(blank=True, max_length=100)),
|
||||
('title', models.CharField(blank=True, max_length=300)),
|
||||
('content', models.TextField(blank=True)),
|
||||
('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20)),
|
||||
('url', models.URLField(blank=True)),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='pages/custom/')),
|
||||
('image_alt', models.CharField(blank=True, max_length=200)),
|
||||
('is_featured', models.BooleanField(default=False)),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='pages.custompagesection')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Custom Page Section Item',
|
||||
'verbose_name_plural': 'Custom Page Section Items',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-26 12:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0008_custom_pages'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='homepagesection',
|
||||
name='section_type',
|
||||
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters')], max_length=30),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-26 12:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0009_alter_homepagesection_section_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homepagesectionitem',
|
||||
name='url',
|
||||
field=models.CharField(blank=True, help_text='Optional link; makes this item clickable.', max_length=300),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='custompagesection',
|
||||
name='section_type',
|
||||
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products Grid'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion')], default='custom', max_length=30),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='custompagesectionitem',
|
||||
name='url',
|
||||
field=models.CharField(blank=True, help_text='Optional link; makes this item clickable.', max_length=300),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='homepagesection',
|
||||
name='section_type',
|
||||
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters')], max_length=30),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def set_custom_page_section_item_page(apps, schema_editor):
|
||||
CustomPageSectionItem = apps.get_model("pages", "CustomPageSectionItem")
|
||||
for item in CustomPageSectionItem.objects.select_related("section").iterator():
|
||||
item.page_id = item.section.page_id
|
||||
item.save(update_fields=["page_id"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pages", "0010_homepagesectionitem_url_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="aboutsectionitem",
|
||||
name="url",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Optional link; makes this item clickable.",
|
||||
max_length=300,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="custompagesectionitem",
|
||||
name="page",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="section_items",
|
||||
to="pages.custompage",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(set_custom_page_section_item_page, migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name="custompagesectionitem",
|
||||
name="page",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="section_items",
|
||||
to="pages.custompage",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pages", "0011_custompagesectionitem_page_aboutsectionitem_url"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="aboutsectionitem",
|
||||
name="icon",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Emoji or short symbol (e.g. ⚗️).",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.0.2 on 2026-06-07 13:23
|
||||
|
||||
import apps.pages.contact_uploads
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0012_aboutsectionitem_icon'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ContactSubmissionAttachment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(storage=apps.pages.contact_uploads.ContactAttachmentStorage(), upload_to=apps.pages.contact_uploads.contact_attachment_upload_to)),
|
||||
('original_filename', models.CharField(max_length=255)),
|
||||
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='pages.contactsubmission')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Contact Attachment',
|
||||
'verbose_name_plural': 'Contact Attachments',
|
||||
'ordering': ['uploaded_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.13 on 2026-06-07 13:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0013_contact_submission_attachments'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='aboutsection',
|
||||
name='section_type',
|
||||
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('supporters', 'Supporters')], default='custom', max_length=30),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aboutsectionitem',
|
||||
name='image',
|
||||
field=models.ImageField(blank=True, help_text='Logo or image (used for Supporters cards).', null=True, upload_to='about/items/'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,141 @@
|
||||
# Generated by Django 5.2.13 on 2026-06-08 12:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0014_alter_aboutsection_section_type_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PageVideo',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('video_source', models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20)),
|
||||
('video_url', models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500)),
|
||||
('video_file', models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/')),
|
||||
('video_poster', models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/')),
|
||||
('video_size', models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10)),
|
||||
('video_aspect_ratio', models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10)),
|
||||
('page', models.CharField(choices=[('contact', 'Contact'), ('faq', 'FAQ')], max_length=20)),
|
||||
('badge', models.CharField(blank=True, max_length=100)),
|
||||
('title', models.CharField(blank=True, max_length=300)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Page Video',
|
||||
'verbose_name_plural': 'Page Videos',
|
||||
'ordering': ['page', 'order'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='video_aspect_ratio',
|
||||
field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='video_file',
|
||||
field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='video_poster',
|
||||
field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='video_size',
|
||||
field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='video_source',
|
||||
field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='video_url',
|
||||
field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='video_aspect_ratio',
|
||||
field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='video_file',
|
||||
field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='video_poster',
|
||||
field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='video_size',
|
||||
field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='video_source',
|
||||
field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='video_url',
|
||||
field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='video_aspect_ratio',
|
||||
field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='video_file',
|
||||
field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='video_poster',
|
||||
field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='video_size',
|
||||
field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='video_source',
|
||||
field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='video_url',
|
||||
field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aboutsection',
|
||||
name='section_type',
|
||||
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('supporters', 'Supporters'), ('video', 'Video')], default='custom', max_length=30),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='custompagesection',
|
||||
name='section_type',
|
||||
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products Grid'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion'), ('video', 'Video')], default='custom', max_length=30),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='homepagesection',
|
||||
name='section_type',
|
||||
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters'), ('video', 'Video')], max_length=30),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.13 on 2026-06-08 13:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0015_video_support'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagevideo',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.13 on 2026-06-08 13:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0016_video_styled_background'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aboutsection',
|
||||
name='description_format',
|
||||
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='custompagesection',
|
||||
name='description_format',
|
||||
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='description_format',
|
||||
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagevideo',
|
||||
name='description_format',
|
||||
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.2.15 on 2026-06-20 23:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0017_video_description_format'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='homepagesection',
|
||||
name='title_highlight',
|
||||
field=models.CharField(blank=True, help_text="Second title line shown in accent colour (e.g. 'Outstanding Projects').", max_length=300),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesectionitem',
|
||||
name='image_alt',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesectionitem',
|
||||
name='project_status',
|
||||
field=models.CharField(blank=True, choices=[('', '—'), ('new', 'New'), ('ongoing', 'Ongoing'), ('done', 'Done')], help_text='Project filter category (Projects Showcase section).', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='homepagesectionitem',
|
||||
name='tags',
|
||||
field=models.CharField(blank=True, help_text='Comma-separated tags shown on project cards (e.g. Web Development, Publication).', max_length=300),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='herosection',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, help_text="Main title line (e.g. 'Advanced Solutions').", max_length=300),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='homepagesection',
|
||||
name='section_type',
|
||||
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('projects', 'Projects Showcase'), ('experience', 'Experience / Benefits'), ('about_strip', 'About Strip'), ('supporters', 'Supporters'), ('video', 'Video')], max_length=30),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='homepagesectionitem',
|
||||
name='image',
|
||||
field=models.ImageField(blank=True, help_text='Card image or icon.', null=True, upload_to='homepage/items/'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,479 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
|
||||
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content
|
||||
from apps.core.video import VideoBlockMixin
|
||||
from apps.pages.contact_uploads import (
|
||||
contact_attachment_storage,
|
||||
contact_attachment_upload_to,
|
||||
)
|
||||
|
||||
RESERVED_PAGE_SLUGS = frozenset({
|
||||
"admin",
|
||||
"about",
|
||||
"contact",
|
||||
"faq",
|
||||
"home",
|
||||
"products",
|
||||
"static",
|
||||
"media",
|
||||
})
|
||||
|
||||
|
||||
class HeroSection(models.Model):
|
||||
badge = models.CharField(max_length=200, blank=True, help_text="Small label above the title (e.g. 'Developing since 2021').")
|
||||
title = models.CharField(max_length=300, blank=True, help_text="Main title line (e.g. 'Advanced Solutions').")
|
||||
title_highlight = models.CharField(max_length=300, blank=True, help_text="Second title line shown in gradient colour (e.g. 'A Powerful Workflow Generator').")
|
||||
subtitle = models.CharField(max_length=500, blank=True, help_text="Short line below the title (e.g. 'for Standardized Radiomics Analysis…').")
|
||||
description = models.TextField(blank=True, help_text="Longer paragraph below the subtitle.")
|
||||
primary_cta_text = models.CharField(max_length=100, blank=True, help_text="Primary button label.")
|
||||
primary_cta_url = models.CharField(max_length=300, blank=True, help_text="Primary button URL (relative or absolute).")
|
||||
secondary_cta_text = models.CharField(max_length=100, blank=True, help_text="Secondary (ghost) button label.")
|
||||
secondary_cta_url = models.CharField(max_length=300, blank=True, help_text="Secondary (ghost) button URL.")
|
||||
image = models.ImageField(upload_to="hero/", blank=True, null=True, help_text="App preview screenshot shown on the right.")
|
||||
image_alt = models.CharField(max_length=300, blank=True, help_text="Alt text for the preview image.")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Hero Section"
|
||||
verbose_name_plural = "Hero Section"
|
||||
|
||||
def __str__(self):
|
||||
return "Hero Section"
|
||||
|
||||
|
||||
class HomepageSection(VideoBlockMixin, models.Model):
|
||||
TYPE_FEATURES = "features"
|
||||
TYPE_SCREENSHOTS = "screenshots"
|
||||
TYPE_PRODUCTS = "products"
|
||||
TYPE_PRODUCTS_CATALOG = "products_catalog"
|
||||
TYPE_PROBLEMS = "problems"
|
||||
TYPE_PROJECTS = "projects"
|
||||
TYPE_EXPERIENCE = "experience"
|
||||
TYPE_ABOUT_STRIP = "about_strip"
|
||||
TYPE_SUPPORTERS = "supporters"
|
||||
TYPE_VIDEO = "video"
|
||||
|
||||
TYPE_CHOICES = [
|
||||
(TYPE_FEATURES, "Features"),
|
||||
(TYPE_SCREENSHOTS, "Screenshots Gallery"),
|
||||
(TYPE_PRODUCTS, "Products"),
|
||||
(TYPE_PRODUCTS_CATALOG, "Products with Sub-products"),
|
||||
(TYPE_PROBLEMS, "Problems / Value Proposition"),
|
||||
(TYPE_PROJECTS, "Projects Showcase"),
|
||||
(TYPE_EXPERIENCE, "Experience / Benefits"),
|
||||
(TYPE_ABOUT_STRIP, "About Strip"),
|
||||
(TYPE_SUPPORTERS, "Supporters"),
|
||||
(TYPE_VIDEO, "Video"),
|
||||
]
|
||||
|
||||
section_type = models.CharField(max_length=30, choices=TYPE_CHOICES)
|
||||
badge = models.CharField(max_length=100, blank=True)
|
||||
title = models.CharField(max_length=300, blank=True)
|
||||
title_highlight = models.CharField(
|
||||
max_length=300,
|
||||
blank=True,
|
||||
help_text="Second title line shown in accent colour (e.g. 'Outstanding Projects').",
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
link_text = models.CharField(max_length=100, blank=True, help_text="CTA button label (About Strip).")
|
||||
link_url = models.CharField(max_length=300, blank=True, help_text="CTA button URL (About Strip).")
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
verbose_name = "Homepage Section"
|
||||
verbose_name_plural = "Homepage Sections"
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.section_type == self.TYPE_VIDEO:
|
||||
self.clean_video_fields(require=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"[{self.get_section_type_display()}] {self.title or self.badge}"
|
||||
|
||||
|
||||
class HomepageSectionItem(models.Model):
|
||||
STATUS_NEW = "new"
|
||||
STATUS_ONGOING = "ongoing"
|
||||
STATUS_DONE = "done"
|
||||
|
||||
PROJECT_STATUS_CHOICES = [
|
||||
("", "—"),
|
||||
(STATUS_NEW, "New"),
|
||||
(STATUS_ONGOING, "Ongoing"),
|
||||
(STATUS_DONE, "Done"),
|
||||
]
|
||||
|
||||
section = models.ForeignKey(HomepageSection, on_delete=models.CASCADE, related_name="items")
|
||||
icon = models.CharField(max_length=20, blank=True, help_text="Emoji or short symbol (e.g. ⚗️).")
|
||||
title = models.CharField(max_length=300, blank=True)
|
||||
content = models.TextField(blank=True)
|
||||
url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.")
|
||||
image = models.ImageField(upload_to="homepage/items/", blank=True, null=True, help_text="Card image or icon.")
|
||||
image_alt = models.CharField(max_length=200, blank=True)
|
||||
tags = models.CharField(
|
||||
max_length=300,
|
||||
blank=True,
|
||||
help_text="Comma-separated tags shown on project cards (e.g. Web Development, Publication).",
|
||||
)
|
||||
project_status = models.CharField(
|
||||
max_length=10,
|
||||
choices=PROJECT_STATUS_CHOICES,
|
||||
blank=True,
|
||||
help_text="Project filter category (Projects Showcase section).",
|
||||
)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
verbose_name = "Homepage Section Item"
|
||||
verbose_name_plural = "Homepage Section Items"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.section} › {self.title or self.icon or '(item)'}"
|
||||
|
||||
@property
|
||||
def tag_list(self):
|
||||
if not self.tags.strip():
|
||||
return []
|
||||
return [tag.strip() for tag in self.tags.split(",") if tag.strip()]
|
||||
|
||||
|
||||
class AboutSection(VideoBlockMixin, models.Model):
|
||||
TYPE_HERO = "hero"
|
||||
TYPE_INTRO = "intro"
|
||||
TYPE_GRID = "grid"
|
||||
TYPE_HISTORY = "history"
|
||||
TYPE_CUSTOM = "custom"
|
||||
TYPE_SUPPORTERS = "supporters"
|
||||
TYPE_VIDEO = "video"
|
||||
|
||||
TYPE_CHOICES = [
|
||||
(TYPE_HERO, "Hero"),
|
||||
(TYPE_INTRO, "Intro Card"),
|
||||
(TYPE_GRID, "Grid Cards"),
|
||||
(TYPE_HISTORY, "History Block"),
|
||||
(TYPE_CUSTOM, "Custom Content"),
|
||||
(TYPE_SUPPORTERS, "Supporters"),
|
||||
(TYPE_VIDEO, "Video"),
|
||||
]
|
||||
|
||||
section_type = models.CharField(max_length=30, choices=TYPE_CHOICES, default=TYPE_CUSTOM)
|
||||
badge = models.CharField(max_length=100, blank=True)
|
||||
title = models.CharField(max_length=300, blank=True)
|
||||
subtitle = models.CharField(max_length=500, blank=True, help_text="Used as subtitle in Hero and year in History.")
|
||||
content = models.TextField(blank=True)
|
||||
content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_MARKDOWN)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
verbose_name = "About Section"
|
||||
verbose_name_plural = "About Sections"
|
||||
|
||||
@property
|
||||
def rendered_content(self):
|
||||
return render_content(self.content, self.content_format)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.section_type == self.TYPE_VIDEO:
|
||||
self.clean_video_fields(require=True)
|
||||
|
||||
def __str__(self):
|
||||
label = self.title or self.badge or self.get_section_type_display()
|
||||
return f"[{self.get_section_type_display()}] {label}"
|
||||
|
||||
|
||||
class AboutSectionItem(models.Model):
|
||||
section = models.ForeignKey(AboutSection, on_delete=models.CASCADE, related_name="items")
|
||||
icon = models.CharField(max_length=20, blank=True, help_text="Emoji or short symbol (e.g. ⚗️).")
|
||||
badge = models.CharField(max_length=100, blank=True)
|
||||
title = models.CharField(max_length=300, blank=True)
|
||||
content = models.TextField(blank=True, help_text="Description text or link label for History links.")
|
||||
url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.")
|
||||
image = models.ImageField(
|
||||
upload_to="about/items/",
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Logo or image (used for Supporters cards).",
|
||||
)
|
||||
image_alt = models.CharField(max_length=200, blank=True)
|
||||
is_featured = models.BooleanField(default=False, help_text="Mark as featured item (e.g. large screenshot).")
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
verbose_name = "About Section Item"
|
||||
verbose_name_plural = "About Section Items"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.section} › {self.title or self.icon or self.badge or '(item)'}"
|
||||
|
||||
|
||||
class ContactSubmission(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
title = models.CharField(max_length=300)
|
||||
description = models.TextField()
|
||||
email = models.EmailField(blank=True)
|
||||
submitted_at = models.DateTimeField(auto_now_add=True)
|
||||
is_read = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-submitted_at"]
|
||||
verbose_name = "Contact Submission"
|
||||
verbose_name_plural = "Contact Submissions"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} — {self.title}"
|
||||
|
||||
|
||||
class ContactSubmissionAttachment(models.Model):
|
||||
submission = models.ForeignKey(
|
||||
ContactSubmission,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="attachments",
|
||||
)
|
||||
file = models.FileField(
|
||||
upload_to=contact_attachment_upload_to,
|
||||
storage=contact_attachment_storage,
|
||||
)
|
||||
original_filename = models.CharField(max_length=255)
|
||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["uploaded_at"]
|
||||
verbose_name = "Contact Attachment"
|
||||
verbose_name_plural = "Contact Attachments"
|
||||
|
||||
def __str__(self):
|
||||
return self.original_filename
|
||||
|
||||
|
||||
class FAQEntry(models.Model):
|
||||
question = models.CharField(max_length=500)
|
||||
answer = models.TextField()
|
||||
answer_format = models.CharField(
|
||||
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
|
||||
)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
verbose_name = "FAQ Entry"
|
||||
verbose_name_plural = "FAQ Entries"
|
||||
|
||||
def __str__(self):
|
||||
return self.question
|
||||
|
||||
@property
|
||||
def rendered_answer(self):
|
||||
return render_content(self.answer, self.answer_format)
|
||||
|
||||
|
||||
class DownloadItem(models.Model):
|
||||
PLATFORM_WINDOWS = "windows"
|
||||
PLATFORM_MACOS = "macos"
|
||||
PLATFORM_LINUX = "linux"
|
||||
|
||||
PLATFORM_CHOICES = [
|
||||
(PLATFORM_WINDOWS, "Windows"),
|
||||
(PLATFORM_MACOS, "macOS"),
|
||||
(PLATFORM_LINUX, "Linux"),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
platform = models.CharField(max_length=20, choices=PLATFORM_CHOICES)
|
||||
version = models.CharField(max_length=50)
|
||||
download_url = models.URLField()
|
||||
description = models.TextField(blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "platform"]
|
||||
verbose_name = "Download Item"
|
||||
verbose_name_plural = "Download Items"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_platform_display()})"
|
||||
|
||||
|
||||
class CustomPage(models.Model):
|
||||
title = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=200, unique=True)
|
||||
menu_label = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Nav label when shown in menu. Defaults to title.",
|
||||
)
|
||||
meta_description = models.CharField(max_length=300, blank=True)
|
||||
show_in_nav = models.BooleanField(default=True)
|
||||
menu_order = models.PositiveIntegerField(default=0)
|
||||
is_published = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["menu_order", "title"]
|
||||
verbose_name = "Custom Page"
|
||||
verbose_name_plural = "Custom Pages"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def nav_label(self):
|
||||
return self.menu_label or self.title
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.slug in RESERVED_PAGE_SLUGS:
|
||||
raise ValidationError({"slug": f'"{self.slug}" is reserved and cannot be used.'})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.title)
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class CustomPageSection(VideoBlockMixin, models.Model):
|
||||
TYPE_HERO = "hero"
|
||||
TYPE_INTRO = "intro"
|
||||
TYPE_GRID = "grid"
|
||||
TYPE_HISTORY = "history"
|
||||
TYPE_CUSTOM = "custom"
|
||||
TYPE_FEATURES = "features"
|
||||
TYPE_SCREENSHOTS = "screenshots"
|
||||
TYPE_PRODUCTS = "products"
|
||||
TYPE_PRODUCTS_CATALOG = "products_catalog"
|
||||
TYPE_PROBLEMS = "problems"
|
||||
TYPE_SUPPORTERS = "supporters"
|
||||
TYPE_ABOUT_STRIP = "about_strip"
|
||||
TYPE_FAQ = "faq"
|
||||
TYPE_VIDEO = "video"
|
||||
|
||||
TYPE_CHOICES = [
|
||||
(TYPE_HERO, "Hero"),
|
||||
(TYPE_INTRO, "Intro Card"),
|
||||
(TYPE_GRID, "Grid Cards"),
|
||||
(TYPE_HISTORY, "History Block"),
|
||||
(TYPE_CUSTOM, "Custom Content"),
|
||||
(TYPE_FEATURES, "Features Grid"),
|
||||
(TYPE_SCREENSHOTS, "Screenshots Gallery"),
|
||||
(TYPE_PRODUCTS, "Products Grid"),
|
||||
(TYPE_PRODUCTS_CATALOG, "Products with Sub-products"),
|
||||
(TYPE_PROBLEMS, "Problems / Value Proposition"),
|
||||
(TYPE_SUPPORTERS, "Supporters"),
|
||||
(TYPE_ABOUT_STRIP, "About Strip"),
|
||||
(TYPE_FAQ, "FAQ Accordion"),
|
||||
(TYPE_VIDEO, "Video"),
|
||||
]
|
||||
|
||||
page = models.ForeignKey(CustomPage, on_delete=models.CASCADE, related_name="sections")
|
||||
section_type = models.CharField(max_length=30, choices=TYPE_CHOICES, default=TYPE_CUSTOM)
|
||||
badge = models.CharField(max_length=100, blank=True)
|
||||
title = models.CharField(max_length=300, blank=True)
|
||||
subtitle = models.CharField(max_length=500, blank=True)
|
||||
description = models.TextField(blank=True, help_text="Short intro text (homepage-style sections).")
|
||||
content = models.TextField(blank=True)
|
||||
content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_MARKDOWN)
|
||||
link_text = models.CharField(max_length=100, blank=True)
|
||||
link_url = models.CharField(max_length=300, blank=True)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
verbose_name = "Custom Page Section"
|
||||
verbose_name_plural = "Custom Page Sections"
|
||||
|
||||
@property
|
||||
def rendered_content(self):
|
||||
return render_content(self.content, self.content_format)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.section_type == self.TYPE_VIDEO:
|
||||
self.clean_video_fields(require=True)
|
||||
|
||||
def __str__(self):
|
||||
label = self.title or self.badge or self.get_section_type_display()
|
||||
return f"{self.page} › [{self.get_section_type_display()}] {label}"
|
||||
|
||||
|
||||
class PageVideo(VideoBlockMixin, models.Model):
|
||||
PAGE_CONTACT = "contact"
|
||||
PAGE_FAQ = "faq"
|
||||
|
||||
PAGE_CHOICES = [
|
||||
(PAGE_CONTACT, "Contact"),
|
||||
(PAGE_FAQ, "FAQ"),
|
||||
]
|
||||
|
||||
page = models.CharField(max_length=20, choices=PAGE_CHOICES)
|
||||
badge = models.CharField(max_length=100, blank=True)
|
||||
title = models.CharField(max_length=300, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["page", "order"]
|
||||
verbose_name = "Page Video"
|
||||
verbose_name_plural = "Page Videos"
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
self.clean_video_fields(require=True)
|
||||
|
||||
def __str__(self):
|
||||
label = self.title or self.badge or self.get_page_display()
|
||||
return f"{self.get_page_display()} › {label}"
|
||||
|
||||
|
||||
class CustomPageSectionItem(models.Model):
|
||||
page = models.ForeignKey(
|
||||
CustomPage,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="section_items",
|
||||
)
|
||||
section = models.ForeignKey(CustomPageSection, on_delete=models.CASCADE, related_name="items")
|
||||
icon = models.CharField(max_length=20, blank=True)
|
||||
badge = models.CharField(max_length=100, blank=True)
|
||||
title = models.CharField(max_length=300, blank=True)
|
||||
content = models.TextField(blank=True)
|
||||
content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN)
|
||||
url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.")
|
||||
image = models.ImageField(upload_to="pages/custom/", blank=True, null=True)
|
||||
image_alt = models.CharField(max_length=200, blank=True)
|
||||
is_featured = models.BooleanField(default=False)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order"]
|
||||
verbose_name = "Custom Page Section Item"
|
||||
verbose_name_plural = "Custom Page Section Items"
|
||||
|
||||
@property
|
||||
def rendered_content(self):
|
||||
return render_content(self.content, self.content_format)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.section_id:
|
||||
self.page_id = self.section.page_id
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.section} › {self.title or self.icon or '(item)'}"
|
||||
@@ -0,0 +1,158 @@
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from PIL import Image
|
||||
|
||||
from apps.pages.contact_uploads import (
|
||||
MAX_ATTACHMENTS,
|
||||
MAX_FILE_SIZE,
|
||||
validate_contact_attachments,
|
||||
)
|
||||
from apps.pages.models import ContactSubmission, ContactSubmissionAttachment
|
||||
|
||||
|
||||
def _png_file(name="screenshot.png"):
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), color="red").save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
return SimpleUploadedFile(name, buffer.read(), content_type="image/png")
|
||||
|
||||
|
||||
def _zip_file(name="logs.zip", entries=None):
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for entry_name, content in (entries or {"app.log": "line one\n"}).items():
|
||||
archive.writestr(entry_name, content)
|
||||
buffer.seek(0)
|
||||
return SimpleUploadedFile(name, buffer.read(), content_type="application/zip")
|
||||
|
||||
|
||||
class ContactUploadValidationTest(TestCase):
|
||||
def test_accepts_valid_png(self):
|
||||
validated = validate_contact_attachments([_png_file()])
|
||||
self.assertEqual(len(validated), 1)
|
||||
self.assertEqual(validated[0][1], "screenshot.png")
|
||||
|
||||
def test_accepts_valid_zip(self):
|
||||
validated = validate_contact_attachments([_zip_file()])
|
||||
self.assertEqual(len(validated), 1)
|
||||
|
||||
def test_accepts_valid_text_log(self):
|
||||
uploaded = SimpleUploadedFile(
|
||||
"error.log",
|
||||
b"2026-06-07 ERROR something failed\n",
|
||||
content_type="text/plain",
|
||||
)
|
||||
validated = validate_contact_attachments([uploaded])
|
||||
self.assertEqual(validated[0][1], "error.log")
|
||||
|
||||
def test_rejects_executable_extension(self):
|
||||
uploaded = SimpleUploadedFile(
|
||||
"malware.exe",
|
||||
b"MZfake",
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
validate_contact_attachments([uploaded])
|
||||
|
||||
def test_rejects_php_disguised_as_png(self):
|
||||
uploaded = SimpleUploadedFile(
|
||||
"image.png",
|
||||
b"<?php echo 'bad'; ?>",
|
||||
content_type="image/png",
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
validate_contact_attachments([uploaded])
|
||||
|
||||
def test_rejects_oversized_file(self):
|
||||
uploaded = SimpleUploadedFile(
|
||||
"big.log",
|
||||
b"x" * (MAX_FILE_SIZE + 1),
|
||||
content_type="text/plain",
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
validate_contact_attachments([uploaded])
|
||||
|
||||
def test_rejects_too_many_files(self):
|
||||
files = [_png_file(f"shot-{index}.png") for index in range(MAX_ATTACHMENTS + 1)]
|
||||
with self.assertRaises(Exception):
|
||||
validate_contact_attachments(files)
|
||||
|
||||
def test_rejects_zip_with_path_traversal(self):
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
archive.writestr("../escape.txt", "bad")
|
||||
buffer.seek(0)
|
||||
uploaded = SimpleUploadedFile(
|
||||
"bad.zip",
|
||||
buffer.read(),
|
||||
content_type="application/zip",
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
validate_contact_attachments([uploaded])
|
||||
|
||||
|
||||
@override_settings(
|
||||
CONTACT_UPLOAD_ROOT=__import__("pathlib").Path(__file__).resolve().parents[3]
|
||||
/ "test_private_uploads"
|
||||
)
|
||||
class ContactViewUploadTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client(enforce_csrf_checks=True)
|
||||
self.url = reverse("pages:contact")
|
||||
|
||||
def _start_session(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.csrf_token = response.cookies["csrftoken"].value
|
||||
captcha_question = response.context["captcha_question"]
|
||||
left, right = captcha_question.split(" + ")
|
||||
return int(left) + int(right)
|
||||
|
||||
def _post_contact(self, captcha_answer, attachments=None, extra=None):
|
||||
payload = {
|
||||
"name": "Test User",
|
||||
"title": "Upload test",
|
||||
"description": "Testing attachments",
|
||||
"email": "test@example.com",
|
||||
"captcha_answer": captcha_answer,
|
||||
}
|
||||
if attachments is not None:
|
||||
payload["attachments"] = attachments
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
return self.client.post(
|
||||
self.url,
|
||||
data=payload,
|
||||
HTTP_X_CSRFTOKEN=self.csrf_token,
|
||||
)
|
||||
|
||||
def test_contact_submission_with_png_attachment(self):
|
||||
captcha_answer = self._start_session()
|
||||
response = self._post_contact(captcha_answer, attachments=_png_file())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()["success"])
|
||||
submission = ContactSubmission.objects.get(title="Upload test")
|
||||
self.assertEqual(submission.attachments.count(), 1)
|
||||
attachment = submission.attachments.first()
|
||||
self.assertEqual(attachment.original_filename, "screenshot.png")
|
||||
self.assertTrue(attachment.file.storage.exists(attachment.file.name))
|
||||
|
||||
def test_contact_submission_rejects_invalid_attachment(self):
|
||||
captcha_answer = self._start_session()
|
||||
response = self._post_contact(
|
||||
captcha_answer,
|
||||
attachments=SimpleUploadedFile(
|
||||
"bad.exe",
|
||||
b"MZ",
|
||||
content_type="application/octet-stream",
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(response.json()["success"])
|
||||
self.assertIn("attachments", response.json()["errors"])
|
||||
self.assertEqual(ContactSubmission.objects.count(), 0)
|
||||
self.assertEqual(ContactSubmissionAttachment.objects.count(), 0)
|
||||
@@ -0,0 +1,94 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.pages.models import CustomPage, CustomPageSection, CustomPageSectionItem
|
||||
|
||||
|
||||
class CustomPageViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.page = CustomPage.objects.create(
|
||||
title="Resources",
|
||||
slug="resources",
|
||||
show_in_nav=True,
|
||||
menu_order=5,
|
||||
is_published=True,
|
||||
)
|
||||
CustomPageSection.objects.create(
|
||||
page=self.page,
|
||||
section_type=CustomPageSection.TYPE_HERO,
|
||||
title="Resources",
|
||||
badge="Docs",
|
||||
is_active=True,
|
||||
)
|
||||
CustomPage.objects.create(
|
||||
title="Draft Page",
|
||||
slug="draft",
|
||||
is_published=False,
|
||||
)
|
||||
|
||||
def test_published_page_returns_200(self):
|
||||
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "resources"}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "pages/custom_page.html")
|
||||
self.assertEqual(response.context["custom_page"], self.page)
|
||||
|
||||
def test_unpublished_page_returns_404(self):
|
||||
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "draft"}))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_unknown_slug_returns_404(self):
|
||||
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "missing"}))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_section_item_with_url_renders_as_link(self):
|
||||
section = CustomPageSection.objects.create(
|
||||
page=self.page,
|
||||
section_type=CustomPageSection.TYPE_FEATURES,
|
||||
title="Highlights",
|
||||
is_active=True,
|
||||
)
|
||||
CustomPageSectionItem.objects.create(
|
||||
page=self.page,
|
||||
section=section,
|
||||
title="Documentation",
|
||||
content="Read the docs.",
|
||||
url="/about/",
|
||||
order=1,
|
||||
)
|
||||
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "resources"}))
|
||||
self.assertContains(response, 'href="/about/"')
|
||||
self.assertContains(response, "section-item-link")
|
||||
self.assertContains(response, "Documentation")
|
||||
|
||||
|
||||
class CustomPageNavTest(TestCase):
|
||||
def test_nav_custom_pages_in_context(self):
|
||||
CustomPage.objects.create(
|
||||
title="Visible",
|
||||
slug="visible",
|
||||
show_in_nav=True,
|
||||
menu_order=1,
|
||||
is_published=True,
|
||||
)
|
||||
CustomPage.objects.create(
|
||||
title="Hidden Nav",
|
||||
slug="hidden-nav",
|
||||
show_in_nav=False,
|
||||
is_published=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
pages = list(response.context["nav_custom_pages"])
|
||||
self.assertEqual(len(pages), 1)
|
||||
self.assertEqual(pages[0].slug, "visible")
|
||||
|
||||
def test_nav_link_rendered(self):
|
||||
CustomPage.objects.create(
|
||||
title="Team",
|
||||
slug="team",
|
||||
menu_label="Our Team",
|
||||
show_in_nav=True,
|
||||
is_published=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertContains(response, "Our Team")
|
||||
self.assertContains(response, reverse("pages:custom_page", kwargs={"slug": "team"}))
|
||||
@@ -0,0 +1,187 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.core.video import parse_youtube_video_id, youtube_embed_url
|
||||
from apps.pages.models import (
|
||||
AboutSection,
|
||||
CustomPage,
|
||||
CustomPageSection,
|
||||
HomepageSection,
|
||||
PageVideo,
|
||||
)
|
||||
from apps.products.models import MainProduct, ProductVideo
|
||||
|
||||
|
||||
class YouTubeParsingTest(TestCase):
|
||||
def test_watch_url(self):
|
||||
self.assertEqual(
|
||||
parse_youtube_video_id("https://www.youtube.com/watch?v=dQw4w9WgXcQ"),
|
||||
"dQw4w9WgXcQ",
|
||||
)
|
||||
|
||||
def test_short_url(self):
|
||||
self.assertEqual(
|
||||
parse_youtube_video_id("https://youtu.be/dQw4w9WgXcQ"),
|
||||
"dQw4w9WgXcQ",
|
||||
)
|
||||
|
||||
def test_embed_url(self):
|
||||
url = youtube_embed_url("https://youtu.be/dQw4w9WgXcQ")
|
||||
self.assertEqual(url, "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ")
|
||||
|
||||
|
||||
class HomepageVideoSectionTest(TestCase):
|
||||
def test_video_section_renders_youtube_embed(self):
|
||||
HomepageSection.objects.create(
|
||||
section_type=HomepageSection.TYPE_VIDEO,
|
||||
title="Demo",
|
||||
video_source="youtube",
|
||||
video_url="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertContains(response, "youtube-nocookie.com/embed/dQw4w9WgXcQ")
|
||||
self.assertContains(response, "video-block--md")
|
||||
|
||||
|
||||
class AboutVideoSectionTest(TestCase):
|
||||
def test_video_section_renders_on_about_page(self):
|
||||
AboutSection.objects.create(
|
||||
section_type=AboutSection.TYPE_VIDEO,
|
||||
title="Overview",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
video_size="lg",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:about"))
|
||||
self.assertContains(response, "video-block--lg")
|
||||
self.assertContains(response, "Overview")
|
||||
|
||||
|
||||
class CustomPageVideoSectionTest(TestCase):
|
||||
def test_video_section_renders_on_custom_page(self):
|
||||
page = CustomPage.objects.create(
|
||||
title="Media",
|
||||
slug="media-page",
|
||||
is_published=True,
|
||||
)
|
||||
CustomPageSection.objects.create(
|
||||
page=page,
|
||||
section_type=CustomPageSection.TYPE_VIDEO,
|
||||
title="Walkthrough",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "media-page"}))
|
||||
self.assertContains(response, "Walkthrough")
|
||||
self.assertContains(response, "iframe")
|
||||
|
||||
|
||||
class PageVideoTest(TestCase):
|
||||
def test_contact_page_video(self):
|
||||
PageVideo.objects.create(
|
||||
page=PageVideo.PAGE_CONTACT,
|
||||
title="Intro",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:contact"))
|
||||
self.assertContains(response, "Intro")
|
||||
self.assertContains(response, "youtube-nocookie.com/embed/dQw4w9WgXcQ")
|
||||
|
||||
def test_faq_page_video(self):
|
||||
PageVideo.objects.create(
|
||||
page=PageVideo.PAGE_FAQ,
|
||||
title="Tutorial",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
self.assertContains(response, "Tutorial")
|
||||
|
||||
|
||||
class ProductVideoTest(TestCase):
|
||||
def setUp(self):
|
||||
self.main_product = MainProduct.objects.create(
|
||||
name="Tecvico",
|
||||
slug="tecvico",
|
||||
short_description="Short",
|
||||
description="Long",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_product_video_renders_on_detail_page(self):
|
||||
ProductVideo.objects.create(
|
||||
main_product=self.main_product,
|
||||
title="Product Demo",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
video_size="full",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
|
||||
)
|
||||
self.assertContains(response, "Product Demo")
|
||||
self.assertContains(response, "video-block--full")
|
||||
|
||||
|
||||
class VideoStyledBackgroundTest(TestCase):
|
||||
def test_styled_background_renders_panel(self):
|
||||
HomepageSection.objects.create(
|
||||
section_type=HomepageSection.TYPE_VIDEO,
|
||||
title="Styled Demo",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
video_styled_background=True,
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertContains(response, "video-panel--styled")
|
||||
self.assertContains(response, "video-panel-blob")
|
||||
|
||||
def test_plain_background_without_panel(self):
|
||||
HomepageSection.objects.create(
|
||||
section_type=HomepageSection.TYPE_VIDEO,
|
||||
title="Plain Demo",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
video_styled_background=False,
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertNotContains(response, "video-panel--styled")
|
||||
self.assertContains(response, "Plain Demo")
|
||||
|
||||
|
||||
class VideoDescriptionFormatTest(TestCase):
|
||||
def test_plain_description_preserves_line_breaks(self):
|
||||
HomepageSection.objects.create(
|
||||
section_type=HomepageSection.TYPE_VIDEO,
|
||||
title="Demo",
|
||||
description="First line\nSecond line",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertContains(response, "First line")
|
||||
self.assertContains(response, "Second line")
|
||||
self.assertContains(response, "<br>")
|
||||
|
||||
def test_markdown_description_renders(self):
|
||||
PageVideo.objects.create(
|
||||
page=PageVideo.PAGE_FAQ,
|
||||
title="Guide",
|
||||
description="**Bold** intro",
|
||||
description_format="markdown",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
self.assertContains(response, "<strong>Bold</strong>")
|
||||
@@ -0,0 +1,125 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.pages.models import FAQEntry
|
||||
|
||||
|
||||
class HomeViewTest(TestCase):
|
||||
def test_home_returns_200(self):
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_home_uses_correct_template(self):
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertTemplateUsed(response, "pages/home.html")
|
||||
|
||||
def test_home_renders_dynamic_showcase_sections(self):
|
||||
from apps.pages.models import HomepageSection
|
||||
|
||||
projects = HomepageSection.objects.create(
|
||||
section_type=HomepageSection.TYPE_PROJECTS,
|
||||
title="Our Journey in the Realm of",
|
||||
title_highlight="Outstanding Projects",
|
||||
description="Explore our standout projects.",
|
||||
order=50,
|
||||
is_active=True,
|
||||
)
|
||||
HomepageSection.objects.create(
|
||||
section_type=HomepageSection.TYPE_EXPERIENCE,
|
||||
title="Experience Leading",
|
||||
title_highlight="the Way in Development",
|
||||
description="Embark on a journey of accelerated product development.",
|
||||
order=51,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
content = response.content.decode()
|
||||
self.assertIn("Our Journey in the Realm of", content)
|
||||
self.assertIn("Outstanding Projects", content)
|
||||
self.assertIn("Experience Leading", content)
|
||||
self.assertIn("the Way in Development", content)
|
||||
self.assertIn('data-project-filter="all"', content)
|
||||
|
||||
|
||||
class AboutViewTest(TestCase):
|
||||
def test_about_returns_200(self):
|
||||
response = self.client.get(reverse("pages:about"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_about_uses_correct_template(self):
|
||||
response = self.client.get(reverse("pages:about"))
|
||||
self.assertTemplateUsed(response, "pages/about.html")
|
||||
|
||||
|
||||
class FAQViewTest(TestCase):
|
||||
def setUp(self):
|
||||
FAQEntry.objects.create(
|
||||
question="What is the license?",
|
||||
answer="It is CC BY-NC-SA.",
|
||||
order=1,
|
||||
is_active=True,
|
||||
)
|
||||
FAQEntry.objects.create(
|
||||
question="Hidden question",
|
||||
answer="Hidden answer",
|
||||
order=2,
|
||||
is_active=False,
|
||||
)
|
||||
|
||||
def test_faq_returns_200(self):
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_faq_uses_correct_template(self):
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
self.assertTemplateUsed(response, "pages/faq.html")
|
||||
|
||||
def test_faq_only_shows_active_entries(self):
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
entries = response.context["faq_entries"]
|
||||
self.assertEqual(entries.count(), 1)
|
||||
self.assertEqual(entries.first().question, "What is the license?")
|
||||
|
||||
|
||||
class ContactViewTest(TestCase):
|
||||
def test_contact_returns_200(self):
|
||||
response = self.client.get(reverse("pages:contact"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_contact_uses_correct_template(self):
|
||||
response = self.client.get(reverse("pages:contact"))
|
||||
self.assertTemplateUsed(response, "pages/contact.html")
|
||||
|
||||
|
||||
class NavigationContextTest(TestCase):
|
||||
def test_nav_main_products_in_context_on_all_pages(self):
|
||||
urls = [
|
||||
reverse("pages:home"),
|
||||
reverse("pages:about"),
|
||||
reverse("pages:faq"),
|
||||
reverse("pages:contact"),
|
||||
reverse("products:overview"),
|
||||
]
|
||||
for url in urls:
|
||||
response = self.client.get(url)
|
||||
self.assertIn(
|
||||
"nav_main_products",
|
||||
response.context,
|
||||
f"Missing nav_main_products at {url}",
|
||||
)
|
||||
|
||||
def test_nav_custom_pages_in_context_on_all_pages(self):
|
||||
urls = [
|
||||
reverse("pages:home"),
|
||||
reverse("pages:about"),
|
||||
reverse("pages:faq"),
|
||||
reverse("pages:contact"),
|
||||
]
|
||||
for url in urls:
|
||||
response = self.client.get(url)
|
||||
self.assertIn(
|
||||
"nav_custom_pages",
|
||||
response.context,
|
||||
f"Missing nav_custom_pages at {url}",
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "pages"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.HomeView.as_view(), name="home"),
|
||||
path("about/", views.AboutView.as_view(), name="about"),
|
||||
path("faq/", views.FAQView.as_view(), name="faq"),
|
||||
path("contact/", views.ContactView.as_view(), name="contact"),
|
||||
path("<slug>/", views.CustomPageView.as_view(), name="custom_page"),
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
import random
|
||||
|
||||
from django.db.models import Prefetch
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.views import View
|
||||
from django.views.generic import DetailView, ListView, TemplateView
|
||||
|
||||
from apps.products.models import MainProduct, SubProduct
|
||||
|
||||
from .forms import ContactForm
|
||||
from .models import (
|
||||
AboutSection,
|
||||
ContactSubmission,
|
||||
ContactSubmissionAttachment,
|
||||
CustomPage,
|
||||
FAQEntry,
|
||||
HeroSection,
|
||||
HomepageSection,
|
||||
PageVideo,
|
||||
)
|
||||
|
||||
|
||||
def _homepage_products_catalog_queryset():
|
||||
return (
|
||||
MainProduct.objects.filter(is_active=True)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"sub_products",
|
||||
queryset=SubProduct.objects.filter(is_active=True).order_by("order", "name"),
|
||||
)
|
||||
)
|
||||
.order_by("order", "name")
|
||||
)
|
||||
|
||||
|
||||
class HomeView(TemplateView):
|
||||
template_name = "pages/home.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["hero"] = HeroSection.objects.first()
|
||||
ctx["homepage_sections"] = (
|
||||
HomepageSection.objects.filter(is_active=True)
|
||||
.prefetch_related("items")
|
||||
.order_by("order")
|
||||
)
|
||||
ctx["homepage_products"] = (
|
||||
MainProduct.objects.filter(is_active=True, show_on_homepage=True)
|
||||
.order_by("homepage_order", "order")
|
||||
)
|
||||
ctx["homepage_products_catalog"] = _homepage_products_catalog_queryset()
|
||||
return ctx
|
||||
|
||||
|
||||
class AboutView(TemplateView):
|
||||
template_name = "pages/about.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["about_sections"] = (
|
||||
AboutSection.objects.filter(is_active=True)
|
||||
.prefetch_related("items")
|
||||
.order_by("order")
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
class FAQView(ListView):
|
||||
model = FAQEntry
|
||||
template_name = "pages/faq.html"
|
||||
context_object_name = "faq_entries"
|
||||
queryset = FAQEntry.objects.filter(is_active=True)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["faq_videos"] = PageVideo.objects.filter(
|
||||
page=PageVideo.PAGE_FAQ,
|
||||
is_active=True,
|
||||
).order_by("order")
|
||||
return ctx
|
||||
|
||||
|
||||
class ContactView(View):
|
||||
template_name = "pages/contact.html"
|
||||
|
||||
def _new_captcha(self, request):
|
||||
a, b = random.randint(1, 9), random.randint(1, 9)
|
||||
request.session["captcha_answer"] = a + b
|
||||
return f"{a} + {b}"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"form": ContactForm(),
|
||||
"captcha_question": self._new_captcha(request),
|
||||
"contact_videos": PageVideo.objects.filter(
|
||||
page=PageVideo.PAGE_CONTACT,
|
||||
is_active=True,
|
||||
).order_by("order"),
|
||||
},
|
||||
)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
form = ContactForm(
|
||||
request.POST,
|
||||
file_list=request.FILES.getlist("attachments"),
|
||||
)
|
||||
expected = request.session.get("captcha_answer")
|
||||
captcha_question = self._new_captcha(request)
|
||||
|
||||
captcha_ok = False
|
||||
try:
|
||||
captcha_ok = int(request.POST.get("captcha_answer", "")) == expected
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if form.is_valid() and captcha_ok:
|
||||
submission = form.save()
|
||||
for uploaded_file, original_name in form.cleaned_data.get(
|
||||
"attachments", []
|
||||
):
|
||||
attachment = ContactSubmissionAttachment(
|
||||
submission=submission,
|
||||
original_filename=original_name,
|
||||
)
|
||||
attachment.file.save(original_name, uploaded_file, save=True)
|
||||
return JsonResponse({"success": True})
|
||||
|
||||
errors: dict = {}
|
||||
if not captcha_ok:
|
||||
errors["captcha"] = ["Incorrect answer — please try again."]
|
||||
errors.update(
|
||||
{field: [str(e) for e in errs] for field, errs in form.errors.items()}
|
||||
)
|
||||
return JsonResponse(
|
||||
{"success": False, "errors": errors, "captcha_question": captcha_question},
|
||||
status=400,
|
||||
)
|
||||
|
||||
|
||||
class CustomPageView(DetailView):
|
||||
model = CustomPage
|
||||
template_name = "pages/custom_page.html"
|
||||
context_object_name = "custom_page"
|
||||
slug_url_kwarg = "slug"
|
||||
|
||||
def get_queryset(self):
|
||||
return CustomPage.objects.filter(is_published=True)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["page_sections"] = (
|
||||
self.object.sections.filter(is_active=True)
|
||||
.prefetch_related("items")
|
||||
.order_by("order")
|
||||
)
|
||||
ctx["homepage_products"] = (
|
||||
MainProduct.objects.filter(is_active=True, show_on_homepage=True)
|
||||
.order_by("homepage_order", "order")
|
||||
)
|
||||
ctx["homepage_products_catalog"] = _homepage_products_catalog_queryset()
|
||||
return ctx
|
||||
Reference in New Issue
Block a user