initial commit
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
from django.contrib import admin
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
|
||||
from .models import SiteBranding, SiteContact
|
||||
|
||||
|
||||
@admin.register(SiteBranding)
|
||||
class SiteBrandingAdmin(admin.ModelAdmin):
|
||||
fieldsets = (
|
||||
("Icon", {"fields": ("icon", "icon_alt")}),
|
||||
(
|
||||
"Sizes",
|
||||
{
|
||||
"fields": ("navbar_icon_size", "footer_icon_size"),
|
||||
"description": "Square dimensions in pixels for each placement.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Appearance",
|
||||
{
|
||||
"fields": (
|
||||
"object_fit",
|
||||
"show_border",
|
||||
"border_width",
|
||||
"border_color",
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return not SiteBranding.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
branding = SiteBranding.objects.first()
|
||||
if branding:
|
||||
return HttpResponseRedirect(
|
||||
reverse("admin:core_sitebranding_change", args=[branding.pk])
|
||||
)
|
||||
return super().changelist_view(request, extra_context)
|
||||
|
||||
|
||||
@admin.register(SiteContact)
|
||||
class SiteContactAdmin(admin.ModelAdmin):
|
||||
fieldsets = (
|
||||
(
|
||||
"Email",
|
||||
{
|
||||
"fields": (
|
||||
"support_email",
|
||||
"email_card_title",
|
||||
"email_card_description",
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Discord",
|
||||
{
|
||||
"fields": (
|
||||
"discord_url",
|
||||
"discord_label",
|
||||
"discord_card_title",
|
||||
"discord_card_description",
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Office address",
|
||||
{
|
||||
"fields": ("office_address", "office_card_title"),
|
||||
"description": "Enter one address line per row.",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return not SiteContact.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
contact = SiteContact.objects.first()
|
||||
if contact:
|
||||
return HttpResponseRedirect(
|
||||
reverse("admin:core_sitecontact_change", args=[contact.pk])
|
||||
)
|
||||
return super().changelist_view(request, extra_context)
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.utils.html import format_html
|
||||
|
||||
from apps.core.video import VIDEO_SOURCE_UPLOAD, VIDEO_SOURCE_YOUTUBE
|
||||
|
||||
|
||||
def video_admin_preview(obj):
|
||||
if not obj.has_video:
|
||||
return "No video configured yet."
|
||||
if obj.video_source == VIDEO_SOURCE_UPLOAD and obj.video_file:
|
||||
if obj.video_poster:
|
||||
return format_html(
|
||||
'<video controls playsinline preload="metadata" style="max-width:100%;" poster="{}">'
|
||||
'<source src="{}"></video>',
|
||||
obj.video_poster.url,
|
||||
obj.video_file.url,
|
||||
)
|
||||
return format_html(
|
||||
'<video controls playsinline preload="metadata" style="max-width:100%;">'
|
||||
'<source src="{}"></video>',
|
||||
obj.video_file.url,
|
||||
)
|
||||
if obj.video_source == VIDEO_SOURCE_YOUTUBE and obj.youtube_embed_url:
|
||||
return format_html(
|
||||
'<iframe src="{}" title="Preview" width="480" height="270" '
|
||||
'style="max-width:100%;border:0;" allowfullscreen loading="lazy"></iframe>',
|
||||
obj.youtube_embed_url,
|
||||
)
|
||||
return "No video configured yet."
|
||||
|
||||
video_admin_preview.short_description = "Preview"
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.core"
|
||||
verbose_name = "Core"
|
||||
@@ -0,0 +1,41 @@
|
||||
from django.db.models import Prefetch
|
||||
|
||||
from apps.core.models import SiteBranding, SiteContact
|
||||
from apps.pages.models import CustomPage
|
||||
from apps.products.models import MainProduct, SubProduct
|
||||
|
||||
|
||||
def site_branding(request):
|
||||
return {"site_branding": SiteBranding.load()}
|
||||
|
||||
|
||||
def site_contact(request):
|
||||
return {"site_contact": SiteContact.load()}
|
||||
|
||||
|
||||
def navigation(request):
|
||||
main_products = (
|
||||
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")
|
||||
)
|
||||
all_footer_products = list(main_products[:5])
|
||||
footer_main_products = all_footer_products[:4]
|
||||
footer_main_products_has_more = len(all_footer_products) == 5
|
||||
nav_custom_pages = CustomPage.objects.filter(
|
||||
is_published=True,
|
||||
show_in_nav=True,
|
||||
).order_by("menu_order", "title")
|
||||
return {
|
||||
"nav_main_products": main_products,
|
||||
"nav_custom_pages": nav_custom_pages,
|
||||
"footer_main_products": footer_main_products,
|
||||
"footer_main_products_has_more": footer_main_products_has_more,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import os
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create a superuser from environment variables if one does not already exist."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
User = get_user_model()
|
||||
|
||||
username = os.environ.get("DJANGO_SUPERUSER_USERNAME", "admin")
|
||||
email = os.environ.get("DJANGO_SUPERUSER_EMAIL", "admin@tecvico.com")
|
||||
password = os.environ.get("DJANGO_SUPERUSER_PASSWORD")
|
||||
|
||||
if not password:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"DJANGO_SUPERUSER_PASSWORD is not set — skipping superuser creation."
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if User.objects.filter(username=username).exists():
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Superuser '{username}' already exists — skipping.")
|
||||
)
|
||||
return
|
||||
|
||||
User.objects.create_superuser(username=username, email=email, password=password)
|
||||
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' created successfully."))
|
||||
@@ -0,0 +1,732 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files import File
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from apps.core.models import SiteBranding, SiteContact
|
||||
from apps.pages.models import DownloadItem, FAQEntry, HeroSection, HomepageSection, HomepageSectionItem
|
||||
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
|
||||
|
||||
MAIN_PRODUCTS = [
|
||||
{
|
||||
"name": "Tecvico",
|
||||
"slug": "tecvico",
|
||||
"short_description": "Visualized & Standardized Environment for Radiomics Analysis",
|
||||
"description": (
|
||||
"Tecvico is a free, open-source software specialized for visualization, "
|
||||
"processing, segmentation, registration, fusion and analysis of medical and "
|
||||
"biomedical images, including radiomics and machine learning analysis. "
|
||||
"Tecvico is a major, entirely-revamped upgrade to the original SERA "
|
||||
"(Matlab-based), now built on Python for broader accessibility and community "
|
||||
"contribution. It enables standardized and reproducible radiomic feature "
|
||||
"extraction in compliance with the Image Biomarker Standardization Initiative "
|
||||
"(IBSI 1.0), and implements image filters standardized against IBSI 2.0."
|
||||
),
|
||||
"order": 1,
|
||||
"show_on_homepage": True,
|
||||
"homepage_order": 1,
|
||||
"sub_products": [
|
||||
{
|
||||
"name": "Image Processing",
|
||||
"slug": "image-processing",
|
||||
"short_description": "Standardized filtering, registration, and fusion techniques",
|
||||
"description": (
|
||||
"Advanced image processing capabilities including standardized filtering "
|
||||
"techniques compliant with IBSI 2.0, image registration, fusion, and "
|
||||
"Standardized Uptake Value (SUV) conversion. Tecvico employs popular "
|
||||
"image processing algorithms to create end-to-end standardized workflows "
|
||||
"for consistent, reproducible research outcomes."
|
||||
),
|
||||
"order": 1,
|
||||
"articles": [
|
||||
{
|
||||
"title": "Image Filtering Techniques",
|
||||
"description": (
|
||||
"Tecvico implements a comprehensive set of image filtering techniques "
|
||||
"fully standardized against the Image Biomarker Standardization "
|
||||
"Initiative (IBSI) phase 2. These filters enable reproducible "
|
||||
"preprocessing across institutions and studies."
|
||||
),
|
||||
"order": 1,
|
||||
"sections": [
|
||||
{"title": "Standardization", "value": "IBSI 2.0 compliant", "order": 1},
|
||||
{
|
||||
"title": "Available Filters",
|
||||
"value": "Mean, Gaussian, Laplacian of Gaussian (LoG), Laws kernels, Gabor, Wavelets (PyWavelets), Log-Sigma",
|
||||
"order": 2,
|
||||
},
|
||||
{"title": "Author", "value": "Tecvico R&D Team", "order": 3},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Image Registration & Fusion",
|
||||
"description": (
|
||||
"Tecvico provides robust image registration and fusion methods, "
|
||||
"enabling multi-modal image alignment for PET/CT, PET/MRI, and "
|
||||
"other combined modality studies. Standardized Uptake Value (SUV) "
|
||||
"conversion is also supported."
|
||||
),
|
||||
"order": 2,
|
||||
"sections": [
|
||||
{"title": "Registration Methods", "value": "Rigid, Affine, Deformable (B-spline)", "order": 1},
|
||||
{"title": "Fusion Techniques", "value": "Overlay, weighted average, multi-modal blending", "order": 2},
|
||||
{"title": "Special Feature", "value": "Standardized Uptake Value (SUV) conversion", "order": 3},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Radiomics Features",
|
||||
"slug": "radiomics-features",
|
||||
"short_description": "IBSI 1.0 compliant handcrafted radiomic feature extraction",
|
||||
"description": (
|
||||
"Tecvico provides comprehensive handcrafted radiomic feature extraction "
|
||||
"fully standardized by the Image Biomarker Standardization Initiative "
|
||||
"(IBSI 1.0). Features are computed from segmented regions of interest "
|
||||
"across multiple image modalities, enabling reproducible quantitative "
|
||||
"imaging biomarker research."
|
||||
),
|
||||
"order": 2,
|
||||
"articles": [
|
||||
{
|
||||
"title": "IBSI Compliant Feature Extraction",
|
||||
"description": (
|
||||
"Tecvico computes a comprehensive set of radiomic features "
|
||||
"covering all IBSI 1.0 feature classes. Features are extracted "
|
||||
"from segmented Regions of Interest (ROIs) and are fully "
|
||||
"reproducible across different platforms and institutions."
|
||||
),
|
||||
"order": 1,
|
||||
"sections": [
|
||||
{"title": "Standardization", "value": "IBSI 1.0 compliant", "order": 1},
|
||||
{
|
||||
"title": "Feature Classes",
|
||||
"value": "Shape (3D & 2D), First-order Statistics, GLCM, GLRLM, GLSZM, GLDM, NGTDM",
|
||||
"order": 2,
|
||||
},
|
||||
{"title": "Output Formats", "value": "CSV, JSON, Excel", "order": 3},
|
||||
{"title": "Reference", "value": "Zwanenburg et al. (2020), Radiology", "order": 4},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Medical Image Visualization",
|
||||
"slug": "medical-image-visualization",
|
||||
"short_description": "Professional multi-modality medical image viewer",
|
||||
"description": (
|
||||
"Tecvico includes a professional medical image viewer that supports "
|
||||
"multiple imaging modalities and file formats. The viewer provides "
|
||||
"comfortable, intuitive controls for slice navigation, windowing, "
|
||||
"zoom, and annotation, suitable for radiation oncologists, radiologists, "
|
||||
"physicists, and data scientists."
|
||||
),
|
||||
"order": 3,
|
||||
"articles": [
|
||||
{
|
||||
"title": "Multi-Modal Image Viewer",
|
||||
"description": (
|
||||
"The integrated viewer supports simultaneous display of multiple "
|
||||
"image modalities with linked cursors, adjustable window/level, "
|
||||
"and overlay capabilities. RT struct contours are rendered "
|
||||
"directly over the underlying images."
|
||||
),
|
||||
"order": 1,
|
||||
"sections": [
|
||||
{"title": "Supported Modalities", "value": "CT, MRI, PET, SPECT, CBCT", "order": 1},
|
||||
{"title": "File Formats", "value": "DICOM, NIFTI (.nii, .nii.gz), NRRD, MHA, NII", "order": 2},
|
||||
{"title": "Special Support", "value": "RT Struct, RT Dose, RT Plan visualization", "order": 3},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Format Conversion",
|
||||
"slug": "format-conversion",
|
||||
"short_description": "Professional converter for medical imaging file formats",
|
||||
"description": (
|
||||
"Tecvico provides a professional image format converter supporting all "
|
||||
"major medical imaging standards. Seamlessly convert between DICOM, "
|
||||
"NIFTI, NRRD, MHA, and other formats without loss of spatial metadata "
|
||||
"or patient information integrity."
|
||||
),
|
||||
"order": 4,
|
||||
"articles": [
|
||||
{
|
||||
"title": "Medical Image Format Converter",
|
||||
"description": (
|
||||
"The built-in converter handles complex DICOM series reconstruction, "
|
||||
"preserving spatial orientation, voxel spacing, and relevant metadata "
|
||||
"throughout conversion. Batch conversion is supported for large "
|
||||
"research datasets."
|
||||
),
|
||||
"order": 1,
|
||||
"sections": [
|
||||
{"title": "Input Formats", "value": "DICOM (all SOP classes), NIFTI, NRRD, NII, MHA, MetaImage", "order": 1},
|
||||
{"title": "Output Formats", "value": "NIFTI (.nii.gz), NRRD, MHA, NII", "order": 2},
|
||||
{"title": "Batch Processing", "value": "Supported — process entire datasets automatically", "order": 3},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Workflow Management",
|
||||
"slug": "workflow-management",
|
||||
"short_description": "Reproducible research workflow creation and sharing",
|
||||
"description": (
|
||||
"Tecvico's workflow management system allows researchers to design, save, "
|
||||
"share, and reuse analysis pipelines. Workflows connect individual "
|
||||
"processing steps — from image loading and preprocessing to feature "
|
||||
"extraction and machine learning — into reproducible, shareable sequences "
|
||||
"that ensure consistency across studies and institutions."
|
||||
),
|
||||
"order": 5,
|
||||
"articles": [
|
||||
{
|
||||
"title": "Reproducible Research Workflows",
|
||||
"description": (
|
||||
"Create end-to-end analysis pipelines by visually connecting "
|
||||
"processing nodes. Each workflow can be exported, shared with "
|
||||
"collaborators, and re-executed to reproduce results on new datasets."
|
||||
),
|
||||
"order": 1,
|
||||
"sections": [
|
||||
{"title": "Key Benefit", "value": "Usability, Reusability and Reproducibility (URR)", "order": 1},
|
||||
{"title": "Collaboration", "value": "Share workflows, datasets, and results with research teams", "order": 2},
|
||||
{"title": "Compatibility", "value": "Works with all supported image modalities and feature extractors", "order": 3},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
FAQ_ENTRIES = [
|
||||
{
|
||||
"question": "What is the Tecvico license?",
|
||||
"answer": (
|
||||
"Tecvico is free and open-source for research purposes.\n\n"
|
||||
"License: CC BY-NC-SA (Creative Commons Attribution-NonCommercial-ShareAlike). "
|
||||
"This means you may use, share, and adapt the software for non-commercial "
|
||||
"research purposes, provided you give appropriate credit and distribute "
|
||||
"derivatives under the same license."
|
||||
),
|
||||
"order": 1,
|
||||
},
|
||||
{
|
||||
"question": "How do I cite Tecvico in my research?",
|
||||
"answer": (
|
||||
"Please cite the following reference if you publish results obtained with "
|
||||
"the help of Tecvico:\n\n"
|
||||
"M. R. Salmanpour, I. Shiri, M. Hosseinzadeh, H. Zaidi, S. Ashrafinia, "
|
||||
"M. Oveisi, A. Rahmim. Tecvico: Visualized & Standardized Environment for "
|
||||
"Radiomics Analysis — A Shareable, Executable, and Reproducible Workflow "
|
||||
"Generator. Proc. IEEE Medical Imaging Conference, 2023."
|
||||
),
|
||||
"order": 2,
|
||||
},
|
||||
{
|
||||
"question": "Which operating systems does Tecvico support?",
|
||||
"answer": (
|
||||
"Tecvico currently fully supports Windows 10 and above (64-bit). "
|
||||
"New versions to support macOS and Linux systems are under active development "
|
||||
"and coming soon. Follow our Discord or check each product module page for updates."
|
||||
),
|
||||
"order": 3,
|
||||
},
|
||||
{
|
||||
"question": "Can I install a new version over an existing installation?",
|
||||
"answer": (
|
||||
"Yes, you can install the new version without removing the previous one. "
|
||||
"However, if you encounter any problems after upgrading, we recommend "
|
||||
"uninstalling the old version first, then performing a clean installation "
|
||||
"of the new release."
|
||||
),
|
||||
"order": 4,
|
||||
},
|
||||
{
|
||||
"question": "Is Tecvico suitable for clinical use?",
|
||||
"answer": (
|
||||
"Tecvico is designed and intended exclusively for research purposes. "
|
||||
"It is not certified for clinical diagnostic use. Always consult with "
|
||||
"qualified medical professionals for clinical decisions."
|
||||
),
|
||||
"order": 5,
|
||||
},
|
||||
{
|
||||
"question": "Where can I get support or report issues?",
|
||||
"answer": (
|
||||
"Support is available via email and through our community Discord server "
|
||||
"(see the Contact page for current details). For bug reports and feature "
|
||||
"requests, please use the Discord forum or contact us directly by email."
|
||||
),
|
||||
"order": 6,
|
||||
},
|
||||
]
|
||||
|
||||
HOMEPAGE_SECTIONS = [
|
||||
{
|
||||
"section_type": HomepageSection.TYPE_FEATURES,
|
||||
"badge": "Capabilities",
|
||||
"title": "Important Features",
|
||||
"description": "Comprehensive tools for medical imaging research, standardized and reproducible.",
|
||||
"order": 1,
|
||||
"items": [
|
||||
{"icon": "⚗️", "title": "Image Filtering", "content": "Standardized image filtering techniques compliant with IBSI 2.0 guidelines.", "order": 1},
|
||||
{"icon": "🖥️", "title": "Professional Viewer", "content": "Comfortable, professional medical image viewer with multi-modality support.", "order": 2},
|
||||
{"icon": "📊", "title": "Radiomics Features", "content": "Handcrafted radiomics feature generation standardized by IBSI 1.0.", "order": 3},
|
||||
{"icon": "🔄", "title": "Format Support", "content": "NIFTI, DICOM, NRRD, and more — comprehensive multi-format support.", "order": 4},
|
||||
{"icon": "🗂️", "title": "Image Registration", "content": "Advanced image registration, fusion, and standardized SUV conversion.", "order": 5},
|
||||
{"icon": "🔬", "title": "RT Struct Support", "content": "Full RT struct support for radiation oncology workflows and research.", "order": 6},
|
||||
],
|
||||
},
|
||||
{
|
||||
"section_type": HomepageSection.TYPE_SCREENSHOTS,
|
||||
"badge": "Gallery",
|
||||
"title": "See Tecvico in Action",
|
||||
"description": "Explore Tecvico's powerful interface, workflow builder, and multi-modal image viewer.",
|
||||
"order": 2,
|
||||
"items": [],
|
||||
},
|
||||
{
|
||||
"section_type": HomepageSection.TYPE_PRODUCTS,
|
||||
"badge": "Our Software",
|
||||
"title": "Products",
|
||||
"description": "Explore our suite of medical imaging and radiomics tools.",
|
||||
"order": 3,
|
||||
"items": [],
|
||||
},
|
||||
{
|
||||
"section_type": HomepageSection.TYPE_PROBLEMS,
|
||||
"badge": "Value Proposition",
|
||||
"title": "What Problems Does Tecvico Solve?",
|
||||
"description": "",
|
||||
"order": 4,
|
||||
"items": [
|
||||
{"icon": "01", "title": "Accessibility", "content": "Tecvico provides a user-friendly interface and a wide range of tools, allowing researchers to perform complex data analysis without extensive technical knowledge or programming expertise.", "order": 1},
|
||||
{"icon": "02", "title": "Integrated Tools", "content": "Tecvico integrates a vast collection of tools and resources from various domains of healthcare and medical imaging research in a common, unified environment.", "order": 2},
|
||||
{"icon": "03", "title": "Flexibility", "content": "Tecvico offers flexibility in terms of tool optimization and workflow customization to match your specific research requirements.", "order": 3},
|
||||
{"icon": "04", "title": "Reproducibility", "content": "Improve usability, reusability, and reproducibility (URR) through a workflow management system that allows researchers to easily create, share, and reuse analysis pipelines.", "order": 4},
|
||||
],
|
||||
},
|
||||
{
|
||||
"section_type": HomepageSection.TYPE_PROJECTS,
|
||||
"title": "Our Journey in the Realm of",
|
||||
"title_highlight": "Outstanding Projects",
|
||||
"description": (
|
||||
"Explore our standout projects here and immerse yourself in our journey "
|
||||
"through the world of innovation and development."
|
||||
),
|
||||
"order": 5,
|
||||
"items": [
|
||||
{
|
||||
"title": "Revolutionizing Radiomics Analysis and Medical Image Visualization",
|
||||
"content": (
|
||||
"Visera is a free, open-source software specialized for visualization, processing, "
|
||||
"segmentation, registration, fusion and analysis of medical / biomedical images, "
|
||||
"including radiomics and machine learning analysis."
|
||||
),
|
||||
"tags": "Web Development, Publication",
|
||||
"project_status": HomepageSectionItem.STATUS_NEW,
|
||||
"url": "https://visera.ca/",
|
||||
"image_path": "projects/research2.png",
|
||||
"image_alt": "Visera medical imaging software",
|
||||
"order": 1,
|
||||
},
|
||||
{
|
||||
"title": "Automatic Segmentation of Head and Neck Cancer using Fusion ...",
|
||||
"content": (
|
||||
"People with the below expertise are able to apply for this project: "
|
||||
"1-The individual with enough experience ..."
|
||||
),
|
||||
"tags": "Web Development, Publication",
|
||||
"project_status": HomepageSectionItem.STATUS_ONGOING,
|
||||
"image_path": "projects/project-frame-2.png",
|
||||
"order": 2,
|
||||
},
|
||||
{
|
||||
"title": "Predicting TNM Stage in Head and Neck Cancer using Multi-Modality Fusion ...",
|
||||
"content": "People with the below expertise are able to apply for this project.",
|
||||
"tags": "Web Development, Publication",
|
||||
"project_status": HomepageSectionItem.STATUS_DONE,
|
||||
"image_path": "projects/project-frame-3.png",
|
||||
"order": 3,
|
||||
},
|
||||
{
|
||||
"title": "Application of Deep Learning Techniques Coupled with fusion Models for ...",
|
||||
"content": (
|
||||
"People with the below expertise are able to apply for this project: "
|
||||
"1-The individual with enough experience ..."
|
||||
),
|
||||
"tags": "Web Development, Publication",
|
||||
"project_status": HomepageSectionItem.STATUS_NEW,
|
||||
"image_path": "projects/project-frame-4.png",
|
||||
"order": 4,
|
||||
},
|
||||
{
|
||||
"title": "Identifying Reliable and Robust Tensor Radiomics Features in Lung Cancer",
|
||||
"content": (
|
||||
"Radiomics is a major frontier in medical image analysis, enabling the mining "
|
||||
"of high-dimensional data from ..."
|
||||
),
|
||||
"tags": "Web Development, Publication",
|
||||
"project_status": HomepageSectionItem.STATUS_ONGOING,
|
||||
"image_path": "projects/project-frame-5.png",
|
||||
"order": 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"section_type": HomepageSection.TYPE_EXPERIENCE,
|
||||
"title": "Experience Leading",
|
||||
"title_highlight": "the Way in Development",
|
||||
"description": (
|
||||
"Embark on a journey of accelerated product development, prioritizing stability, "
|
||||
"security, and flexible technology choices.\n\n"
|
||||
"Our commitment to crafting a distinctive user experience ensures your product "
|
||||
"stands out with innovative design and seamless functionality."
|
||||
),
|
||||
"order": 6,
|
||||
"items": [
|
||||
{
|
||||
"title": "Financial Benefits",
|
||||
"content": (
|
||||
"Maximize your financial gains with our solutions, offering cost-effective "
|
||||
"strategies and optimized financial performance."
|
||||
),
|
||||
"image_path": "experience/financial-benefits.svg",
|
||||
"order": 1,
|
||||
},
|
||||
{
|
||||
"title": "24/7 Support",
|
||||
"content": (
|
||||
"Enjoy peace of mind with our round-the-clock support, ensuring assistance "
|
||||
"and guidance whenever you need it, day or night."
|
||||
),
|
||||
"image_path": "experience/support.svg",
|
||||
"order": 2,
|
||||
},
|
||||
{
|
||||
"title": "Quality Assurance",
|
||||
"content": (
|
||||
"Rely on our commitment to quality assurance, where meticulous processes "
|
||||
"guarantee the delivery of high-quality, error-free outcomes."
|
||||
),
|
||||
"image_path": "experience/quality-assurance.svg",
|
||||
"order": 3,
|
||||
},
|
||||
{
|
||||
"title": "International Workshop",
|
||||
"content": (
|
||||
"Engage in our international workshops, fostering collaboration and knowledge "
|
||||
"exchange on a global scale for enhanced innovation and skill development."
|
||||
),
|
||||
"image_path": "experience/international-workshop.svg",
|
||||
"order": 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"section_type": HomepageSection.TYPE_ABOUT_STRIP,
|
||||
"badge": "Our Story",
|
||||
"title": "More to Know",
|
||||
"description": (
|
||||
"Tecvico has been developing since 2021 by the Quantitative Radiomolecular Imaging "
|
||||
"and Therapy (Qurit) lab & program at the University of British Columbia & "
|
||||
"BC Cancer Research Institute, Vancouver, BC, Canada."
|
||||
),
|
||||
"link_text": "Learn More",
|
||||
"link_url": "/about/",
|
||||
"order": 7,
|
||||
"items": [],
|
||||
},
|
||||
{
|
||||
"section_type": HomepageSection.TYPE_SUPPORTERS,
|
||||
"badge": "Acknowledgements",
|
||||
"title": "Our Supporters",
|
||||
"description": "Tecvico is made possible by the support of leading research institutions and organizations.",
|
||||
"order": 8,
|
||||
"items": [
|
||||
{
|
||||
"title": "University of British Columbia",
|
||||
"content": "Faculty of Medicine and the Department of Integrative Oncology.",
|
||||
"order": 1,
|
||||
},
|
||||
{
|
||||
"title": "BC Cancer Research Institute",
|
||||
"content": "Supporting cutting-edge radiomics and medical imaging research in Vancouver, BC.",
|
||||
"order": 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
DOWNLOAD_ITEMS = [
|
||||
{
|
||||
"name": "Tecvico Desktop",
|
||||
"platform": "windows",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/tecvico/tecvico/releases/latest/download/Tecvico-Setup.exe",
|
||||
"description": "Windows 10 and above (64-bit). Installer package.",
|
||||
"is_active": True,
|
||||
"order": 1,
|
||||
},
|
||||
{
|
||||
"name": "Tecvico Desktop",
|
||||
"platform": "macos",
|
||||
"version": "Coming Soon",
|
||||
"download_url": "#",
|
||||
"description": "macOS version is under development.",
|
||||
"is_active": False,
|
||||
"order": 2,
|
||||
},
|
||||
{
|
||||
"name": "Tecvico Desktop",
|
||||
"platform": "linux",
|
||||
"version": "Coming Soon",
|
||||
"download_url": "#",
|
||||
"description": "Linux version is under development.",
|
||||
"is_active": False,
|
||||
"order": 3,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed the database with initial Tecvico website content"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--flush",
|
||||
action="store_true",
|
||||
help="Delete all existing seed data before re-seeding",
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
if options["flush"]:
|
||||
self.stdout.write("Flushing existing seed data...")
|
||||
ArticleSection.objects.all().delete()
|
||||
Article.objects.all().delete()
|
||||
SubProduct.objects.all().delete()
|
||||
MainProduct.objects.all().delete()
|
||||
FAQEntry.objects.all().delete()
|
||||
DownloadItem.objects.all().delete()
|
||||
HomepageSectionItem.objects.all().delete()
|
||||
HomepageSection.objects.all().delete()
|
||||
HeroSection.objects.all().delete()
|
||||
|
||||
self._seed_products()
|
||||
self._seed_faq()
|
||||
self._seed_downloads()
|
||||
self._seed_homepage_sections()
|
||||
self._seed_hero()
|
||||
self._seed_site_contact()
|
||||
self._seed_site_branding()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Content seeded successfully."))
|
||||
|
||||
def _seed_products(self):
|
||||
for product_data in MAIN_PRODUCTS:
|
||||
sub_products_data = product_data.pop("sub_products")
|
||||
main_product, created = MainProduct.objects.get_or_create(
|
||||
slug=product_data["slug"],
|
||||
defaults=product_data,
|
||||
)
|
||||
if not created:
|
||||
for field, value in product_data.items():
|
||||
setattr(main_product, field, value)
|
||||
main_product.save()
|
||||
|
||||
action = "Created" if created else "Updated"
|
||||
self.stdout.write(f" {action} main product: {main_product.name}")
|
||||
|
||||
for sub_data in sub_products_data:
|
||||
articles_data = sub_data.pop("articles")
|
||||
sub_product, sub_created = SubProduct.objects.get_or_create(
|
||||
main_product=main_product,
|
||||
slug=sub_data["slug"],
|
||||
defaults=sub_data,
|
||||
)
|
||||
if not sub_created:
|
||||
for field, value in sub_data.items():
|
||||
setattr(sub_product, field, value)
|
||||
sub_product.save()
|
||||
|
||||
sub_action = "Created" if sub_created else "Updated"
|
||||
self.stdout.write(f" {sub_action} sub-product: {sub_product.name}")
|
||||
|
||||
for article_data in articles_data:
|
||||
sections_data = article_data.pop("sections")
|
||||
article, art_created = Article.objects.get_or_create(
|
||||
sub_product=sub_product,
|
||||
title=article_data["title"],
|
||||
defaults=article_data,
|
||||
)
|
||||
if not art_created:
|
||||
for field, value in article_data.items():
|
||||
setattr(article, field, value)
|
||||
article.save()
|
||||
|
||||
art_action = "Created" if art_created else "Updated"
|
||||
self.stdout.write(f" {art_action} article: {article.title}")
|
||||
|
||||
for section_data in sections_data:
|
||||
section, _ = ArticleSection.objects.get_or_create(
|
||||
article=article,
|
||||
title=section_data["title"],
|
||||
defaults=section_data,
|
||||
)
|
||||
|
||||
def _seed_faq(self):
|
||||
for entry_data in FAQ_ENTRIES:
|
||||
faq, created = FAQEntry.objects.get_or_create(
|
||||
question=entry_data["question"],
|
||||
defaults=entry_data,
|
||||
)
|
||||
if not created:
|
||||
for field, value in entry_data.items():
|
||||
setattr(faq, field, value)
|
||||
faq.save()
|
||||
|
||||
action = "Created" if created else "Updated"
|
||||
self.stdout.write(f" {action} FAQ: {faq.question[:60]}...")
|
||||
|
||||
def _seed_homepage_sections(self):
|
||||
for section_data in HOMEPAGE_SECTIONS:
|
||||
items_data = section_data.pop("items")
|
||||
section, created = HomepageSection.objects.get_or_create(
|
||||
order=section_data["order"],
|
||||
defaults=section_data,
|
||||
)
|
||||
if not created:
|
||||
for field, value in section_data.items():
|
||||
setattr(section, field, value)
|
||||
section.is_active = True
|
||||
section.save()
|
||||
|
||||
action = "Created" if created else "Updated"
|
||||
self.stdout.write(f" {action} homepage section: {section}")
|
||||
|
||||
seed_titles = [item_data["title"] for item_data in items_data if item_data.get("title")]
|
||||
if seed_titles:
|
||||
section.items.exclude(title__in=seed_titles).delete()
|
||||
else:
|
||||
section.items.all().delete()
|
||||
|
||||
for item_data in items_data:
|
||||
image_path = item_data.pop("image_path", None)
|
||||
item, item_created = HomepageSectionItem.objects.get_or_create(
|
||||
section=section,
|
||||
title=item_data["title"],
|
||||
defaults=item_data,
|
||||
)
|
||||
if not item_created:
|
||||
for field, value in item_data.items():
|
||||
setattr(item, field, value)
|
||||
item.save()
|
||||
if image_path:
|
||||
self._attach_item_image(item, image_path)
|
||||
|
||||
def _attach_item_image(self, item, relative_path):
|
||||
path = Path(settings.BASE_DIR) / "static" / "images" / "tecvico" / relative_path
|
||||
if not path.exists():
|
||||
self.stdout.write(self.style.WARNING(f" Missing image: {path}"))
|
||||
return
|
||||
if item.image and item.image.name.endswith(path.name):
|
||||
return
|
||||
with path.open("rb") as handle:
|
||||
item.image.save(path.name, File(handle), save=True)
|
||||
|
||||
def _seed_downloads(self):
|
||||
for item_data in DOWNLOAD_ITEMS:
|
||||
item, created = DownloadItem.objects.get_or_create(
|
||||
name=item_data["name"],
|
||||
platform=item_data["platform"],
|
||||
defaults=item_data,
|
||||
)
|
||||
if not created:
|
||||
for field, value in item_data.items():
|
||||
setattr(item, field, value)
|
||||
item.save()
|
||||
|
||||
action = "Created" if created else "Updated"
|
||||
self.stdout.write(f" {action} download: {item}")
|
||||
|
||||
def _seed_hero(self):
|
||||
data = {
|
||||
"badge": "Innovation, Advancement, Competition",
|
||||
"title": "Advanced Solutions",
|
||||
"title_highlight": "for Your Business Development",
|
||||
"subtitle": "Welcome to a new era of commerce with us",
|
||||
"description": (
|
||||
"At Tecvico, we're a dynamic team challenging business norms. Our creativity and "
|
||||
"expertise converge to provide innovative solutions, transforming enterprises."
|
||||
),
|
||||
"primary_cta_text": "Get started for free",
|
||||
"primary_cta_url": "/products/",
|
||||
"secondary_cta_text": "About Tecvico",
|
||||
"secondary_cta_url": "/about/",
|
||||
"image_alt": "Tecvico showcase",
|
||||
}
|
||||
hero = HeroSection.objects.first()
|
||||
if hero is None:
|
||||
HeroSection.objects.create(**data)
|
||||
self.stdout.write(" Created hero section")
|
||||
else:
|
||||
for field, value in data.items():
|
||||
if field == "image":
|
||||
continue
|
||||
setattr(hero, field, value)
|
||||
hero.save()
|
||||
self.stdout.write(" Updated hero section")
|
||||
|
||||
def _seed_site_contact(self):
|
||||
contact, created = SiteContact.objects.get_or_create(
|
||||
pk=1,
|
||||
defaults={
|
||||
"support_email": "pr@tecvico.com",
|
||||
"discord_url": "https://discord.gg/9XxA6pV9hb",
|
||||
"email_card_description": "For direct software support:",
|
||||
"discord_card_description": "Join for community support and announcements.",
|
||||
"office_address": (
|
||||
"BC Cancer Research Center\n"
|
||||
"675 West 10th Ave, Office 6-112\n"
|
||||
"Vancouver, BC, V5Z 1L3\n"
|
||||
"Canada"
|
||||
),
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
updates = {
|
||||
"support_email": "pr@tecvico.com",
|
||||
"discord_url": "https://discord.gg/9XxA6pV9hb",
|
||||
"email_card_description": "For direct software support:",
|
||||
"discord_card_description": "Join for community support and announcements.",
|
||||
"office_address": (
|
||||
"BC Cancer Research Center\n"
|
||||
"675 West 10th Ave, Office 6-112\n"
|
||||
"Vancouver, BC, V5Z 1L3\n"
|
||||
"Canada"
|
||||
),
|
||||
}
|
||||
for field, value in updates.items():
|
||||
setattr(contact, field, value)
|
||||
contact.save()
|
||||
action = "Created" if created else "Updated"
|
||||
self.stdout.write(f" {action} site contact")
|
||||
|
||||
def _seed_site_branding(self):
|
||||
branding = SiteBranding.load()
|
||||
if branding.icon:
|
||||
branding.icon.delete(save=False)
|
||||
branding.icon = None
|
||||
branding.icon_alt = "Tecvico"
|
||||
branding.navbar_icon_size = 40
|
||||
branding.footer_icon_size = 48
|
||||
branding.show_border = False
|
||||
branding.save()
|
||||
self.stdout.write(" Updated site branding")
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-25 09:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SiteBranding',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('icon', models.ImageField(blank=True, help_text='Logo shown in the site header and footer. Leave empty to use the default static icon.', null=True, upload_to='branding/')),
|
||||
('icon_alt', models.CharField(blank=True, help_text='Alt text for the brand icon (decorative icons can stay empty).', max_length=200)),
|
||||
('navbar_icon_size', models.PositiveSmallIntegerField(default=26, help_text='Width and height in pixels for the header icon.')),
|
||||
('footer_icon_size', models.PositiveSmallIntegerField(default=34, help_text='Width and height in pixels for the footer icon.')),
|
||||
('show_border', models.BooleanField(default=False, help_text='Draw a border around the brand icon.')),
|
||||
('border_color', models.CharField(default='#c4b5fd', help_text='Border color as hex (e.g. #c4b5fd).', max_length=7)),
|
||||
('border_width', models.PositiveSmallIntegerField(default=1, help_text='Border width in pixels.')),
|
||||
('object_fit', models.CharField(choices=[('cover', 'Cover (fill square, may crop)'), ('contain', 'Contain (fit inside square)')], default='cover', max_length=10)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Site Branding',
|
||||
'verbose_name_plural': 'Site Branding',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-25 11:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def seed_site_contact(apps, schema_editor):
|
||||
SiteContact = apps.get_model("core", "SiteContact")
|
||||
SiteContact.objects.get_or_create(
|
||||
pk=1,
|
||||
defaults={
|
||||
"support_email": "support@radiuma.com",
|
||||
"discord_url": "https://discord.gg/9XxA6pV9hb",
|
||||
"office_address": (
|
||||
"BC Cancer Research Center\n"
|
||||
"675 West 10th Ave, Office 6-112\n"
|
||||
"Vancouver, BC, V5Z 1L3\n"
|
||||
"Canada"
|
||||
),
|
||||
"email_card_description": "For direct software support:",
|
||||
"discord_card_description": "Join for community support and announcements.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_site_branding'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SiteContact',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('support_email', models.EmailField(blank=True, help_text='Shown in the footer and on the contact page. Hidden when empty.', max_length=254)),
|
||||
('discord_url', models.URLField(blank=True, help_text='Discord invite link. Hidden when empty.')),
|
||||
('discord_label', models.CharField(blank=True, help_text='Button label (e.g. Join Discord). Defaults to “Join Discord” when empty.', max_length=100)),
|
||||
('office_address', models.TextField(blank=True, help_text='Physical address; one line per row. Hidden when empty.')),
|
||||
('email_card_title', models.CharField(blank=True, help_text='Contact page email card heading. Defaults to “Email Support”.', max_length=200)),
|
||||
('email_card_description', models.CharField(blank=True, help_text='Short text under the email card heading on the contact page.', max_length=500)),
|
||||
('discord_card_title', models.CharField(blank=True, help_text='Contact page Discord card heading. Defaults to “Discord Community”.', max_length=200)),
|
||||
('discord_card_description', models.CharField(blank=True, help_text='Short text under the Discord card heading on the contact page.', max_length=500)),
|
||||
('office_card_title', models.CharField(blank=True, help_text='Contact page office card heading. Defaults to “Office”.', max_length=200)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Site Contact',
|
||||
'verbose_name_plural': 'Site Contact',
|
||||
},
|
||||
),
|
||||
migrations.RunPython(seed_site_contact, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,177 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class SiteBranding(models.Model):
|
||||
OBJECT_FIT_COVER = "cover"
|
||||
OBJECT_FIT_CONTAIN = "contain"
|
||||
OBJECT_FIT_CHOICES = [
|
||||
(OBJECT_FIT_COVER, "Cover (fill square, may crop)"),
|
||||
(OBJECT_FIT_CONTAIN, "Contain (fit inside square)"),
|
||||
]
|
||||
|
||||
icon = models.ImageField(
|
||||
upload_to="branding/",
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Logo shown in the site header and footer. Leave empty to use the default static icon.",
|
||||
)
|
||||
icon_alt = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
help_text="Alt text for the brand icon (decorative icons can stay empty).",
|
||||
)
|
||||
navbar_icon_size = models.PositiveSmallIntegerField(
|
||||
default=26,
|
||||
help_text="Width and height in pixels for the header icon.",
|
||||
)
|
||||
footer_icon_size = models.PositiveSmallIntegerField(
|
||||
default=34,
|
||||
help_text="Width and height in pixels for the footer icon.",
|
||||
)
|
||||
show_border = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Draw a border around the brand icon.",
|
||||
)
|
||||
border_color = models.CharField(
|
||||
max_length=7,
|
||||
default="#3051ff",
|
||||
help_text="Border color as hex (e.g. #c4b5fd).",
|
||||
)
|
||||
border_width = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
help_text="Border width in pixels.",
|
||||
)
|
||||
object_fit = models.CharField(
|
||||
max_length=10,
|
||||
choices=OBJECT_FIT_CHOICES,
|
||||
default=OBJECT_FIT_COVER,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Site Branding"
|
||||
verbose_name_plural = "Site Branding"
|
||||
|
||||
def __str__(self):
|
||||
return "Site Branding"
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
obj, _ = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
def icon_style(self, size_px):
|
||||
parts = [
|
||||
f"width:{size_px}px",
|
||||
f"height:{size_px}px",
|
||||
f"object-fit:{self.object_fit}",
|
||||
]
|
||||
if self.show_border:
|
||||
parts.append(f"border:{self.border_width}px solid {self.border_color}")
|
||||
return ";".join(parts)
|
||||
|
||||
@property
|
||||
def navbar_icon_style(self):
|
||||
return self.icon_style(self.navbar_icon_size)
|
||||
|
||||
@property
|
||||
def footer_icon_style(self):
|
||||
return self.icon_style(self.footer_icon_size)
|
||||
|
||||
|
||||
class SiteContact(models.Model):
|
||||
support_email = models.EmailField(
|
||||
blank=True,
|
||||
help_text="Shown in the footer and on the contact page. Hidden when empty.",
|
||||
)
|
||||
discord_url = models.URLField(
|
||||
blank=True,
|
||||
help_text="Discord invite link. Hidden when empty.",
|
||||
)
|
||||
discord_label = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Button label (e.g. Join Discord). Defaults to “Join Discord” when empty.",
|
||||
)
|
||||
office_address = models.TextField(
|
||||
blank=True,
|
||||
help_text="Physical address; one line per row. Hidden when empty.",
|
||||
)
|
||||
email_card_title = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
help_text="Contact page email card heading. Defaults to “Email Support”.",
|
||||
)
|
||||
email_card_description = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="Short text under the email card heading on the contact page.",
|
||||
)
|
||||
discord_card_title = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
help_text="Contact page Discord card heading. Defaults to “Discord Community”.",
|
||||
)
|
||||
discord_card_description = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="Short text under the Discord card heading on the contact page.",
|
||||
)
|
||||
office_card_title = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
help_text="Contact page office card heading. Defaults to “Office”.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Site Contact"
|
||||
verbose_name_plural = "Site Contact"
|
||||
|
||||
def __str__(self):
|
||||
return "Site Contact"
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
obj, _ = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
@property
|
||||
def discord_label_display(self):
|
||||
return self.discord_label.strip() or "Join Discord"
|
||||
|
||||
@property
|
||||
def office_address_lines(self):
|
||||
if not self.office_address.strip():
|
||||
return []
|
||||
return [line.strip() for line in self.office_address.splitlines() if line.strip()]
|
||||
|
||||
@property
|
||||
def has_support_email(self):
|
||||
return bool(self.support_email)
|
||||
|
||||
@property
|
||||
def has_discord(self):
|
||||
return bool(self.discord_url)
|
||||
|
||||
@property
|
||||
def has_office_address(self):
|
||||
return bool(self.office_address_lines)
|
||||
|
||||
@property
|
||||
def has_footer_contact(self):
|
||||
return self.has_support_email or self.has_office_address
|
||||
|
||||
@property
|
||||
def has_contact_sidebar(self):
|
||||
return self.has_support_email or self.has_discord or self.has_office_address
|
||||
|
||||
@property
|
||||
def email_card_title_display(self):
|
||||
return self.email_card_title.strip() or "Email Support"
|
||||
|
||||
@property
|
||||
def discord_card_title_display(self):
|
||||
return self.discord_card_title.strip() or "Discord Community"
|
||||
|
||||
@property
|
||||
def office_card_title_display(self):
|
||||
return self.office_card_title.strip() or "Office"
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from apps.core.context_processors import site_branding
|
||||
from apps.core.models import SiteBranding
|
||||
|
||||
|
||||
class SiteBrandingModelTests(TestCase):
|
||||
def test_load_creates_singleton(self):
|
||||
branding = SiteBranding.load()
|
||||
self.assertEqual(branding.pk, 1)
|
||||
self.assertEqual(SiteBranding.objects.count(), 1)
|
||||
|
||||
def test_icon_style_includes_border_when_enabled(self):
|
||||
branding = SiteBranding.load()
|
||||
branding.show_border = True
|
||||
branding.border_color = "#ffffff"
|
||||
branding.border_width = 2
|
||||
branding.navbar_icon_size = 30
|
||||
style = branding.navbar_icon_style
|
||||
self.assertIn("width:30px", style)
|
||||
self.assertIn("border:2px solid #ffffff", style)
|
||||
|
||||
def test_icon_style_omits_border_when_disabled(self):
|
||||
branding = SiteBranding.load()
|
||||
branding.show_border = False
|
||||
self.assertNotIn("border:", branding.navbar_icon_style)
|
||||
|
||||
|
||||
class SiteBrandingContextProcessorTests(TestCase):
|
||||
def test_site_branding_in_context(self):
|
||||
SiteBranding.load()
|
||||
request = RequestFactory().get("/")
|
||||
ctx = site_branding(request)
|
||||
self.assertIn("site_branding", ctx)
|
||||
self.assertIsInstance(ctx["site_branding"], SiteBranding)
|
||||
@@ -0,0 +1,55 @@
|
||||
from django.test import Client, RequestFactory, TestCase
|
||||
|
||||
from apps.core.context_processors import site_contact
|
||||
from apps.core.models import SiteContact
|
||||
|
||||
|
||||
class SiteContactModelTests(TestCase):
|
||||
def test_office_address_lines_skips_blank_lines(self):
|
||||
contact = SiteContact.load()
|
||||
contact.office_address = "Line one\n\nLine two"
|
||||
self.assertEqual(contact.office_address_lines, ["Line one", "Line two"])
|
||||
|
||||
def test_has_footer_contact_requires_email_or_office(self):
|
||||
contact = SiteContact.load()
|
||||
contact.support_email = ""
|
||||
contact.office_address = ""
|
||||
contact.discord_url = "https://discord.gg/example"
|
||||
self.assertFalse(contact.has_footer_contact)
|
||||
self.assertTrue(contact.has_discord)
|
||||
|
||||
def test_discord_label_default(self):
|
||||
contact = SiteContact.load()
|
||||
contact.discord_label = ""
|
||||
self.assertEqual(contact.discord_label_display, "Join Discord")
|
||||
|
||||
|
||||
class SiteContactContextProcessorTests(TestCase):
|
||||
def test_site_contact_in_context(self):
|
||||
SiteContact.load()
|
||||
request = RequestFactory().get("/")
|
||||
ctx = site_contact(request)
|
||||
self.assertIn("site_contact", ctx)
|
||||
self.assertIsInstance(ctx["site_contact"], SiteContact)
|
||||
|
||||
|
||||
class SiteContactTemplateTests(TestCase):
|
||||
def test_footer_hides_email_when_empty(self):
|
||||
contact = SiteContact.load()
|
||||
contact.support_email = ""
|
||||
contact.discord_url = ""
|
||||
contact.office_address = ""
|
||||
contact.save()
|
||||
response = Client().get("/")
|
||||
self.assertNotContains(response, "mailto:")
|
||||
self.assertNotContains(response, "discord.gg")
|
||||
|
||||
def test_contact_page_hides_sidebar_when_empty(self):
|
||||
contact = SiteContact.load()
|
||||
contact.support_email = ""
|
||||
contact.discord_url = ""
|
||||
contact.office_address = ""
|
||||
contact.save()
|
||||
response = Client().get("/contact/")
|
||||
self.assertNotContains(response, "contact-sidebar")
|
||||
self.assertContains(response, "contact-layout--full")
|
||||
@@ -0,0 +1,34 @@
|
||||
import markdown as _md
|
||||
from django.utils.html import escape, mark_safe
|
||||
|
||||
FORMAT_PLAIN = "plain"
|
||||
FORMAT_MARKDOWN = "markdown"
|
||||
FORMAT_HTML = "html"
|
||||
|
||||
CONTENT_FORMAT_CHOICES = [
|
||||
(FORMAT_PLAIN, "Plain Text"),
|
||||
(FORMAT_MARKDOWN, "Markdown"),
|
||||
(FORMAT_HTML, "HTML"),
|
||||
]
|
||||
|
||||
_MD_EXTENSIONS = ["extra", "nl2br", "sane_lists"]
|
||||
|
||||
|
||||
def render_content(text: str, fmt: str) -> str:
|
||||
if not text:
|
||||
return mark_safe("")
|
||||
|
||||
if fmt == FORMAT_HTML:
|
||||
return mark_safe(text)
|
||||
|
||||
if fmt == FORMAT_MARKDOWN:
|
||||
return mark_safe(_md.markdown(text, extensions=_MD_EXTENSIONS))
|
||||
|
||||
paragraphs = text.split("\n\n")
|
||||
parts = []
|
||||
for para in paragraphs:
|
||||
para = para.strip()
|
||||
if para:
|
||||
lines = escape(para).split("\n")
|
||||
parts.append("<p>" + "<br>".join(lines) + "</p>")
|
||||
return mark_safe("".join(parts) if parts else f"<p>{escape(text)}</p>")
|
||||
@@ -0,0 +1,172 @@
|
||||
import re
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content
|
||||
|
||||
VIDEO_SOURCE_YOUTUBE = "youtube"
|
||||
VIDEO_SOURCE_UPLOAD = "upload"
|
||||
|
||||
VIDEO_SOURCE_CHOICES = [
|
||||
(VIDEO_SOURCE_YOUTUBE, "YouTube / external link"),
|
||||
(VIDEO_SOURCE_UPLOAD, "Uploaded file"),
|
||||
]
|
||||
|
||||
VIDEO_SIZE_SMALL = "sm"
|
||||
VIDEO_SIZE_MEDIUM = "md"
|
||||
VIDEO_SIZE_LARGE = "lg"
|
||||
VIDEO_SIZE_FULL = "full"
|
||||
|
||||
VIDEO_SIZE_CHOICES = [
|
||||
(VIDEO_SIZE_SMALL, "Small (480px)"),
|
||||
(VIDEO_SIZE_MEDIUM, "Medium (720px)"),
|
||||
(VIDEO_SIZE_LARGE, "Large (960px)"),
|
||||
(VIDEO_SIZE_FULL, "Full width"),
|
||||
]
|
||||
|
||||
VIDEO_ASPECT_16_9 = "16/9"
|
||||
VIDEO_ASPECT_4_3 = "4/3"
|
||||
VIDEO_ASPECT_1_1 = "1/1"
|
||||
|
||||
VIDEO_ASPECT_CHOICES = [
|
||||
(VIDEO_ASPECT_16_9, "16:9 (widescreen)"),
|
||||
(VIDEO_ASPECT_4_3, "4:3 (standard)"),
|
||||
(VIDEO_ASPECT_1_1, "1:1 (square)"),
|
||||
]
|
||||
|
||||
VIDEO_UPLOAD_EXTENSIONS = frozenset({".mp4", ".webm", ".ogg", ".mov"})
|
||||
|
||||
VIDEO_ADMIN_FIELDS = (
|
||||
"video_source",
|
||||
"video_url",
|
||||
"video_file",
|
||||
"video_poster",
|
||||
"video_size",
|
||||
"video_aspect_ratio",
|
||||
"video_styled_background",
|
||||
)
|
||||
|
||||
VIDEO_ADMIN_FIELDSET = (
|
||||
"Video",
|
||||
{
|
||||
"fields": VIDEO_ADMIN_FIELDS + ("video_preview",),
|
||||
"description": (
|
||||
"Choose YouTube link or uploaded file. Set display size and aspect ratio "
|
||||
"to control how the preview appears on the site."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
YOUTUBE_ID_PATTERNS = (
|
||||
re.compile(r"(?:youtube\.com/watch\?(?:[^&]+&)*v=|youtube\.com/embed/|youtube\.com/shorts/|youtu\.be/)([\w-]{11})"),
|
||||
re.compile(r"^([\w-]{11})$"),
|
||||
)
|
||||
|
||||
|
||||
def parse_youtube_video_id(url):
|
||||
if not url:
|
||||
return ""
|
||||
value = url.strip()
|
||||
for pattern in YOUTUBE_ID_PATTERNS:
|
||||
match = pattern.search(value)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def youtube_embed_url(url):
|
||||
video_id = parse_youtube_video_id(url)
|
||||
if not video_id:
|
||||
return ""
|
||||
return f"https://www.youtube-nocookie.com/embed/{video_id}"
|
||||
|
||||
|
||||
class VideoBlockMixin(models.Model):
|
||||
video_source = models.CharField(
|
||||
max_length=20,
|
||||
choices=VIDEO_SOURCE_CHOICES,
|
||||
default=VIDEO_SOURCE_YOUTUBE,
|
||||
blank=True,
|
||||
)
|
||||
video_url = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="YouTube watch, embed, or youtu.be link.",
|
||||
)
|
||||
video_file = models.FileField(
|
||||
upload_to="videos/",
|
||||
blank=True,
|
||||
help_text="MP4, WebM, OGG, or MOV file.",
|
||||
)
|
||||
video_poster = models.ImageField(
|
||||
upload_to="videos/posters/",
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Optional thumbnail shown before an uploaded video plays.",
|
||||
)
|
||||
video_size = models.CharField(
|
||||
max_length=10,
|
||||
choices=VIDEO_SIZE_CHOICES,
|
||||
default=VIDEO_SIZE_MEDIUM,
|
||||
)
|
||||
video_aspect_ratio = models.CharField(
|
||||
max_length=10,
|
||||
choices=VIDEO_ASPECT_CHOICES,
|
||||
default=VIDEO_ASPECT_16_9,
|
||||
)
|
||||
video_styled_background = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Glass panel with ambient glow (similar to the Downloads section).",
|
||||
)
|
||||
description_format = models.CharField(
|
||||
max_length=20,
|
||||
choices=CONTENT_FORMAT_CHOICES,
|
||||
default=FORMAT_PLAIN,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def youtube_embed_url(self):
|
||||
return youtube_embed_url(self.video_url)
|
||||
|
||||
@property
|
||||
def has_video(self):
|
||||
if self.video_source == VIDEO_SOURCE_UPLOAD:
|
||||
return bool(self.video_file)
|
||||
return bool(self.youtube_embed_url)
|
||||
|
||||
@property
|
||||
def video_size_class(self):
|
||||
return f"video-block--{self.video_size or VIDEO_SIZE_MEDIUM}"
|
||||
|
||||
@property
|
||||
def video_aspect_class(self):
|
||||
ratio = (self.video_aspect_ratio or VIDEO_ASPECT_16_9).replace("/", "-")
|
||||
return f"video-block--ratio-{ratio}"
|
||||
|
||||
@property
|
||||
def rendered_description(self):
|
||||
description = getattr(self, "description", "") or ""
|
||||
return render_content(description, self.description_format)
|
||||
|
||||
def clean_video_fields(self, require=False):
|
||||
if not require and not self.video_url and not self.video_file:
|
||||
return
|
||||
if self.video_source == VIDEO_SOURCE_YOUTUBE:
|
||||
if not self.video_url.strip():
|
||||
raise ValidationError({"video_url": "Enter a YouTube link."})
|
||||
if not self.youtube_embed_url:
|
||||
raise ValidationError({"video_url": "Enter a valid YouTube link."})
|
||||
elif self.video_source == VIDEO_SOURCE_UPLOAD:
|
||||
if not self.video_file:
|
||||
raise ValidationError({"video_file": "Upload a video file."})
|
||||
extension = self.video_file.name.rsplit(".", 1)[-1].lower() if self.video_file.name else ""
|
||||
if f".{extension}" not in VIDEO_UPLOAD_EXTENSIONS:
|
||||
raise ValidationError(
|
||||
{
|
||||
"video_file": "Unsupported format. Use MP4, WebM, OGG, or MOV.",
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user