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
+92
View File
@@ -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)
+30
View File
@@ -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"
+7
View File
@@ -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"
+41
View File
@@ -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,
}
View File
@@ -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',
},
),
]
+52
View File
@@ -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),
]
View File
+177
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
+35
View File
@@ -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)
+55
View File
@@ -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")
+34
View File
@@ -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>")
+172
View File
@@ -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.",
}
)