from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils import timezone from django.utils.text import slugify from apps.core.people import person_sort_key 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", "blog", "faq", "home", "projects", "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. 'Communication to Care,').") 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…').") read_more_subtitle = models.BooleanField(default=False, help_text="Collapse the subtitle with Read more when it is long.") description = models.TextField(blank=True, help_text="Longer paragraph below the subtitle.") read_more_description = models.BooleanField(default=False, help_text="Collapse this description with Read more when it is long.") 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_ABOUT_STRIP = "about_strip" TYPE_SUPPORTERS = "supporters" TYPE_VIDEO = "video" TYPE_CHOICES = [ (TYPE_FEATURES, "Features"), (TYPE_SCREENSHOTS, "Screenshots Gallery"), (TYPE_PRODUCTS, "Projects"), (TYPE_PRODUCTS_CATALOG, "Projects with sub-projects"), (TYPE_PROBLEMS, "Problems / Value Proposition"), (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) description = models.TextField(blank=True) read_more_description = models.BooleanField(default=False, help_text="Collapse this description with a Read more button.") 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): 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) read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.") 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="Logo or image (used for Supporters cards).") 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)'}" class AboutSection(VideoBlockMixin, models.Model): TYPE_HERO = "hero" TYPE_INTRO = "intro" TYPE_GRID = "grid" TYPE_HISTORY = "history" TYPE_CUSTOM = "custom" TYPE_SUPPORTERS = "supporters" TYPE_PEOPLE = "people" 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_PEOPLE, "People / Organizers & Collaborators"), (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.") read_more_subtitle = models.BooleanField(default=False, help_text="Collapse a long hero subtitle with Read more.") content = models.TextField(blank=True) read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.") 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) @property def ordered_people_items(self): return sorted(self.items.all(), key=person_sort_key) 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) role = models.CharField( "Role / group", max_length=100, blank=True, help_text="For People sections: Lead, Leader, Organizer, Collaborator, or any custom role name.", ) content = models.TextField(blank=True, help_text="Description text or link label for History links.") read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.") 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)'}" @property def name(self): return self.title @property def role_label(self): if self.role.casefold() in {"lead", "leader", "organizer", "collaborator"}: return self.role.title() return self.role 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 BlogPost(models.Model): STATUS_DRAFT = "draft" STATUS_PUBLISHED = "published" STATUS_CHOICES = [ (STATUS_DRAFT, "Draft"), (STATUS_PUBLISHED, "Published"), ] title = models.CharField(max_length=240) slug = models.SlugField(max_length=240, unique=True, blank=True) excerpt = models.CharField( max_length=400, blank=True, help_text="Short summary shown on the blog listing and in search previews.", ) read_more_excerpt = models.BooleanField(default=False, help_text="Collapse a long excerpt with Read more on the listing and article page.") content = models.TextField() read_more_content = models.BooleanField(default=False, help_text="Collapse article text with a Read more button.") content_format = models.CharField( max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_MARKDOWN, ) cover_image = models.ImageField(upload_to="blog/", blank=True, null=True) cover_image_alt = models.CharField(max_length=240, blank=True) author_name = models.CharField(max_length=120, blank=True) status = models.CharField( max_length=20, choices=STATUS_CHOICES, default=STATUS_DRAFT ) published_at = models.DateTimeField(default=timezone.now) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-published_at", "-pk"] verbose_name = "Blog post" verbose_name_plural = "Blog posts" def __str__(self): return self.title @property def rendered_content(self): return render_content(self.content, self.content_format) @property def is_public(self): return self.status == self.STATUS_PUBLISHED and self.published_at <= timezone.now() def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.title) super().save(*args, **kwargs) def get_absolute_url(self): return reverse("pages:blog_detail", kwargs={"slug": self.slug}) class BlogPostPartner(models.Model): ORGANIZER = "organizer" COLLABORATOR = "collaborator" post = models.ForeignKey(BlogPost, on_delete=models.CASCADE, related_name="partners") role = models.CharField( "Role / group", max_length=100, default="Collaborator", help_text="Type Lead, Leader, Organizer, Collaborator, or any other role name.", ) name = models.CharField(max_length=200) description = models.TextField(blank=True) read_more_description = models.BooleanField(default=False, help_text="Collapse this person's description with Read more when it is long.") image = models.ImageField(upload_to="blog/partners/", blank=True, null=True) image_alt = models.CharField(max_length=240, blank=True) url = models.URLField(blank=True) order = models.PositiveIntegerField( "Display order", default=0, help_text="Smaller numbers appear first within each role. Leads/Leaders, Organizers, Collaborators, then custom roles.", ) class Meta: ordering = [ models.Case( models.When(role__iexact="lead", then=models.Value(0)), models.When(role__iexact="leader", then=models.Value(0)), models.When(role__iexact="organizer", then=models.Value(1)), models.When(role__iexact="collaborator", then=models.Value(2)), default=models.Value(3), output_field=models.IntegerField(), ), "order", "pk", ] verbose_name = "Article person" verbose_name_plural = "Article people" def __str__(self): return f"{self.name} ({self.role_label})" @property def role_label(self): if self.role.casefold() in {"lead", "leader", self.ORGANIZER, self.COLLABORATOR}: return self.role.title() return self.role 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_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, "Projects Grid"), (TYPE_PRODUCTS_CATALOG, "Projects with sub-projects"), (TYPE_PROBLEMS, "Problems / Value Proposition"), (TYPE_SUPPORTERS, "Supporters"), (TYPE_ABOUT_STRIP, "About Strip"), (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) read_more_subtitle = models.BooleanField(default=False, help_text="Collapse a long hero subtitle with Read more.") description = models.TextField(blank=True, help_text="Short intro text (homepage-style sections).") read_more_description = models.BooleanField(default=False, help_text="Collapse this description with a Read more button.") content = models.TextField(blank=True) read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.") 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_CHOICES = [ (PAGE_CONTACT, "Become a Member"), ] 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) read_more_description = models.BooleanField(default=False, help_text="Collapse this video description with Read more.") 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) read_more_content = models.BooleanField(default=False, help_text="Collapse this text with a Read more button.") 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)'}" class PeopleMap(models.Model): show_people_details = models.BooleanField( "Show people details below the map", default=True, help_text="Disable to display only the map, without the people information cards underneath.", ) title = models.CharField(max_length=200) description = models.TextField(blank=True) style = models.CharField(max_length=20, default="aurora", choices=[ ("aurora", "Aurora — dark ocean"), ("paper", "Atlas — light"), ("classic", "Classic — original blue / turquoise palette"), ]) BACKGROUND_CHOICES = [ ("classic", "Classic continents — blue silhouette"), ("atlas", "World atlas — country borders and coordinate grid"), ("midnight", "Midnight — dotted continents"), ("ocean", "Ocean — luminous continents and meridians"), ] background_style = models.CharField(max_length=20, choices=BACKGROUND_CHOICES, default="classic", help_text="Built-in artwork shipped with the application, also available on the server. An uploaded image overrides this choice.") @property def background_static_path(self): return { "classic": "images/world-map.svg", "atlas": "images/maps/world-atlas.svg", "midnight": "images/maps/world-midnight.svg", "ocean": "images/maps/world-ocean.svg", }.get(self.background_style, "images/world-map.svg") background_image = models.ImageField(upload_to="maps/", blank=True, help_text="Optional original map artwork. Must use an equirectangular world projection.") is_active = models.BooleanField(default=True) def __str__(self): return self.title class MapPerson(models.Model): name = models.CharField(max_length=200) qualifications = models.CharField(max_length=100, blank=True) role = models.CharField(max_length=200, blank=True) institution = models.CharField(max_length=300, blank=True) biography = models.TextField(blank=True) photo = models.ImageField(upload_to="maps/people/", blank=True) profile_url = models.URLField(blank=True) class Meta: ordering = ["name", "pk"] verbose_name_plural = "Map people" def __str__(self): return self.name class MapMembership(models.Model): people_map = models.ForeignKey(PeopleMap, on_delete=models.CASCADE, related_name="memberships") person = models.ForeignKey(MapPerson, on_delete=models.CASCADE, related_name="map_memberships") location_method = models.CharField(max_length=12, default="city", choices=[("city", "Country and city"), ("coordinates", "Coordinates")]) country = models.CharField(max_length=100, blank=True) city = models.CharField(max_length=100, blank=True) latitude = models.FloatField(blank=True, null=True) longitude = models.FloatField(blank=True, null=True) order = models.PositiveIntegerField(default=0) class Meta: ordering = ["order", "pk"] constraints = [models.UniqueConstraint(fields=["people_map", "person"], name="unique_map_person")] def clean(self): super().clean() if self.location_method == "city": from .maps import resolve_city if not self.city or not self.country: raise ValidationError('Country and city are required for city locations.') self.latitude, self.longitude = resolve_city(self.country, self.city) import math errors = {} for field, limit in (("latitude", 90), ("longitude", 180)): value = getattr(self, field) if value is None or not math.isfinite(value) or not -limit <= value <= limit: errors[field] = f'Enter a coordinate between {-limit} and {limit}.' if errors: raise ValidationError(errors) def save(self, *args, **kwargs): self.full_clean() return super().save(*args, **kwargs) class MapPlacement(models.Model): people_map = models.ForeignKey(PeopleMap, on_delete=models.CASCADE, related_name="placements") page_path = models.CharField(max_length=400, help_text="Local page path, e.g. /about/, /projects/project-slug/, or any custom page. No domain or query string.") position = models.CharField(max_length=10, choices=[("top", "Before page content"), ("bottom", "After page content")], default="bottom") order = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) class Meta: ordering = ["order", "pk"] constraints = [models.UniqueConstraint(fields=["people_map", "page_path", "position"], name="unique_map_placement")] def clean(self): super().clean() if not self.page_path.startswith('/') or self.page_path.startswith('//') or any(c in self.page_path for c in ('?', '#', '://')): raise ValidationError({'page_path': 'Use a local path beginning with /, without a domain, query or fragment.'}) def __str__(self): return f'{self.people_map} → {self.page_path}'