feat: Dockerize and complete project

This commit is contained in:
mohamad
2026-04-27 17:30:08 +03:30
parent 051148ce78
commit 3c10f5c5ed
62 changed files with 5296 additions and 1 deletions
View File
View File
+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"
+10
View File
@@ -0,0 +1,10 @@
from apps.products.models import MainProduct
def navigation(request):
main_products = (
MainProduct.objects.filter(is_active=True)
.prefetch_related("sub_products")
.order_by("order", "name")
)
return {"nav_main_products": main_products}
View File
@@ -0,0 +1,400 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.pages.models import DownloadItem, FAQEntry
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
MAIN_PRODUCTS = [
{
"name": "ViSERA",
"slug": "visera",
"short_description": "Visualized & Standardized Environment for Radiomics Analysis",
"description": (
"ViSERA 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. "
"ViSERA 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,
"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. ViSERA 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": (
"ViSERA 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 Corp R&D Team", "order": 3},
],
},
{
"title": "Image Registration & Fusion",
"description": (
"ViSERA 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": (
"ViSERA 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": (
"ViSERA 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": (
"ViSERA 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": (
"ViSERA 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": (
"ViSERA'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 ViSERA license?",
"answer": (
"ViSERA 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 ViSERA in my research?",
"answer": (
"Please cite the following reference if you publish results obtained with "
"the help of ViSERA:\n\n"
"M. R. Salmanpour, I. Shiri, M. Hosseinzadeh, H. Zaidi, S. Ashrafinia, "
"M. Oveisi, A. Rahmim. ViSERA: 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 ViSERA support?",
"answer": (
"ViSERA 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 the Downloads 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 ViSERA suitable for clinical use?",
"answer": (
"ViSERA 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 at support@tecvico.com and through our "
"community Discord server. For bug reports and feature requests, please "
"use the Discord forum or contact us directly by email."
),
"order": 6,
},
]
DOWNLOAD_ITEMS = [
{
"name": "ViSERA Desktop",
"platform": "windows",
"version": "1.0.0",
"download_url": "https://github.com/tecvico/visera/releases/latest/download/ViSERA-Setup.exe",
"description": "Windows 10 and above (64-bit). Installer package.",
"is_active": True,
"order": 1,
},
{
"name": "ViSERA Desktop",
"platform": "macos",
"version": "Coming Soon",
"download_url": "#",
"description": "macOS version is under development.",
"is_active": False,
"order": 2,
},
{
"name": "ViSERA 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 / ViSERA content from visera.ca"
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()
self._seed_products()
self._seed_faq()
self._seed_downloads()
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_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}")
View File
+27
View File
@@ -0,0 +1,27 @@
from django.contrib import admin
from .models import DownloadItem, FAQEntry
@admin.register(FAQEntry)
class FAQEntryAdmin(admin.ModelAdmin):
list_display = ("question", "order", "is_active", "created_at")
list_filter = ("is_active",)
search_fields = ("question", "answer")
list_editable = ("order", "is_active")
fieldsets = (
(None, {"fields": ("question", "answer")}),
("Settings", {"fields": ("order", "is_active")}),
)
@admin.register(DownloadItem)
class DownloadItemAdmin(admin.ModelAdmin):
list_display = ("name", "platform", "version", "is_active", "order")
list_filter = ("platform", "is_active")
search_fields = ("name", "description")
list_editable = ("order", "is_active")
fieldsets = (
(None, {"fields": ("name", "platform", "version", "download_url", "description")}),
("Settings", {"fields": ("order", "is_active")}),
)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class PagesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.pages"
verbose_name = "Pages"
+50
View File
@@ -0,0 +1,50 @@
# Generated by Django 5.2.13 on 2026-04-27 09:07
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='DownloadItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('platform', models.CharField(choices=[('windows', 'Windows'), ('macos', 'macOS'), ('linux', 'Linux')], max_length=20)),
('version', models.CharField(max_length=50)),
('download_url', models.URLField()),
('description', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
('order', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'Download Item',
'verbose_name_plural': 'Download Items',
'ordering': ['order', 'platform'],
},
),
migrations.CreateModel(
name='FAQEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('question', models.CharField(max_length=500)),
('answer', models.TextField()),
('order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'FAQ Entry',
'verbose_name_plural': 'FAQ Entries',
'ordering': ['order'],
},
),
]
View File
+47
View File
@@ -0,0 +1,47 @@
from django.db import models
class FAQEntry(models.Model):
question = models.CharField(max_length=500)
answer = models.TextField()
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["order"]
verbose_name = "FAQ Entry"
verbose_name_plural = "FAQ Entries"
def __str__(self):
return self.question
class DownloadItem(models.Model):
PLATFORM_WINDOWS = "windows"
PLATFORM_MACOS = "macos"
PLATFORM_LINUX = "linux"
PLATFORM_CHOICES = [
(PLATFORM_WINDOWS, "Windows"),
(PLATFORM_MACOS, "macOS"),
(PLATFORM_LINUX, "Linux"),
]
name = models.CharField(max_length=200)
platform = models.CharField(max_length=20, choices=PLATFORM_CHOICES)
version = models.CharField(max_length=50)
download_url = models.URLField()
description = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
order = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["order", "platform"]
verbose_name = "Download Item"
verbose_name_plural = "Download Items"
def __str__(self):
return f"{self.name} ({self.get_platform_display()})"
View File
+114
View File
@@ -0,0 +1,114 @@
from django.test import TestCase
from django.urls import reverse
from apps.pages.models import DownloadItem, FAQEntry
class HomeViewTest(TestCase):
def test_home_returns_200(self):
response = self.client.get(reverse("pages:home"))
self.assertEqual(response.status_code, 200)
def test_home_uses_correct_template(self):
response = self.client.get(reverse("pages:home"))
self.assertTemplateUsed(response, "pages/home.html")
class AboutViewTest(TestCase):
def test_about_returns_200(self):
response = self.client.get(reverse("pages:about"))
self.assertEqual(response.status_code, 200)
def test_about_uses_correct_template(self):
response = self.client.get(reverse("pages:about"))
self.assertTemplateUsed(response, "pages/about.html")
class DownloadsViewTest(TestCase):
def setUp(self):
DownloadItem.objects.create(
name="ViSERA Desktop",
platform="windows",
version="1.0",
download_url="https://example.com/windows",
is_active=True,
)
DownloadItem.objects.create(
name="ViSERA Desktop",
platform="macos",
version="Coming Soon",
download_url="#",
is_active=False,
)
def test_downloads_returns_200(self):
response = self.client.get(reverse("pages:downloads"))
self.assertEqual(response.status_code, 200)
def test_downloads_uses_correct_template(self):
response = self.client.get(reverse("pages:downloads"))
self.assertTemplateUsed(response, "pages/downloads.html")
def test_downloads_context_has_platform_keys(self):
response = self.client.get(reverse("pages:downloads"))
self.assertIn("windows_items", response.context)
self.assertIn("macos_items", response.context)
self.assertIn("linux_items", response.context)
def test_windows_item_in_context(self):
response = self.client.get(reverse("pages:downloads"))
self.assertEqual(response.context["windows_items"].count(), 1)
class FAQViewTest(TestCase):
def setUp(self):
FAQEntry.objects.create(
question="What is the license?",
answer="It is CC BY-NC-SA.",
order=1,
is_active=True,
)
FAQEntry.objects.create(
question="Hidden question",
answer="Hidden answer",
order=2,
is_active=False,
)
def test_faq_returns_200(self):
response = self.client.get(reverse("pages:faq"))
self.assertEqual(response.status_code, 200)
def test_faq_uses_correct_template(self):
response = self.client.get(reverse("pages:faq"))
self.assertTemplateUsed(response, "pages/faq.html")
def test_faq_only_shows_active_entries(self):
response = self.client.get(reverse("pages:faq"))
entries = response.context["faq_entries"]
self.assertEqual(entries.count(), 1)
self.assertEqual(entries.first().question, "What is the license?")
class ContactViewTest(TestCase):
def test_contact_returns_200(self):
response = self.client.get(reverse("pages:contact"))
self.assertEqual(response.status_code, 200)
def test_contact_uses_correct_template(self):
response = self.client.get(reverse("pages:contact"))
self.assertTemplateUsed(response, "pages/contact.html")
class NavigationContextTest(TestCase):
def test_nav_main_products_in_context_on_all_pages(self):
urls = [
reverse("pages:home"),
reverse("pages:about"),
reverse("pages:faq"),
reverse("pages:contact"),
reverse("pages:downloads"),
]
for url in urls:
response = self.client.get(url)
self.assertIn("nav_main_products", response.context, f"Missing nav_main_products at {url}")
+13
View File
@@ -0,0 +1,13 @@
from django.urls import path
from . import views
app_name = "pages"
urlpatterns = [
path("", views.HomeView.as_view(), name="home"),
path("about/", views.AboutView.as_view(), name="about"),
path("downloads/", views.DownloadsView.as_view(), name="downloads"),
path("faq/", views.FAQView.as_view(), name="faq"),
path("contact/", views.ContactView.as_view(), name="contact"),
]
+40
View File
@@ -0,0 +1,40 @@
from django.views.generic import ListView, TemplateView
from .models import DownloadItem, FAQEntry
class HomeView(TemplateView):
template_name = "pages/home.html"
class AboutView(TemplateView):
template_name = "pages/about.html"
class DownloadsView(ListView):
template_name = "pages/downloads.html"
context_object_name = "download_items"
def get_queryset(self):
return DownloadItem.objects.filter(is_active=True).order_by("order", "platform")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
all_items = DownloadItem.objects.order_by("order", "platform")
context["windows_items"] = all_items.filter(
platform=DownloadItem.PLATFORM_WINDOWS
)
context["macos_items"] = all_items.filter(platform=DownloadItem.PLATFORM_MACOS)
context["linux_items"] = all_items.filter(platform=DownloadItem.PLATFORM_LINUX)
return context
class FAQView(ListView):
model = FAQEntry
template_name = "pages/faq.html"
context_object_name = "faq_entries"
queryset = FAQEntry.objects.filter(is_active=True)
class ContactView(TemplateView):
template_name = "pages/contact.html"
View File
+83
View File
@@ -0,0 +1,83 @@
from django.contrib import admin
from .models import Article, ArticleSection, MainProduct, SubProduct
class ArticleSectionInline(admin.TabularInline):
model = ArticleSection
extra = 1
fields = ("title", "value", "order")
ordering = ("order",)
class ArticleInline(admin.StackedInline):
model = Article
extra = 0
fields = ("title", "description", "order")
ordering = ("order",)
show_change_link = True
class SubProductInline(admin.StackedInline):
model = SubProduct
extra = 0
fields = ("name", "slug", "short_description", "image", "order", "is_active")
ordering = ("order",)
show_change_link = True
prepopulated_fields = {"slug": ("name",)}
@admin.register(MainProduct)
class MainProductAdmin(admin.ModelAdmin):
list_display = ("name", "order", "is_active", "created_at")
list_filter = ("is_active",)
search_fields = ("name", "description")
prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active")
inlines = [SubProductInline]
fieldsets = (
(None, {"fields": ("name", "slug", "short_description", "description")}),
("Media", {"fields": ("image",)}),
("Settings", {"fields": ("order", "is_active")}),
)
@admin.register(SubProduct)
class SubProductAdmin(admin.ModelAdmin):
list_display = ("name", "main_product", "order", "is_active", "created_at")
list_filter = ("is_active", "main_product")
search_fields = ("name", "description", "main_product__name")
prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active")
raw_id_fields = ("main_product",)
inlines = [ArticleInline]
fieldsets = (
(
None,
{"fields": ("main_product", "name", "slug", "short_description", "description")},
),
("Media", {"fields": ("image",)}),
("Settings", {"fields": ("order", "is_active")}),
)
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ("title", "sub_product", "order", "created_at")
list_filter = ("sub_product__main_product",)
search_fields = ("title", "description")
list_editable = ("order",)
raw_id_fields = ("sub_product",)
inlines = [ArticleSectionInline]
fieldsets = (
(None, {"fields": ("sub_product", "title", "description")}),
("Settings", {"fields": ("order",)}),
)
@admin.register(ArticleSection)
class ArticleSectionAdmin(admin.ModelAdmin):
list_display = ("title", "article", "order")
search_fields = ("title", "value", "article__title")
list_editable = ("order",)
raw_id_fields = ("article",)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ProductsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.products"
verbose_name = "Products"
+93
View File
@@ -0,0 +1,93 @@
# Generated by Django 5.2.13 on 2026-04-27 09:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Article',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=300)),
('description', models.TextField()),
('order', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Article',
'verbose_name_plural': 'Articles',
'ordering': ['order', 'title'],
},
),
migrations.CreateModel(
name='MainProduct',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('slug', models.SlugField(blank=True, unique=True)),
('short_description', models.CharField(max_length=300)),
('description', models.TextField()),
('image', models.ImageField(blank=True, null=True, upload_to='products/main/')),
('order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Main Product',
'verbose_name_plural': 'Main Products',
'ordering': ['order', 'name'],
},
),
migrations.CreateModel(
name='ArticleSection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('value', models.TextField()),
('order', models.PositiveIntegerField(default=0)),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='products.article')),
],
options={
'verbose_name': 'Article Section',
'verbose_name_plural': 'Article Sections',
'ordering': ['order'],
},
),
migrations.CreateModel(
name='SubProduct',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('slug', models.SlugField(blank=True)),
('short_description', models.CharField(max_length=300)),
('description', models.TextField()),
('image', models.ImageField(blank=True, null=True, upload_to='products/sub/')),
('order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('main_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sub_products', to='products.mainproduct')),
],
options={
'verbose_name': 'Sub Product',
'verbose_name_plural': 'Sub Products',
'ordering': ['order', 'name'],
'unique_together': {('main_product', 'slug')},
},
),
migrations.AddField(
model_name='article',
name='sub_product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='products.subproduct'),
),
]
+111
View File
@@ -0,0 +1,111 @@
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
class MainProduct(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True, blank=True)
short_description = models.CharField(max_length=300)
description = models.TextField()
image = models.ImageField(upload_to="products/main/", blank=True, null=True)
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["order", "name"]
verbose_name = "Main Product"
verbose_name_plural = "Main Products"
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse("products:main_product_detail", kwargs={"main_slug": self.slug})
class SubProduct(models.Model):
main_product = models.ForeignKey(
MainProduct,
on_delete=models.CASCADE,
related_name="sub_products",
)
name = models.CharField(max_length=200)
slug = models.SlugField(blank=True)
short_description = models.CharField(max_length=300)
description = models.TextField()
image = models.ImageField(upload_to="products/sub/", blank=True, null=True)
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["order", "name"]
unique_together = [["main_product", "slug"]]
verbose_name = "Sub Product"
verbose_name_plural = "Sub Products"
def __str__(self):
return f"{self.main_product.name} {self.name}"
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse(
"products:sub_product_detail",
kwargs={
"main_slug": self.main_product.slug,
"sub_slug": self.slug,
},
)
class Article(models.Model):
sub_product = models.ForeignKey(
SubProduct,
on_delete=models.CASCADE,
related_name="articles",
)
title = models.CharField(max_length=300)
description = models.TextField()
order = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["order", "title"]
verbose_name = "Article"
verbose_name_plural = "Articles"
def __str__(self):
return self.title
class ArticleSection(models.Model):
article = models.ForeignKey(
Article,
on_delete=models.CASCADE,
related_name="sections",
)
title = models.CharField(max_length=200)
value = models.TextField()
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["order"]
verbose_name = "Article Section"
verbose_name_plural = "Article Sections"
def __str__(self):
return f"{self.article.title} {self.title}"
View File
+77
View File
@@ -0,0 +1,77 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
class AdminAccessTest(TestCase):
def setUp(self):
self.superuser = User.objects.create_superuser(
username="admin",
email="admin@example.com",
password="securepassword123",
)
self.client.login(username="admin", password="securepassword123")
self.main_product = MainProduct.objects.create(
name="Test Product",
short_description="Short",
description="Description",
)
self.sub_product = SubProduct.objects.create(
main_product=self.main_product,
name="Sub Product",
short_description="Short sub",
description="Sub description",
)
self.article = Article.objects.create(
sub_product=self.sub_product,
title="Test Article",
description="Article body",
)
self.section = ArticleSection.objects.create(
article=self.article,
title="Link",
value="https://example.com",
)
def test_main_product_changelist_accessible(self):
url = reverse("admin:products_mainproduct_changelist")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_sub_product_changelist_accessible(self):
url = reverse("admin:products_subproduct_changelist")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_article_changelist_accessible(self):
url = reverse("admin:products_article_changelist")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_article_section_changelist_accessible(self):
url = reverse("admin:products_articlesection_changelist")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_main_product_change_accessible(self):
url = reverse("admin:products_mainproduct_change", args=[self.main_product.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_sub_product_change_accessible(self):
url = reverse("admin:products_subproduct_change", args=[self.sub_product.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_article_change_accessible(self):
url = reverse("admin:products_article_change", args=[self.article.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_admin_requires_authentication(self):
self.client.logout()
url = reverse("admin:products_mainproduct_changelist")
response = self.client.get(url)
self.assertNotEqual(response.status_code, 200)
+134
View File
@@ -0,0 +1,134 @@
from django.test import TestCase
from django.urls import reverse
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
class MainProductModelTest(TestCase):
def setUp(self):
self.product = MainProduct.objects.create(
name="Test Product",
short_description="Short description",
description="Full description of the test product.",
)
def test_str_representation(self):
self.assertEqual(str(self.product), "Test Product")
def test_slug_auto_generated_on_create(self):
self.assertEqual(self.product.slug, "test-product")
def test_slug_not_overwritten_on_update(self):
self.product.name = "Changed Name"
self.product.save()
self.assertEqual(self.product.slug, "test-product")
def test_default_order_is_zero(self):
self.assertEqual(self.product.order, 0)
def test_default_is_active_is_true(self):
self.assertTrue(self.product.is_active)
def test_get_absolute_url(self):
url = self.product.get_absolute_url()
self.assertEqual(url, reverse("products:main_product_detail", kwargs={"main_slug": "test-product"}))
def test_slug_uniqueness(self):
from django.db import IntegrityError
with self.assertRaises(IntegrityError):
MainProduct.objects.create(
name="Test Product",
slug="test-product",
short_description="Another",
description="Another",
)
def test_timestamps_set_on_create(self):
self.assertIsNotNone(self.product.created_at)
self.assertIsNotNone(self.product.updated_at)
class SubProductModelTest(TestCase):
def setUp(self):
self.main_product = MainProduct.objects.create(
name="Main Product",
short_description="Short",
description="Description",
)
self.sub_product = SubProduct.objects.create(
main_product=self.main_product,
name="Sub Product",
short_description="Sub short",
description="Sub description.",
)
def test_str_representation(self):
self.assertIn("Main Product", str(self.sub_product))
self.assertIn("Sub Product", str(self.sub_product))
def test_slug_auto_generated(self):
self.assertEqual(self.sub_product.slug, "sub-product")
def test_get_absolute_url(self):
url = self.sub_product.get_absolute_url()
expected = reverse(
"products:sub_product_detail",
kwargs={"main_slug": "main-product", "sub_slug": "sub-product"},
)
self.assertEqual(url, expected)
def test_cascade_delete_with_main_product(self):
main_pk = self.main_product.pk
sub_pk = self.sub_product.pk
self.main_product.delete()
self.assertFalse(SubProduct.objects.filter(pk=sub_pk).exists())
self.assertFalse(MainProduct.objects.filter(pk=main_pk).exists())
class ArticleModelTest(TestCase):
def setUp(self):
self.main_product = MainProduct.objects.create(
name="Main",
short_description="Short",
description="Desc",
)
self.sub_product = SubProduct.objects.create(
main_product=self.main_product,
name="Sub",
short_description="Short",
description="Desc",
)
self.article = Article.objects.create(
sub_product=self.sub_product,
title="Test Article",
description="Article body.",
)
def test_str_representation(self):
self.assertEqual(str(self.article), "Test Article")
def test_cascade_delete_with_sub_product(self):
article_pk = self.article.pk
self.sub_product.delete()
self.assertFalse(Article.objects.filter(pk=article_pk).exists())
class ArticleSectionModelTest(TestCase):
def setUp(self):
main = MainProduct.objects.create(name="M", short_description="s", description="d")
sub = SubProduct.objects.create(main_product=main, name="S", short_description="s", description="d")
self.article = Article.objects.create(sub_product=sub, title="Article", description="Desc")
self.section = ArticleSection.objects.create(
article=self.article,
title="Link",
value="https://example.com",
)
def test_str_representation(self):
self.assertIn("Article", str(self.section))
self.assertIn("Link", str(self.section))
def test_cascade_delete_with_article(self):
section_pk = self.section.pk
self.article.delete()
self.assertFalse(ArticleSection.objects.filter(pk=section_pk).exists())
+137
View File
@@ -0,0 +1,137 @@
from django.test import TestCase
from django.urls import reverse
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
class ProductViewsSetup(TestCase):
def setUp(self):
self.main_product = MainProduct.objects.create(
name="ViSERA",
slug="visera",
short_description="Radiomics software",
description="Full description.",
)
self.sub_product = SubProduct.objects.create(
main_product=self.main_product,
name="Image Processing",
slug="image-processing",
short_description="Filtering and registration",
description="Sub description.",
)
self.article = Article.objects.create(
sub_product=self.sub_product,
title="Filtering",
description="Article body.",
)
ArticleSection.objects.create(
article=self.article,
title="Standard",
value="IBSI 2.0",
)
class ProductOverviewViewTest(ProductViewsSetup):
def test_overview_returns_200(self):
response = self.client.get(reverse("products:overview"))
self.assertEqual(response.status_code, 200)
def test_overview_uses_correct_template(self):
response = self.client.get(reverse("products:overview"))
self.assertTemplateUsed(response, "products/overview.html")
def test_overview_contains_main_product(self):
response = self.client.get(reverse("products:overview"))
self.assertIn(self.main_product, response.context["main_products"])
def test_inactive_product_excluded(self):
inactive = MainProduct.objects.create(
name="Inactive",
short_description="s",
description="d",
is_active=False,
)
response = self.client.get(reverse("products:overview"))
self.assertNotIn(inactive, response.context["main_products"])
class MainProductDetailViewTest(ProductViewsSetup):
def test_detail_returns_200(self):
url = reverse("products:main_product_detail", kwargs={"main_slug": "visera"})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_detail_uses_correct_template(self):
url = reverse("products:main_product_detail", kwargs={"main_slug": "visera"})
response = self.client.get(url)
self.assertTemplateUsed(response, "products/main_detail.html")
def test_detail_contains_product_in_context(self):
url = reverse("products:main_product_detail", kwargs={"main_slug": "visera"})
response = self.client.get(url)
self.assertEqual(response.context["main_product"], self.main_product)
def test_nonexistent_slug_returns_404(self):
url = reverse("products:main_product_detail", kwargs={"main_slug": "nonexistent"})
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_inactive_product_returns_404(self):
self.main_product.is_active = False
self.main_product.save()
url = reverse("products:main_product_detail", kwargs={"main_slug": "visera"})
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
class SubProductDetailViewTest(ProductViewsSetup):
def test_sub_detail_returns_200(self):
url = reverse(
"products:sub_product_detail",
kwargs={"main_slug": "visera", "sub_slug": "image-processing"},
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_sub_detail_uses_correct_template(self):
url = reverse(
"products:sub_product_detail",
kwargs={"main_slug": "visera", "sub_slug": "image-processing"},
)
response = self.client.get(url)
self.assertTemplateUsed(response, "products/sub_detail.html")
def test_sub_detail_context_keys(self):
url = reverse(
"products:sub_product_detail",
kwargs={"main_slug": "visera", "sub_slug": "image-processing"},
)
response = self.client.get(url)
self.assertIn("main_product", response.context)
self.assertIn("sub_product", response.context)
self.assertIn("articles", response.context)
self.assertIn("siblings", response.context)
def test_sub_detail_articles_in_context(self):
url = reverse(
"products:sub_product_detail",
kwargs={"main_slug": "visera", "sub_slug": "image-processing"},
)
response = self.client.get(url)
self.assertIn(self.article, list(response.context["articles"]))
def test_nonexistent_sub_slug_returns_404(self):
url = reverse(
"products:sub_product_detail",
kwargs={"main_slug": "visera", "sub_slug": "does-not-exist"},
)
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_mismatched_main_slug_returns_404(self):
url = reverse(
"products:sub_product_detail",
kwargs={"main_slug": "wrong-product", "sub_slug": "image-processing"},
)
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
+19
View File
@@ -0,0 +1,19 @@
from django.urls import path
from . import views
app_name = "products"
urlpatterns = [
path("", views.ProductOverviewView.as_view(), name="overview"),
path(
"<slug:main_slug>/",
views.MainProductDetailView.as_view(),
name="main_product_detail",
),
path(
"<slug:main_slug>/<slug:sub_slug>/",
views.SubProductDetailView.as_view(),
name="sub_product_detail",
),
]
+53
View File
@@ -0,0 +1,53 @@
from django.shortcuts import get_object_or_404
from django.views.generic import DetailView, ListView, TemplateView
from .models import MainProduct, SubProduct
class ProductOverviewView(ListView):
model = MainProduct
template_name = "products/overview.html"
context_object_name = "main_products"
queryset = MainProduct.objects.filter(is_active=True).prefetch_related(
"sub_products"
)
class MainProductDetailView(DetailView):
model = MainProduct
template_name = "products/main_detail.html"
context_object_name = "main_product"
slug_url_kwarg = "main_slug"
queryset = MainProduct.objects.filter(is_active=True).prefetch_related(
"sub_products"
)
class SubProductDetailView(TemplateView):
template_name = "products/sub_detail.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
main_product = get_object_or_404(
MainProduct,
slug=self.kwargs["main_slug"],
is_active=True,
)
sub_product = get_object_or_404(
SubProduct,
slug=self.kwargs["sub_slug"],
main_product=main_product,
is_active=True,
)
context["main_product"] = main_product
context["sub_product"] = sub_product
context["articles"] = sub_product.articles.prefetch_related("sections").all()
context["siblings"] = (
SubProduct.objects.filter(
main_product=main_product,
is_active=True,
)
.exclude(pk=sub_product.pk)
.order_by("order", "name")
)
return context