feat: Dockerize and complete project
This commit is contained in:
@@ -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",)
|
||||
@@ -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"
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user