initial commit

This commit is contained in:
mohamad
2026-06-21 17:54:19 +03:30
commit 20d6df3b27
191 changed files with 14362 additions and 0 deletions
View File
+330
View File
@@ -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")}),
)
+7
View File
@@ -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"
+188
View File
@@ -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
+41
View File
@@ -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
+50
View File
@@ -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/'),
),
]
+141
View File
@@ -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/'),
),
]
View File
+479
View File
@@ -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)'}"
View File
+158
View File
@@ -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)
+94
View File
@@ -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"}))
+187
View File
@@ -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>")
+125
View File
@@ -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}",
)
+13
View File
@@ -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"),
]
+165
View File
@@ -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