feat: add people to projects and articles
This commit is contained in:
+25
-4
@@ -3,7 +3,28 @@ from django.contrib import admin
|
||||
from apps.core.admin_video import video_admin_preview
|
||||
from apps.core.video import VIDEO_ADMIN_FIELDSET
|
||||
|
||||
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct
|
||||
from .models import (
|
||||
Article, ArticleCitation, ArticlePerson, ArticleSection, MainProduct,
|
||||
MainProductPerson, ProductVideo, SubProduct, SubProductPerson,
|
||||
)
|
||||
|
||||
|
||||
class PersonInline(admin.StackedInline):
|
||||
extra = 0
|
||||
fields = ("role", "name", "description", "read_more_description", "image", "image_alt", "url", "order")
|
||||
ordering = ("order",)
|
||||
|
||||
|
||||
class MainProductPersonInline(PersonInline):
|
||||
model = MainProductPerson
|
||||
|
||||
|
||||
class SubProductPersonInline(PersonInline):
|
||||
model = SubProductPerson
|
||||
|
||||
|
||||
class ArticlePersonInline(PersonInline):
|
||||
model = ArticlePerson
|
||||
|
||||
|
||||
class ArticleCitationInline(admin.TabularInline):
|
||||
@@ -77,7 +98,7 @@ class MainProductAdmin(admin.ModelAdmin):
|
||||
search_fields = ("name", "description")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
||||
inlines = [MainProductArticleInline, ProductVideoInline, SubProductInline]
|
||||
inlines = [MainProductPersonInline, MainProductArticleInline, ProductVideoInline, SubProductInline]
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -136,7 +157,7 @@ class SubProductAdmin(admin.ModelAdmin):
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
||||
raw_id_fields = ("main_product",)
|
||||
inlines = [SubProductArticleInline, SubProductVideoInline]
|
||||
inlines = [SubProductPersonInline, SubProductArticleInline, SubProductVideoInline]
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -164,7 +185,7 @@ class ArticleAdmin(admin.ModelAdmin):
|
||||
search_fields = ("title", "description", "main_product__name", "sub_product__name")
|
||||
list_editable = ("order",)
|
||||
raw_id_fields = ("main_product", "sub_product")
|
||||
inlines = [ArticleSectionInline, ArticleCitationInline]
|
||||
inlines = [ArticlePersonInline, ArticleSectionInline, ArticleCitationInline]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("main_product", "sub_product", "badge", "title")}),
|
||||
("Description", {"fields": ("description_format", "description", "read_more_description")}),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Generated by Django 5.2.16 on 2026-10-28 14:09
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0024_articlecitation_read_more_text_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ArticlePerson',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(default='Collaborator', help_text='Type Organizer, Collaborator, or any other role name.', max_length=100, verbose_name='Role / group')),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('read_more_description', models.BooleanField(default=False, help_text="Collapse this person's description with Read more when it is long.")),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='products/people/')),
|
||||
('image_alt', models.CharField(blank=True, max_length=240)),
|
||||
('url', models.URLField(blank=True)),
|
||||
('order', models.PositiveIntegerField(default=0, help_text='Smaller numbers appear first. Organizers appear before collaborators, then custom roles.', verbose_name='Display order')),
|
||||
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='people', to='products.article')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Project article person',
|
||||
'verbose_name_plural': 'Project article people',
|
||||
'ordering': [models.Case(models.When(role__iexact='organizer', then=models.Value(0)), models.When(role__iexact='collaborator', then=models.Value(1)), default=models.Value(2), output_field=models.IntegerField()), 'order', 'pk'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MainProductPerson',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(default='Collaborator', help_text='Type Organizer, Collaborator, or any other role name.', max_length=100, verbose_name='Role / group')),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('read_more_description', models.BooleanField(default=False, help_text="Collapse this person's description with Read more when it is long.")),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='products/people/')),
|
||||
('image_alt', models.CharField(blank=True, max_length=240)),
|
||||
('url', models.URLField(blank=True)),
|
||||
('order', models.PositiveIntegerField(default=0, help_text='Smaller numbers appear first. Organizers appear before collaborators, then custom roles.', verbose_name='Display order')),
|
||||
('main_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='people', to='products.mainproduct')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Main project person',
|
||||
'verbose_name_plural': 'Main project people',
|
||||
'ordering': [models.Case(models.When(role__iexact='organizer', then=models.Value(0)), models.When(role__iexact='collaborator', then=models.Value(1)), default=models.Value(2), output_field=models.IntegerField()), 'order', 'pk'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubProductPerson',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(default='Collaborator', help_text='Type Organizer, Collaborator, or any other role name.', max_length=100, verbose_name='Role / group')),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('read_more_description', models.BooleanField(default=False, help_text="Collapse this person's description with Read more when it is long.")),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='products/people/')),
|
||||
('image_alt', models.CharField(blank=True, max_length=240)),
|
||||
('url', models.URLField(blank=True)),
|
||||
('order', models.PositiveIntegerField(default=0, help_text='Smaller numbers appear first. Organizers appear before collaborators, then custom roles.', verbose_name='Display order')),
|
||||
('sub_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='people', to='products.subproduct')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Sub-project person',
|
||||
'verbose_name_plural': 'Sub-project people',
|
||||
'ordering': [models.Case(models.When(role__iexact='organizer', then=models.Value(0)), models.When(role__iexact='collaborator', then=models.Value(1)), default=models.Value(2), output_field=models.IntegerField()), 'order', 'pk'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -207,6 +207,10 @@ class Article(models.Model):
|
||||
def rendered_description(self):
|
||||
return render_content(self.description, self.description_format)
|
||||
|
||||
@property
|
||||
def people_heading_id(self):
|
||||
return f"article-{self.pk}-people"
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
has_main = self.main_product_id is not None
|
||||
@@ -220,6 +224,67 @@ class Article(models.Model):
|
||||
return self.title
|
||||
|
||||
|
||||
class ProjectPerson(models.Model):
|
||||
role = models.CharField(
|
||||
"Role / group", max_length=100, default="Collaborator",
|
||||
help_text="Type Organizer, Collaborator, or any other role name.",
|
||||
)
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
read_more_description = models.BooleanField(
|
||||
default=False, help_text="Collapse this person's description with Read more when it is long."
|
||||
)
|
||||
image = models.ImageField(upload_to="products/people/", blank=True, null=True)
|
||||
image_alt = models.CharField(max_length=240, blank=True)
|
||||
url = models.URLField(blank=True)
|
||||
order = models.PositiveIntegerField(
|
||||
"Display order", default=0,
|
||||
help_text="Smaller numbers appear first. Organizers appear before collaborators, then custom roles.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = [
|
||||
models.Case(
|
||||
models.When(role__iexact="organizer", then=models.Value(0)),
|
||||
models.When(role__iexact="collaborator", then=models.Value(1)),
|
||||
default=models.Value(2), output_field=models.IntegerField(),
|
||||
),
|
||||
"order", "pk",
|
||||
]
|
||||
|
||||
@property
|
||||
def role_label(self):
|
||||
return self.role.title() if self.role.casefold() in {"organizer", "collaborator"} else self.role
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.role_label})"
|
||||
|
||||
|
||||
class MainProductPerson(ProjectPerson):
|
||||
main_product = models.ForeignKey(MainProduct, on_delete=models.CASCADE, related_name="people")
|
||||
|
||||
class Meta(ProjectPerson.Meta):
|
||||
verbose_name = "Main project person"
|
||||
verbose_name_plural = "Main project people"
|
||||
|
||||
|
||||
class SubProductPerson(ProjectPerson):
|
||||
sub_product = models.ForeignKey(SubProduct, on_delete=models.CASCADE, related_name="people")
|
||||
|
||||
class Meta(ProjectPerson.Meta):
|
||||
verbose_name = "Sub-project person"
|
||||
verbose_name_plural = "Sub-project people"
|
||||
|
||||
|
||||
class ArticlePerson(ProjectPerson):
|
||||
article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name="people")
|
||||
|
||||
class Meta(ProjectPerson.Meta):
|
||||
verbose_name = "Project article person"
|
||||
verbose_name_plural = "Project article people"
|
||||
|
||||
|
||||
class SubProductVersion(models.Model):
|
||||
main_product = models.ForeignKey(
|
||||
MainProduct,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
|
||||
from apps.products.models import (
|
||||
Article, ArticlePerson, ArticleSection, MainProduct, MainProductPerson,
|
||||
SubProduct, SubProductPerson,
|
||||
)
|
||||
|
||||
|
||||
class ProductViewsSetup(TestCase):
|
||||
@@ -56,6 +59,31 @@ class ProductOverviewViewTest(ProductViewsSetup):
|
||||
|
||||
|
||||
class MainProductDetailViewTest(ProductViewsSetup):
|
||||
def test_project_and_article_people_are_independent_and_ordered(self):
|
||||
MainProductPerson.objects.create(main_product=self.main_product, name="Main collaborator", role="Collaborator", order=1)
|
||||
MainProductPerson.objects.create(main_product=self.main_product, name="Main organizer", role="Organizer", order=9)
|
||||
main_article = Article.objects.create(main_product=self.main_product, title="Main article", description="Text")
|
||||
ArticlePerson.objects.create(article=main_article, name="Article advisor", role="Scientific advisor", order=0)
|
||||
response = self.client.get(self.main_product.get_absolute_url())
|
||||
html = response.content.decode()
|
||||
self.assertLess(html.index("Main organizer"), html.index("Main collaborator"))
|
||||
self.assertContains(response, "Article advisor")
|
||||
self.assertNotContains(response, "Sub collaborator")
|
||||
self.assertContains(response, f'id="article-{main_article.pk}-people"')
|
||||
|
||||
def test_people_section_is_hidden_until_created_and_read_more_is_optional(self):
|
||||
response = self.client.get(self.main_product.get_absolute_url())
|
||||
self.assertNotContains(response, 'id="main-project-people"')
|
||||
person = MainProductPerson.objects.create(
|
||||
main_product=self.main_product, name="Project leader", description="A long biography",
|
||||
)
|
||||
response = self.client.get(self.main_product.get_absolute_url())
|
||||
self.assertContains(response, "Project leader")
|
||||
self.assertNotContains(response, 'data-readmore="88"')
|
||||
person.read_more_description = True
|
||||
person.save(update_fields=["read_more_description"])
|
||||
self.assertContains(self.client.get(self.main_product.get_absolute_url()), 'data-readmore="88"')
|
||||
|
||||
def test_read_more_can_be_set_per_article_and_section(self):
|
||||
url = self.main_product.get_absolute_url()
|
||||
article = Article.objects.create(main_product=self.main_product, title="Notes", description="Long notes")
|
||||
@@ -112,6 +140,15 @@ class MainProductDetailViewTest(ProductViewsSetup):
|
||||
|
||||
|
||||
class SubProductDetailViewTest(ProductViewsSetup):
|
||||
def test_sub_project_and_article_people_render_separately(self):
|
||||
SubProductPerson.objects.create(sub_product=self.sub_product, name="Sub organizer", role="Organizer")
|
||||
ArticlePerson.objects.create(article=self.article, name="Article collaborator", role="Collaborator")
|
||||
response = self.client.get(self.sub_product.get_absolute_url())
|
||||
self.assertContains(response, "Sub organizer")
|
||||
self.assertContains(response, "Article collaborator")
|
||||
self.assertContains(response, 'id="sub-project-people"')
|
||||
self.assertNotContains(self.client.get(self.main_product.get_absolute_url()), "Sub organizer")
|
||||
|
||||
def test_sub_detail_returns_200(self):
|
||||
url = reverse(
|
||||
"products:sub_product_detail",
|
||||
|
||||
@@ -23,7 +23,7 @@ class ProductOverviewView(ListView):
|
||||
template_name = "products/overview.html"
|
||||
context_object_name = "main_products"
|
||||
queryset = MainProduct.objects.filter(is_active=True).prefetch_related(
|
||||
"sub_products"
|
||||
"sub_products", "people"
|
||||
)
|
||||
|
||||
|
||||
@@ -33,13 +33,13 @@ class MainProductDetailView(DetailView):
|
||||
context_object_name = "main_product"
|
||||
slug_url_kwarg = "main_slug"
|
||||
queryset = MainProduct.objects.filter(is_active=True).prefetch_related(
|
||||
"sub_products"
|
||||
"sub_products", "people"
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["articles"] = self.object.articles.prefetch_related(
|
||||
"sections", "citations"
|
||||
"sections", "citations", "people"
|
||||
).all()
|
||||
videos = self.object.videos.filter(is_active=True).order_by("order", "pk")
|
||||
context.update(
|
||||
@@ -65,7 +65,7 @@ class SubProductDetailView(TemplateView):
|
||||
is_active=True,
|
||||
)
|
||||
sub_product = get_object_or_404(
|
||||
SubProduct,
|
||||
SubProduct.objects.prefetch_related("people"),
|
||||
slug=self.kwargs["sub_slug"],
|
||||
main_product=main_product,
|
||||
is_active=True,
|
||||
@@ -73,7 +73,7 @@ class SubProductDetailView(TemplateView):
|
||||
context["main_product"] = main_product
|
||||
context["sub_product"] = sub_product
|
||||
context["articles"] = sub_product.articles.prefetch_related(
|
||||
"sections", "citations"
|
||||
"sections", "citations", "people"
|
||||
).all()
|
||||
videos = sub_product.videos.filter(is_active=True).order_by("order", "pk")
|
||||
context.update(
|
||||
|
||||
Reference in New Issue
Block a user