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
+16
View File
@@ -0,0 +1,16 @@
.git
.gitignore
.env
.env.local
.venv
venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
htmlcov/
.coverage
media/
staticfiles/
README.md
docs/
+12
View File
@@ -0,0 +1,12 @@
DJANGO_SETTINGS_MODULE=config.settings.development
DJANGO_SECRET_KEY=django-insecure-replace-this-with-a-real-secret-key-in-production
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
POSTGRES_DB=tecvico
POSTGRES_USER=tecvico_user
POSTGRES_PASSWORD=tecvico_password
POSTGRES_HOST=db
POSTGRES_PORT=5432
SECURE_SSL_REDIRECT=False
+49
View File
@@ -0,0 +1,49 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
dist/
*.egg-info/
.installed.cfg
*.egg
pip-log.txt
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
.pytest_cache/
nosetests.xml
coverage.xml
*.cover
*.log
local_settings.py
db.sqlite3
instance/
.scrapy
docs/_build/
__pypackages__/
celerybeat-schedule
celerybeat.pid
.env
.env.local
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.mypy_cache/
.dmypy.json
media/
staticfiles/
+31
View File
@@ -0,0 +1,31 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
DJANGO_SETTINGS_MODULE=config.settings.production
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-"]
+277 -1
View File
@@ -1,2 +1,278 @@
# Radiuma_Website
# Tecvico Website
Django MVT informational website for **Tecvico Corp** (formerly Visera), showcasing the **ViSERA** medical imaging and radiomics software suite.
## Tech Stack
| Layer | Technology |
|---|---|
| Backend | Django 5.1 (MVT) |
| Database | PostgreSQL 16 |
| Static files | WhiteNoise (with Brotli compression) |
| Application server | Gunicorn |
| Containerization | Docker + Docker Compose |
| Frontend | Vanilla HTML/CSS/JS (no framework) |
## Project Structure
```
tecvico_website/
├── config/ # Django project configuration
│ └── settings/
│ ├── base.py # Shared settings
│ ├── development.py # Dev settings (DEBUG=True, dotenv)
│ └── production.py # Production settings (security headers)
├── apps/
│ ├── core/ # Context processors, management commands
│ │ └── management/commands/seed_content.py
│ ├── products/ # MainProduct, SubProduct, Article, ArticleSection
│ └── pages/ # FAQEntry, DownloadItem; static pages
├── templates/ # Global templates
│ ├── base.html
│ ├── partials/
│ └── pages/ & products/
├── static/
│ ├── css/main.css # Full design system (dark glass/ice theme)
│ └── js/main.js # Navbar, FAQ accordion, scroll effects
├── Dockerfile
├── docker-compose.yml # Production compose
├── docker-compose.override.yml # Development compose overrides
└── entrypoint.sh # DB wait + migrate on container start
```
## URL Map
| URL | View | Description |
|---|---|---|
| `/` | `HomeView` | Landing page |
| `/about/` | `AboutView` | What is Tecvico |
| `/products/` | `ProductOverviewView` | All main products |
| `/products/<main-slug>/` | `MainProductDetailView` | Main product + sub-products |
| `/products/<main-slug>/<sub-slug>/` | `SubProductDetailView` | Sub-product + articles |
| `/downloads/` | `DownloadsView` | Download items by platform |
| `/faq/` | `FAQView` | FAQ entries |
| `/contact/` | `ContactView` | Contact info |
| `/admin/` | Django Admin | Admin panel |
## Data Model
```
MainProduct
└── SubProduct (FK → MainProduct)
└── Article (FK → SubProduct)
└── ArticleSection (FK → Article) ← key/value metadata
FAQEntry ← admin-managed FAQ items
DownloadItem ← admin-managed download links per platform
```
---
## Local Development (with Docker)
### Prerequisites
- Docker Desktop (or Docker Engine + Compose plugin)
### 1. Clone & configure
```bash
git clone <repo-url> tecvico_website
cd tecvico_website
cp .env.example .env
```
Edit `.env` and set at minimum:
```
DJANGO_SECRET_KEY=your-very-secure-random-key
POSTGRES_PASSWORD=choose-a-strong-password
```
### 2. Start services (development mode)
The `docker-compose.override.yml` automatically activates when you run `docker compose up`, mounting the source code and using the development settings.
```bash
docker compose up --build
```
The app is available at **http://localhost:8000**
### 3. Create a superuser
```bash
docker compose exec web python manage.py createsuperuser
```
### 4. Seed initial content
Populate the database with content scraped and adapted from visera.ca:
```bash
docker compose exec web python manage.py seed_content
```
To flush and re-seed from scratch:
```bash
docker compose exec web python manage.py seed_content --flush
```
---
## Local Development (without Docker)
### Prerequisites
- Python 3.12+
- PostgreSQL 14+
### 1. Set up virtual environment
```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements-dev.txt
```
### 2. Configure environment
```bash
cp .env.example .env
```
Edit `.env`:
```
DJANGO_SECRET_KEY=your-key
POSTGRES_DB=tecvico
POSTGRES_USER=your_pg_user
POSTGRES_PASSWORD=your_pg_password
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
```
### 3. Create the database
```bash
createdb tecvico
```
### 4. Run migrations & seed
```bash
python manage.py migrate
python manage.py seed_content
python manage.py createsuperuser
```
### 5. Start development server
```bash
python manage.py runserver
```
---
## Running Tests
### With Django test runner
```bash
python manage.py test apps
```
### With pytest (requires `requirements-dev.txt`)
```bash
pytest
```
### With coverage report
```bash
coverage run -m pytest
coverage report -m
coverage html # Generates htmlcov/index.html
```
---
## Production Deployment
### 1. Build and start production containers
Remove `docker-compose.override.yml` (or don't override it) and pass production environment variables:
```bash
DJANGO_SETTINGS_MODULE=config.settings.production \
docker compose -f docker-compose.yml up --build -d
```
### 2. Production `.env` checklist
| Variable | Notes |
|---|---|
| `DJANGO_SECRET_KEY` | Use `python -c "import secrets; print(secrets.token_urlsafe(50))"` |
| `DEBUG` | Must be `False` |
| `ALLOWED_HOSTS` | Comma-separated: `yourdomain.com,www.yourdomain.com` |
| `CSRF_TRUSTED_ORIGINS` | `https://yourdomain.com` |
| `POSTGRES_PASSWORD` | Strong random password |
| `SECURE_SSL_REDIRECT` | `True` when behind TLS termination |
### 3. Reverse proxy (recommended)
Place an Nginx or Caddy reverse proxy in front of Gunicorn for TLS termination and serving static files (or let WhiteNoise handle statics directly).
---
## Admin Panel
Access Django Admin at `/admin/` with superuser credentials.
### What you can manage
| Model | Description |
|---|---|
| **Main Products** | Top-level products with nested sub-products inline |
| **Sub Products** | Modules within a main product; articles editable inline |
| **Articles** | Article entries with section key/values inline |
| **Article Sections** | Individual key-value metadata rows |
| **FAQ Entries** | Accordion FAQ items (order, active toggle) |
| **Download Items** | Platform download links (Windows/macOS/Linux) |
---
## Seed Content
The `seed_content` command populates:
- **ViSERA** (MainProduct) with 5 sub-products:
- Image Processing
- Radiomics Features
- Medical Image Visualization
- Format Conversion
- Workflow Management
- Articles and sections for each sub-product
- 6 FAQ entries
- 3 download items (Windows active, macOS/Linux coming soon)
Content is adapted from the original [visera.ca](https://visera.ca) website.
---
## Design System
The website uses a custom dark-glass design combining:
- **visera.ca** aesthetic — dark background, organic blob animations, blue/teal accents
- **Apple visionOS Ice** aesthetic — frosted glass panels (`backdrop-filter: blur`), translucent cards, soft gradients
Key CSS custom properties are in `static/css/main.css` under `:root`.
---
## License
Content is adapted from visera.ca under CC BY-NC-SA. Software code is proprietary to Tecvico Corp.
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
View File
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
application = get_asgi_application()
View File
+128
View File
@@ -0,0 +1,128 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = os.environ.get(
"DJANGO_SECRET_KEY",
"django-insecure-base-key-override-in-production",
)
DEBUG = False
ALLOWED_HOSTS = []
DJANGO_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
]
LOCAL_APPS = [
"apps.core",
"apps.products",
"apps.pages",
]
INSTALLED_APPS = DJANGO_APPS + LOCAL_APPS
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"apps.core.context_processors.navigation",
],
},
},
]
WSGI_APPLICATION = "config.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_DB", "tecvico"),
"USER": os.environ.get("POSTGRES_USER", "tecvico_user"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
"CONN_MAX_AGE": 60,
"OPTIONS": {
"connect_timeout": 10,
},
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"root": {
"handlers": ["console"],
"level": "WARNING",
},
"loggers": {
"django": {
"handlers": ["console"],
"level": os.environ.get("DJANGO_LOG_LEVEL", "WARNING"),
"propagate": False,
},
},
}
+37
View File
@@ -0,0 +1,37 @@
import os
from dotenv import load_dotenv
load_dotenv()
from .base import * # noqa: F401, F403, E402
DEBUG = True
SECRET_KEY = os.environ.get(
"DJANGO_SECRET_KEY",
"django-insecure-development-key-not-for-production-use",
)
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(",")
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {"class": "logging.StreamHandler"},
},
"root": {
"handlers": ["console"],
"level": "DEBUG",
},
"loggers": {
"django": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
},
}
+25
View File
@@ -0,0 +1,25 @@
import os
from .base import * # noqa: F401, F403
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = False
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",")
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_SSL_REDIRECT = os.environ.get("SECURE_SSL_REDIRECT", "True") == "True"
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
CSRF_TRUSTED_ORIGINS = [
origin.strip()
for origin in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")
if origin.strip()
]
+19
View File
@@ -0,0 +1,19 @@
import os
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
admin.site.site_header = "Tecvico Administration"
admin.site.site_title = "Tecvico Admin"
admin.site.index_title = "Welcome to Tecvico Administration"
urlpatterns = [
path("admin/", admin.site.urls),
path("products/", include("apps.products.urls", namespace="products")),
path("", include("apps.pages.urls", namespace="pages")),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
application = get_wsgi_application()
+9
View File
@@ -0,0 +1,9 @@
services:
web:
build: .
environment:
DJANGO_SETTINGS_MODULE: config.settings.development
volumes:
- .:/app
- media_data:/app/media
command: python manage.py runserver 0.0.0.0:8000
+37
View File
@@ -0,0 +1,37 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
env_file:
- .env
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
web:
build: .
restart: unless-stopped
ports:
- "8000:8000"
env_file:
- .env
environment:
DJANGO_SETTINGS_MODULE: config.settings.production
POSTGRES_HOST: db
volumes:
- media_data:/app/media
depends_on:
db:
condition: service_healthy
volumes:
postgres_data:
media_data:
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
set -e
echo "Waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}..."
until python -c "
import os, psycopg2, sys
try:
psycopg2.connect(
dbname=os.environ.get('POSTGRES_DB', 'tecvico'),
user=os.environ.get('POSTGRES_USER', 'tecvico_user'),
password=os.environ.get('POSTGRES_PASSWORD', ''),
host=os.environ.get('POSTGRES_HOST', 'db'),
port=os.environ.get('POSTGRES_PORT', '5432'),
)
sys.exit(0)
except Exception:
sys.exit(1)
"; do
echo "Database not ready. Retrying in 2 seconds..."
sleep 2
done
echo "PostgreSQL is ready."
echo "Running database migrations..."
python manage.py migrate --noinput
echo "Starting application..."
exec "$@"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env python
import os
import sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
+6
View File
@@ -0,0 +1,6 @@
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.development
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
pytest>=8.3.0
pytest-django>=4.9.0
coverage>=7.6.0
+6
View File
@@ -0,0 +1,6 @@
Django>=5.1,<6.0
psycopg2-binary>=2.9.9
gunicorn>=22.0.0
whitenoise[brotli]>=6.7.0
Pillow>=10.4.0
python-dotenv>=1.0.1
+1942
View File
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
'use strict';
const SELECTORS = {
navbar: '#navbar',
navbarToggle: '#navbarToggle',
navbarMenu: '#navbarMenu',
productsNavItem: '#productsNavItem',
faqItems: '.faq-item',
faqQuestion: '.faq-question',
faqAnswer: '.faq-answer',
fadeInElements: '.fade-in',
};
function initNavbarScroll() {
const navbar = document.querySelector(SELECTORS.navbar);
if (!navbar) return;
const onScroll = () => {
if (window.scrollY > 20) {
navbar.classList.add('scrolled');
} else {
navbar.classList.remove('scrolled');
}
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
}
function initMobileMenu() {
const toggle = document.querySelector(SELECTORS.navbarToggle);
const menu = document.querySelector(SELECTORS.navbarMenu);
if (!toggle || !menu) return;
toggle.addEventListener('click', () => {
const isOpen = menu.classList.toggle('open');
toggle.classList.toggle('open', isOpen);
toggle.setAttribute('aria-expanded', String(isOpen));
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && menu.classList.contains('open')) {
menu.classList.remove('open');
toggle.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
toggle.focus();
}
});
document.addEventListener('click', (e) => {
if (!menu.contains(e.target) && !toggle.contains(e.target)) {
menu.classList.remove('open');
toggle.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
}
});
}
function initMegaMenu() {
const productsItem = document.querySelector(SELECTORS.productsNavItem);
if (!productsItem) return;
const trigger = productsItem.querySelector('.nav-link');
const megamenu = productsItem.querySelector('.megamenu');
if (!trigger || !megamenu) return;
productsItem.addEventListener('mouseenter', () => {
productsItem.classList.add('open');
trigger.setAttribute('aria-expanded', 'true');
});
productsItem.addEventListener('mouseleave', () => {
productsItem.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false');
});
trigger.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const isOpen = productsItem.classList.toggle('open');
trigger.setAttribute('aria-expanded', String(isOpen));
}
if (e.key === 'Escape') {
productsItem.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false');
}
});
}
function initFAQAccordion() {
const faqItems = document.querySelectorAll(SELECTORS.faqItems);
faqItems.forEach((item) => {
const question = item.querySelector(SELECTORS.faqQuestion);
const answer = item.querySelector(SELECTORS.faqAnswer);
if (!question || !answer) return;
question.addEventListener('click', () => {
const isOpen = item.classList.contains('open');
faqItems.forEach((other) => {
if (other !== item) {
other.classList.remove('open');
const otherQuestion = other.querySelector(SELECTORS.faqQuestion);
const otherAnswer = other.querySelector(SELECTORS.faqAnswer);
if (otherQuestion) otherQuestion.setAttribute('aria-expanded', 'false');
if (otherAnswer) otherAnswer.hidden = true;
}
});
item.classList.toggle('open', !isOpen);
question.setAttribute('aria-expanded', String(!isOpen));
answer.hidden = isOpen;
});
});
}
function initIntersectionObserver() {
const elements = document.querySelectorAll(SELECTORS.fadeInElements);
if (!elements.length) return;
if (!('IntersectionObserver' in window)) {
elements.forEach((el) => el.classList.add('visible'));
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.12, rootMargin: '0px 0px -40px 0px' }
);
elements.forEach((el, index) => {
el.style.transitionDelay = `${index * 0.06}s`;
observer.observe(el);
});
}
function init() {
initNavbarScroll();
initMobileMenu();
initMegaMenu();
initFAQAccordion();
initIntersectionObserver();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
+28
View File
@@ -0,0 +1,28 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{% block meta_description %}Tecvico Corp — Advanced Medical Imaging & Radiomics Solutions{% endblock %}" />
<title>{% block title %}Tecvico Corp{% endblock %} | Tecvico</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{% static 'css/main.css' %}" />
{% block extra_css %}{% endblock %}
</head>
<body>
{% include "partials/_navbar.html" %}
<main id="main-content">
{% block content %}{% endblock %}
</main>
{% include "partials/_footer.html" %}
<script src="{% static 'js/main.js' %}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
{% extends "base.html" %}
{% load static %}
{% block title %}What is Tecvico{% endblock %}
{% block meta_description %}Learn about Tecvico Corp and ViSERA — Visualized & Standardized Environment for Radiomics Analysis, developed at UBC and BC Cancer Research Institute.{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="page-hero-heading">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
</div>
<div class="container">
<div class="page-hero-content">
<div class="section-badge">About</div>
<h1 class="page-hero-title" id="page-hero-heading">What is Tecvico?</h1>
<p class="page-hero-subtitle">
Visualized &amp; Standardized Environment for Radiomics Analysis
</p>
</div>
</div>
</section>
<section class="section" aria-labelledby="about-intro-heading">
<div class="container">
<div class="about-intro glass-card fade-in">
<div class="about-intro-text">
<h2 id="about-intro-heading">Desktop Software for Medical Imaging Research</h2>
<ul class="about-list">
<li>Desktop software to improve usability, reusability, and reproducibility in medical imaging &amp; healthcare research.</li>
<li>Development platform to create reproducible research workflows by connecting different tools.</li>
<li>Useful for collaborative research projects and for ensuring consistency across different studies.</li>
<li>User-friendly for different expertise levels, including radiation oncologists, radiologists, physicists &amp; data scientists.</li>
</ul>
</div>
</div>
</div>
</section>
<section class="section" aria-labelledby="standardization-heading">
<div class="container">
<div class="section-header">
<div class="section-badge">Standards</div>
<h2 class="section-title" id="standardization-heading">Standardization</h2>
</div>
<div class="standards-grid">
<div class="standard-card glass-card fade-in">
<div class="standard-badge">IBSI 1.0</div>
<h3 class="standard-title">Radiomic Feature Extraction</h3>
<p class="standard-desc">
ViSERA is a python-based open-source package that enables standardized and
reproducible radiomic feature extraction in compliance with the Image Biomarker
Standardization Initiative (IBSI 1.0).
</p>
</div>
<div class="standard-card glass-card fade-in">
<div class="standard-badge">IBSI 2.0</div>
<h3 class="standard-title">Image Filtering</h3>
<p class="standard-desc">
Image filters have been standardized against IBSI 2.0 by implementing and
validating several filter options, ensuring reproducibility across research
institutions worldwide.
</p>
</div>
<div class="standard-card glass-card fade-in">
<div class="standard-badge">Python</div>
<h3 class="standard-title">Open Source</h3>
<p class="standard-desc">
ViSERA is a major, entirely-revamped upgrade to the original SERA (Matlab-based),
now built on Python for broader accessibility and community contribution.
</p>
</div>
<div class="standard-card glass-card fade-in">
<div class="standard-badge">End-to-End</div>
<h3 class="standard-title">Standardized Workflows</h3>
<p class="standard-desc">
ViSERA employs a number of popular image processing algorithms to create
end-to-end standardized workflows for consistent, reproducible research results.
</p>
</div>
</div>
</div>
</section>
<section class="section" aria-labelledby="history-heading">
<div class="container">
<div class="history-block glass-card fade-in">
<div class="history-content">
<div class="section-badge">History</div>
<h2 id="history-heading">Our Origins</h2>
<p>
Tecvico (formerly known as Visera) has been developing since 2021 by the
Quantitative Radiomolecular Imaging and Therapy (Qurit) lab &amp; program at the
University of British Columbia &amp; BC Cancer Research Institute, Vancouver, BC, Canada.
</p>
<p>
Our mission is to bridge the gap between cutting-edge radiomics research and
practical clinical application by providing standardized, reproducible, and
user-friendly software tools.
</p>
<div class="history-links">
<a href="https://www.qurit.ca" class="btn-ghost" target="_blank" rel="noopener noreferrer">Qurit Lab</a>
<a href="https://www.ubc.ca" class="btn-ghost" target="_blank" rel="noopener noreferrer">UBC</a>
<a href="https://www.bccrc.ca" class="btn-ghost" target="_blank" rel="noopener noreferrer">BC Cancer</a>
</div>
</div>
<div class="history-visual" aria-hidden="true">
<div class="history-blob history-blob--1"></div>
<div class="history-blob history-blob--2"></div>
<div class="history-year">2021</div>
</div>
</div>
</div>
</section>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Contact{% endblock %}
{% block meta_description %}Contact Tecvico Corp — support for ViSERA software and general inquiries.{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="contact-heading">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
</div>
<div class="container">
<div class="page-hero-content">
<div class="section-badge">Get in Touch</div>
<h1 class="page-hero-title" id="contact-heading">Contact Us</h1>
<p class="page-hero-subtitle">Support for ViSERA software is available via email and Discord.</p>
</div>
</div>
</section>
<section class="section" aria-labelledby="contact-details-heading">
<div class="container">
<h2 class="sr-only" id="contact-details-heading">Contact Details</h2>
<div class="contact-grid">
<div class="contact-card glass-card fade-in">
<div class="contact-icon" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<path d="M5 8C5 6.895 5.895 6 7 6H25C26.105 6 27 6.895 27 8V22C27 23.105 26.105 24 25 24H7C5.895 24 5 23.105 5 22V8Z" stroke="currentColor" stroke-width="1.5"/>
<path d="M5 9L16 16L27 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
<h3 class="contact-card-title">Email Support</h3>
<p class="contact-card-desc">
For software support and general inquiries, reach us at:
</p>
<a href="mailto:support@tecvico.com" class="contact-email-link">
support@tecvico.com
</a>
</div>
<div class="contact-card glass-card fade-in">
<div class="contact-icon" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
</svg>
</div>
<h3 class="contact-card-title">Discord Community</h3>
<p class="contact-card-desc">
Join our Discord server for community support, feature discussions, and announcements.
</p>
<a href="https://discord.gg/9XxA6pV9hb" class="btn-primary" target="_blank" rel="noopener noreferrer">
Join Discord Forum
</a>
</div>
<div class="contact-card glass-card fade-in">
<div class="contact-icon" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<path d="M16 3C11.029 3 7 7.029 7 12C7 19 16 29 16 29C16 29 25 19 25 12C25 7.029 20.971 3 16 3Z" stroke="currentColor" stroke-width="1.5"/>
<circle cx="16" cy="12" r="3" stroke="currentColor" stroke-width="1.5"/>
</svg>
</div>
<h3 class="contact-card-title">Office Address</h3>
<address class="contact-address">
<p>BC Cancer Research Center</p>
<p>675 West 10th Ave</p>
<p>Office 6-112</p>
<p>Vancouver, BC, V5Z 1L3</p>
<p>Canada</p>
</address>
</div>
</div>
</div>
</section>
{% endblock %}
+130
View File
@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Downloads{% endblock %}
{% block meta_description %}Download ViSERA by Tecvico — available for Windows, macOS, and Linux.{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="downloads-heading">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
</div>
<div class="container">
<div class="page-hero-content">
<div class="section-badge">Software</div>
<h1 class="page-hero-title" id="downloads-heading">Downloads</h1>
<p class="page-hero-subtitle">Download the latest version of ViSERA for your platform.</p>
</div>
</div>
</section>
<section class="section" aria-labelledby="download-platforms-heading">
<div class="container">
<h2 class="sr-only" id="download-platforms-heading">Available Platforms</h2>
<div class="downloads-grid">
<div class="download-card glass-card fade-in {% if not windows_items %}download-card--unavailable{% endif %}">
<div class="download-platform-icon" aria-hidden="true">
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
<path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801"/>
</svg>
</div>
<h3 class="download-platform-name">Windows</h3>
<p class="download-platform-desc">Windows 10 and up (64-bit)</p>
{% if windows_items %}
{% for item in windows_items %}
<div class="download-item">
<span class="download-version">v{{ item.version }}</span>
{% if item.description %}<p class="download-item-desc">{{ item.description }}</p>{% endif %}
{% if item.download_url and item.download_url != '#' %}
<a href="{{ item.download_url }}" class="btn-primary download-btn" download>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M9 2V12M9 12L5 8M9 12L13 8M3 15H15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Download
</a>
{% endif %}
</div>
{% endfor %}
{% else %}
<span class="coming-soon-badge">Coming Soon</span>
{% endif %}
</div>
<div class="download-card glass-card fade-in {% if not macos_items %}download-card--unavailable{% endif %}">
<div class="download-platform-icon" aria-hidden="true">
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
</svg>
</div>
<h3 class="download-platform-name">macOS</h3>
<p class="download-platform-desc">macOS 11 Big Sur and up</p>
{% if macos_items %}
{% for item in macos_items %}
<div class="download-item">
<span class="download-version">v{{ item.version }}</span>
{% if item.description %}<p class="download-item-desc">{{ item.description }}</p>{% endif %}
{% if item.download_url and item.download_url != '#' %}
<a href="{{ item.download_url }}" class="btn-primary download-btn" download>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M9 2V12M9 12L5 8M9 12L13 8M3 15H15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Download
</a>
{% endif %}
</div>
{% endfor %}
{% else %}
<span class="coming-soon-badge">Coming Soon</span>
{% endif %}
</div>
<div class="download-card glass-card fade-in {% if not linux_items %}download-card--unavailable{% endif %}">
<div class="download-platform-icon" aria-hidden="true">
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.EraseINIT.3.337.572.95.23 1.681-.078.277-.21.557-.303.815-.125.35-.169.63-.046.875.315.648 1.086.698 1.728.hyperparameter698.256 0 .504-.034.72-.046.372-.022.626.026.783.17.428.39.405 1.353.504 2.408.096.99.184 2.023.618 2.813.22.391.528.717.938.939.41.22.915.316 1.44.282.52-.033 1.092-.218 1.544-.668.296-.296.522-.723.58-1.226.029-.248.017-.51-.02-.77-.15-1.168.056-2.22.617-2.82.274-.294.548-.452.748-.606.238-.186.427-.41.554-.676.247-.517.194-1.107-.026-1.633-.11-.271-.238-.517-.324-.773-.148-.448-.172-.913.053-1.377.226-.467.67-.876 1.02-1.428a.424.424 0 00.11-.134c.25-.43.31-.915.245-1.395-.065-.476-.245-.937-.49-1.37-.245-.436-.54-.838-.807-1.278-.254-.428-.47-.903-.522-1.445-.1-.994.268-2.1.368-3.158.05-.53.04-1.046-.059-1.518-.097-.47-.3-.9-.616-1.24-.636-.68-1.608-.904-2.527-.974-.45-.035-.899-.026-1.334.026zm.042 1.174c.42-.047.852-.056 1.277-.022.812.062 1.59.262 2.047.758.228.245.37.554.44.918.073.366.08.78.03 1.233-.103 1.055-.486 2.198-.36 3.34.061.567.302 1.09.575 1.56.27.46.572.876.822 1.319.25.443.43.904.483 1.362.053.45.003.895-.2 1.269a.37.37 0 00-.099.11c-.35.56-.83.993-1.1 1.57-.28.587-.295 1.218-.12 1.822.088.3.215.585.33.869.217.526.253 1.015.066 1.4a1.088 1.088 0 01-.354.43c-.22.162-.502.322-.806.638-.7.742-.924 1.963-.756 3.254.028.207.038.414.013.605-.045.38-.19.688-.41.908-.33.335-.765.497-1.178.524-.414.027-.83-.054-1.14-.22-.315-.169-.54-.43-.691-.714-.358-.647-.445-1.572-.54-2.553-.1-1.012-.118-2.066-.67-2.593-.269-.247-.645-.345-1.117-.32-.232.014-.455.047-.677.047-.44 0-.836-.054-.97-.33-.065-.133-.073-.36.043-.688.1-.29.238-.586.292-.866.133-.676.05-1.313-.313-1.75-.364-.44-1.027-.604-1.718-.777-.69-.176-1.44-.396-1.582-.862-.092-.302-.06-.666.11-1.127.087-.23.097-.482.054-.727-.083-.499.053-.986.22-1.427.246-.66 1.463-2.382 2.39-3.479.87-1.044 1.15-2.02 1.22-3.167.063-1.26-.744-5.02 2.57-5.27z"/>
</svg>
</div>
<h3 class="download-platform-name">Linux</h3>
<p class="download-platform-desc">Ubuntu 20.04+ and compatible distributions</p>
{% if linux_items %}
{% for item in linux_items %}
<div class="download-item">
<span class="download-version">v{{ item.version }}</span>
{% if item.description %}<p class="download-item-desc">{{ item.description }}</p>{% endif %}
{% if item.download_url and item.download_url != '#' %}
<a href="{{ item.download_url }}" class="btn-primary download-btn" download>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M9 2V12M9 12L5 8M9 12L13 8M3 15H15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Download
</a>
{% endif %}
</div>
{% endfor %}
{% else %}
<span class="coming-soon-badge">Coming Soon</span>
{% endif %}
</div>
</div>
</div>
</section>
<section class="section" aria-labelledby="reinstall-heading">
<div class="container">
<div class="info-block glass-card fade-in">
<h2 id="reinstall-heading">Installation Notes</h2>
<p>
If you have installed an older version of ViSERA, you can install the new version
over it without removing the previous installation. However, if you encounter any
problems, please remove the old version first before reinstalling.
</p>
<a href="{% url 'pages:faq' %}" class="btn-ghost">View Full FAQ</a>
</div>
</div>
</section>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% load static %}
{% block title %}FAQ{% endblock %}
{% block meta_description %}Frequently asked questions about ViSERA by Tecvico — licensing, citation, system requirements, and more.{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="faq-heading">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
</div>
<div class="container">
<div class="page-hero-content">
<div class="section-badge">Help</div>
<h1 class="page-hero-title" id="faq-heading">FAQ</h1>
<p class="page-hero-subtitle">Frequently Asked Questions</p>
</div>
</div>
</section>
<section class="section" aria-labelledby="faq-list-heading">
<div class="container">
<h2 class="sr-only" id="faq-list-heading">FAQ List</h2>
{% if faq_entries %}
<div class="faq-list">
{% for entry in faq_entries %}
<div class="faq-item glass-card fade-in">
<button
class="faq-question"
aria-expanded="false"
aria-controls="faq-answer-{{ entry.pk }}"
id="faq-question-{{ entry.pk }}"
>
{{ entry.question }}
<svg class="faq-icon" width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 8L10 13L15 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div
class="faq-answer"
id="faq-answer-{{ entry.pk }}"
role="region"
aria-labelledby="faq-question-{{ entry.pk }}"
hidden
>
<p>{{ entry.answer }}</p>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-state">No FAQ entries available yet.</p>
{% endif %}
<div class="faq-contact glass-card fade-in">
<h2>Still Have Questions?</h2>
<p>Can't find the answer you're looking for? Get in touch with our support team.</p>
<a href="{% url 'pages:contact' %}" class="btn-primary">Contact Support</a>
</div>
</div>
</section>
{% endblock %}
+188
View File
@@ -0,0 +1,188 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Home{% endblock %}
{% block meta_description %}Tecvico Corp — A Powerful Workflow Generator for Standardized Radiomics Analysis and Medical Image Visualization.{% endblock %}
{% block content %}
<section class="hero" aria-labelledby="hero-heading">
<div class="hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
<div class="blob blob--3"></div>
</div>
<div class="container hero-content">
<div class="hero-badge">Developing since 2021</div>
<h1 class="hero-title" id="hero-heading">
Tecvico,
<br />
<span class="gradient-text">A Powerful Workflow Generator</span>
</h1>
<p class="hero-subtitle">
for Standardized Radiomics Analysis and Medical Image Visualization
</p>
<p class="hero-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.
</p>
<div class="hero-actions">
<a href="{% url 'pages:downloads' %}" class="btn-primary">Download Now</a>
<a href="{% url 'pages:about' %}" class="btn-ghost">About Tecvico</a>
</div>
</div>
</section>
<section class="section features-section" aria-labelledby="features-heading">
<div class="container">
<div class="section-header">
<div class="section-badge">Capabilities</div>
<h2 class="section-title" id="features-heading">Important Features</h2>
<p class="section-desc">
Comprehensive tools for medical imaging research, standardized and reproducible.
</p>
</div>
<div class="features-grid">
{% for feature in features %}
<div class="feature-card glass-card fade-in">
<div class="feature-icon" aria-hidden="true">{{ feature.icon }}</div>
<h3 class="feature-title">{{ feature.title }}</h3>
<p class="feature-desc">{{ feature.desc }}</p>
</div>
{% empty %}
<div class="feature-card glass-card fade-in">
<div class="feature-icon" aria-hidden="true">⚗️</div>
<h3 class="feature-title">Image Filtering</h3>
<p class="feature-desc">Standardized image filtering techniques compliant with IBSI 2.0 guidelines.</p>
</div>
<div class="feature-card glass-card fade-in">
<div class="feature-icon" aria-hidden="true">🖥️</div>
<h3 class="feature-title">Professional Viewer</h3>
<p class="feature-desc">Comfortable, professional medical image viewer with multi-modality support.</p>
</div>
<div class="feature-card glass-card fade-in">
<div class="feature-icon" aria-hidden="true">📊</div>
<h3 class="feature-title">Radiomics Features</h3>
<p class="feature-desc">Handcrafted radiomics feature generation standardized by IBSI 1.0.</p>
</div>
<div class="feature-card glass-card fade-in">
<div class="feature-icon" aria-hidden="true">🔄</div>
<h3 class="feature-title">Format Support</h3>
<p class="feature-desc">NIFTI, DICOM, NRRD, and more — comprehensive multi-format support.</p>
</div>
<div class="feature-card glass-card fade-in">
<div class="feature-icon" aria-hidden="true">🗂️</div>
<h3 class="feature-title">Image Registration</h3>
<p class="feature-desc">Advanced image registration, fusion, and standardized SUV conversion.</p>
</div>
<div class="feature-card glass-card fade-in">
<div class="feature-icon" aria-hidden="true">🔬</div>
<h3 class="feature-title">RT Struct Support</h3>
<p class="feature-desc">Full RT struct support for radiation oncology workflows and research.</p>
</div>
{% endfor %}
</div>
</div>
</section>
{% if nav_main_products %}
<section class="section products-section" aria-labelledby="products-heading">
<div class="container">
<div class="section-header">
<div class="section-badge">Our Software</div>
<h2 class="section-title" id="products-heading">Products</h2>
<p class="section-desc">Explore our suite of medical imaging and radiomics tools.</p>
</div>
<div class="products-grid">
{% for product in nav_main_products %}
<a href="{{ product.get_absolute_url }}" class="product-card glass-card fade-in">
{% if product.image %}
<div class="product-card-image">
<img src="{{ product.image.url }}" alt="{{ product.name }}" loading="lazy" />
</div>
{% endif %}
<div class="product-card-body">
<h3 class="product-card-title">{{ product.name }}</h3>
<p class="product-card-desc">{{ product.short_description }}</p>
<span class="product-card-link">
Explore
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8H13M13 8L9 4M13 8L9 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</div>
</a>
{% endfor %}
</div>
</div>
</section>
{% endif %}
<section class="section problems-section" aria-labelledby="problems-heading">
<div class="container">
<div class="section-header">
<div class="section-badge">Value Proposition</div>
<h2 class="section-title" id="problems-heading">What Problems Does Tecvico Solve?</h2>
</div>
<div class="problems-grid">
<div class="problem-item glass-card fade-in">
<div class="problem-number" aria-hidden="true">01</div>
<h3 class="problem-title">Accessibility</h3>
<p class="problem-desc">
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.
</p>
</div>
<div class="problem-item glass-card fade-in">
<div class="problem-number" aria-hidden="true">02</div>
<h3 class="problem-title">Integrated Tools</h3>
<p class="problem-desc">
Tecvico integrates a vast collection of tools and resources from various domains
of healthcare and medical imaging research in a common, unified environment.
</p>
</div>
<div class="problem-item glass-card fade-in">
<div class="problem-number" aria-hidden="true">03</div>
<h3 class="problem-title">Flexibility</h3>
<p class="problem-desc">
Tecvico offers flexibility in terms of tool optimization and workflow customization
to match your specific research requirements.
</p>
</div>
<div class="problem-item glass-card fade-in">
<div class="problem-number" aria-hidden="true">04</div>
<h3 class="problem-title">Reproducibility</h3>
<p class="problem-desc">
Improve usability, reusability, and reproducibility (URR) through a workflow
management system that allows researchers to easily create, share, and reuse
analysis pipelines.
</p>
</div>
</div>
</div>
</section>
<section class="section about-strip" aria-labelledby="about-strip-heading">
<div class="container">
<div class="about-strip-inner glass-card">
<div class="about-strip-content">
<div class="section-badge">Our Story</div>
<h2 class="section-title" id="about-strip-heading">More to Know</h2>
<p class="about-strip-text">
Tecvico has been developing since 2021 by the Quantitative Radiomolecular Imaging
and Therapy (Qurit) lab &amp; program at the University of British Columbia &amp;
BC Cancer Research Institute, Vancouver, BC, Canada.
</p>
<a href="{% url 'pages:about' %}" class="btn-primary">Learn More</a>
</div>
<div class="about-strip-blobs" aria-hidden="true">
<div class="strip-blob strip-blob--1"></div>
<div class="strip-blob strip-blob--2"></div>
</div>
</div>
</div>
</section>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% load static %}
<footer class="footer" role="contentinfo">
<div class="footer-blobs" aria-hidden="true">
<div class="footer-blob footer-blob--1"></div>
<div class="footer-blob footer-blob--2"></div>
</div>
<div class="container">
<div class="footer-grid">
<div class="footer-brand-col">
<a href="{% url 'pages:home' %}" class="footer-logo" aria-label="Tecvico home">
<span class="brand-icon">T</span>
<span class="brand-name">Tecvico</span>
</a>
<p class="footer-tagline">
Advancing medical imaging and radiomics research through innovative, standardized software solutions.
</p>
<a href="https://discord.gg/9XxA6pV9hb" class="btn-ghost btn-sm" target="_blank" rel="noopener noreferrer">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
</svg>
Join Discord
</a>
</div>
<div class="footer-links-col">
<h3 class="footer-heading">Navigation</h3>
<ul class="footer-links" role="list">
<li><a href="{% url 'pages:home' %}">Home</a></li>
<li><a href="{% url 'pages:about' %}">What is Tecvico</a></li>
<li><a href="{% url 'products:overview' %}">Products</a></li>
<li><a href="{% url 'pages:downloads' %}">Downloads</a></li>
<li><a href="{% url 'pages:faq' %}">FAQ</a></li>
<li><a href="{% url 'pages:contact' %}">Contact</a></li>
</ul>
</div>
{% if nav_main_products %}
<div class="footer-links-col">
<h3 class="footer-heading">Products</h3>
<ul class="footer-links" role="list">
{% for product in nav_main_products %}
<li><a href="{{ product.get_absolute_url }}">{{ product.name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="footer-contact-col">
<h3 class="footer-heading">Contact</h3>
<address class="footer-address">
<p>BC Cancer Research Center</p>
<p>675 West 10th Ave, Office 6-112</p>
<p>Vancouver, BC, V5Z 1L3</p>
</address>
<a href="mailto:support@tecvico.com" class="footer-email">support@tecvico.com</a>
</div>
</div>
<div class="footer-bottom">
<p class="footer-copy">
&copy; {% now "Y" %} Tecvico Corp. All rights reserved. Formerly known as Visera.
</p>
<p class="footer-credit">
Developed at <a href="https://www.qurit.ca" target="_blank" rel="noopener noreferrer">Qurit Lab</a>,
<a href="https://www.ubc.ca" target="_blank" rel="noopener noreferrer">University of British Columbia</a>
&amp; <a href="https://www.bccrc.ca" target="_blank" rel="noopener noreferrer">BC Cancer Research Institute</a>
</p>
</div>
</div>
</footer>
+97
View File
@@ -0,0 +1,97 @@
{% load static %}
<nav class="navbar" id="navbar" role="navigation" aria-label="Main navigation">
<div class="navbar-container">
<a href="{% url 'pages:home' %}" class="navbar-brand" aria-label="Tecvico home">
<span class="brand-icon">T</span>
<span class="brand-name">Tecvico</span>
</a>
<button class="navbar-toggle" id="navbarToggle" aria-expanded="false" aria-controls="navbarMenu" aria-label="Toggle navigation">
<span class="toggle-bar"></span>
<span class="toggle-bar"></span>
<span class="toggle-bar"></span>
</button>
<div class="navbar-menu" id="navbarMenu">
<ul class="nav-list" role="list">
<li class="nav-item">
<a href="{% url 'pages:home' %}" class="nav-link {% if request.resolver_match.url_name == 'home' %}active{% endif %}">
Home
</a>
</li>
{% if nav_main_products %}
<li class="nav-item has-megamenu" id="productsNavItem">
<a href="{% url 'products:overview' %}" class="nav-link {% if 'products' in request.resolver_match.namespace %}active{% endif %}" aria-haspopup="true" aria-expanded="false" id="productsTrigger">
Products
<svg class="nav-arrow" width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2 4L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
<div class="megamenu" role="menu" aria-labelledby="productsTrigger">
<div class="megamenu-inner">
<div class="megamenu-grid">
{% for product in nav_main_products %}
<div class="megamenu-column">
<a href="{{ product.get_absolute_url }}" class="megamenu-product-title" role="menuitem">
{{ product.name }}
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M3 7H11M11 7L8 4M11 7L8 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
<p class="megamenu-product-desc">{{ product.short_description }}</p>
{% with subs=product.sub_products.all %}
{% if subs %}
<ul class="megamenu-sublist" role="list">
{% for sub in subs %}
{% if sub.is_active %}
<li>
<a href="{{ sub.get_absolute_url }}" class="megamenu-sublink" role="menuitem">
<span class="sublink-dot"></span>
{{ sub.name }}
</a>
</li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
{% endwith %}
</div>
{% endfor %}
</div>
</div>
</div>
</li>
{% endif %}
<li class="nav-item">
<a href="{% url 'pages:about' %}" class="nav-link {% if request.resolver_match.url_name == 'about' %}active{% endif %}">
What is Tecvico
</a>
</li>
<li class="nav-item">
<a href="{% url 'pages:downloads' %}" class="nav-link {% if request.resolver_match.url_name == 'downloads' %}active{% endif %}">
Downloads
</a>
</li>
<li class="nav-item">
<a href="{% url 'pages:faq' %}" class="nav-link {% if request.resolver_match.url_name == 'faq' %}active{% endif %}">
FAQ
</a>
</li>
<li class="nav-item">
<a href="{% url 'pages:contact' %}" class="nav-link nav-link--cta {% if request.resolver_match.url_name == 'contact' %}active{% endif %}">
Contact
</a>
</li>
</ul>
</div>
</div>
</nav>
+88
View File
@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ main_product.name }}{% endblock %}
{% block meta_description %}{{ main_product.short_description }}{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="main-product-heading">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
<div class="blob blob--3"></div>
</div>
<div class="container">
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol class="breadcrumb-list" role="list">
<li><a href="{% url 'pages:home' %}">Home</a></li>
<li aria-hidden="true" class="breadcrumb-sep"></li>
<li><a href="{% url 'products:overview' %}">Products</a></li>
<li aria-hidden="true" class="breadcrumb-sep"></li>
<li aria-current="page">{{ main_product.name }}</li>
</ol>
</nav>
<div class="page-hero-content">
<div class="section-badge">Product</div>
<h1 class="page-hero-title" id="main-product-heading">{{ main_product.name }}</h1>
<p class="page-hero-subtitle">{{ main_product.short_description }}</p>
</div>
</div>
</section>
<section class="section" aria-labelledby="product-desc-heading">
<div class="container">
<div class="product-detail-intro glass-card fade-in">
<h2 class="sr-only" id="product-desc-heading">About {{ main_product.name }}</h2>
{% if main_product.image %}
<div class="product-detail-image">
<img src="{{ main_product.image.url }}" alt="{{ main_product.name }}" loading="lazy" />
</div>
{% endif %}
<div class="product-detail-text">
<p class="product-detail-desc">{{ main_product.description }}</p>
</div>
</div>
</div>
</section>
{% with subs=main_product.sub_products.all %}
{% if subs %}
<section class="section" aria-labelledby="subproducts-heading">
<div class="container">
<div class="section-header">
<div class="section-badge">Modules</div>
<h2 class="section-title" id="subproducts-heading">{{ main_product.name }} Modules</h2>
<p class="section-desc">
Explore the different modules and capabilities within {{ main_product.name }}.
</p>
</div>
<div class="subproducts-grid">
{% for sub in subs %}
{% if sub.is_active %}
<a href="{{ sub.get_absolute_url }}" class="subproduct-card glass-card fade-in">
{% if sub.image %}
<div class="subproduct-card-image">
<img src="{{ sub.image.url }}" alt="{{ sub.name }}" loading="lazy" />
</div>
{% endif %}
<div class="subproduct-card-body">
<h3 class="subproduct-card-title">{{ sub.name }}</h3>
<p class="subproduct-card-desc">{{ sub.short_description }}</p>
<span class="subproduct-card-link">
Learn More
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M3 7H11M11 7L8 4M11 7L8 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</div>
</a>
{% endif %}
{% endfor %}
</div>
</div>
</section>
{% endif %}
{% endwith %}
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Products{% endblock %}
{% block meta_description %}Explore Tecvico's suite of medical imaging and radiomics software products.{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="products-overview-heading">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
</div>
<div class="container">
<div class="page-hero-content">
<div class="section-badge">Software Suite</div>
<h1 class="page-hero-title" id="products-overview-heading">Our Products</h1>
<p class="page-hero-subtitle">Advanced tools for medical imaging research and radiomics analysis.</p>
</div>
</div>
</section>
<section class="section" aria-labelledby="products-list-heading">
<div class="container">
<h2 class="sr-only" id="products-list-heading">Product List</h2>
{% if main_products %}
<div class="products-overview-grid">
{% for product in main_products %}
<div class="product-overview-card glass-card fade-in">
{% if product.image %}
<div class="product-overview-image">
<img src="{{ product.image.url }}" alt="{{ product.name }}" loading="lazy" />
</div>
{% endif %}
<div class="product-overview-body">
<h2 class="product-overview-title">{{ product.name }}</h2>
<p class="product-overview-short">{{ product.short_description }}</p>
<p class="product-overview-desc">{{ product.description }}</p>
<a href="{{ product.get_absolute_url }}" class="btn-primary">Explore {{ product.name }}</a>
</div>
{% with subs=product.sub_products.all %}
{% if subs %}
<div class="product-overview-subs">
<h3 class="product-overview-subs-title">Modules</h3>
<ul class="product-overview-subs-list" role="list">
{% for sub in subs %}
{% if sub.is_active %}
<li>
<a href="{{ sub.get_absolute_url }}" class="sub-link-chip">
{{ sub.name }}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2.5 6H9.5M9.5 6L7 3.5M9.5 6L7 8.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
{% endif %}
{% endwith %}
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state-block glass-card">
<p>No products available at this time. Check back soon.</p>
</div>
{% endif %}
</div>
</section>
{% endblock %}
+111
View File
@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ sub_product.name }} — {{ main_product.name }}{% endblock %}
{% block meta_description %}{{ sub_product.short_description }}{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="sub-product-heading">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
</div>
<div class="container">
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol class="breadcrumb-list" role="list">
<li><a href="{% url 'pages:home' %}">Home</a></li>
<li aria-hidden="true" class="breadcrumb-sep"></li>
<li><a href="{% url 'products:overview' %}">Products</a></li>
<li aria-hidden="true" class="breadcrumb-sep"></li>
<li><a href="{{ main_product.get_absolute_url }}">{{ main_product.name }}</a></li>
<li aria-hidden="true" class="breadcrumb-sep"></li>
<li aria-current="page">{{ sub_product.name }}</li>
</ol>
</nav>
<div class="page-hero-content">
<div class="section-badge">{{ main_product.name }}</div>
<h1 class="page-hero-title" id="sub-product-heading">{{ sub_product.name }}</h1>
<p class="page-hero-subtitle">{{ sub_product.short_description }}</p>
</div>
</div>
</section>
<div class="section sub-product-layout">
<div class="container">
<div class="sub-product-grid">
<main class="sub-product-main">
{% if sub_product.image %}
<div class="sub-product-image glass-card fade-in">
<img src="{{ sub_product.image.url }}" alt="{{ sub_product.name }}" loading="lazy" />
</div>
{% endif %}
<div class="sub-product-desc glass-card fade-in">
<h2>About {{ sub_product.name }}</h2>
<p>{{ sub_product.description }}</p>
</div>
{% if articles %}
<section aria-labelledby="articles-heading">
<h2 class="articles-section-title" id="articles-heading">Articles</h2>
<div class="articles-list">
{% for article in articles %}
<article class="article-card glass-card fade-in" aria-labelledby="article-{{ article.pk }}-title">
<h3 class="article-title" id="article-{{ article.pk }}-title">{{ article.title }}</h3>
<p class="article-description">{{ article.description }}</p>
{% if article.sections.all %}
<div class="article-sections">
<dl class="article-sections-list">
{% for section in article.sections.all %}
<div class="article-section-item">
<dt class="section-key">{{ section.title }}</dt>
<dd class="section-value">{{ section.value }}</dd>
</div>
{% endfor %}
</dl>
</div>
{% endif %}
</article>
{% endfor %}
</div>
</section>
{% endif %}
</main>
<aside class="sub-product-sidebar" aria-label="Related modules">
<div class="sidebar-sticky">
<div class="sidebar-section glass-card">
<h3 class="sidebar-title">
<a href="{{ main_product.get_absolute_url }}">{{ main_product.name }}</a> Modules
</h3>
{% if siblings %}
<ul class="sidebar-list" role="list">
{% for sibling in siblings %}
<li>
<a href="{{ sibling.get_absolute_url }}" class="sidebar-link">
<span class="sidebar-dot" aria-hidden="true"></span>
{{ sibling.name }}
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="sidebar-empty">No other modules in this product.</p>
{% endif %}
</div>
<a href="{{ main_product.get_absolute_url }}" class="btn-ghost sidebar-back-btn">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M10 3L5 8L10 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back to {{ main_product.name }}
</a>
</div>
</aside>
</div>
</div>
</div>
{% endblock %}