feat: add people to projects and articles

This commit is contained in:
mohamad
2026-10-28 22:10:43 +08:00
parent 473637f477
commit 7c186b4230
14 changed files with 288 additions and 18 deletions
+9 -2
View File
@@ -37,10 +37,17 @@ class SiteBrandingAdmin(admin.ModelAdmin):
},
),
(
"Blog article people section",
"Article people section",
{
"fields": ("article_people_eyebrow", "article_people_title"),
"description": "These headings are shared by every blog article that has people cards.",
"description": "These headings are shared by blog articles and project articles that have people cards.",
},
),
(
"Project people section",
{
"fields": ("project_people_eyebrow", "project_people_title"),
"description": "These headings are shared by main projects and sub-projects that have people cards.",
},
),
(
@@ -0,0 +1,33 @@
# Generated by Django 5.2.16 on 2026-10-28 14:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0006_sitebranding_article_people_eyebrow_and_more'),
]
operations = [
migrations.AddField(
model_name='sitebranding',
name='project_people_eyebrow',
field=models.CharField(blank=True, default='The people behind this project', help_text='Small heading above the people cards on main projects and sub-projects.', max_length=160),
),
migrations.AddField(
model_name='sitebranding',
name='project_people_title',
field=models.CharField(default='Organizers & collaborators', help_text='Heading above the people cards on main projects and sub-projects.', max_length=200),
),
migrations.AlterField(
model_name='sitebranding',
name='article_people_eyebrow',
field=models.CharField(blank=True, default='The people behind this article', help_text='Small heading above the people cards on blog and project articles.', max_length=160),
),
migrations.AlterField(
model_name='sitebranding',
name='article_people_title',
field=models.CharField(default='Organizers & collaborators', help_text='Heading above the people cards on blog and project articles.', max_length=200),
),
]
+10 -2
View File
@@ -34,12 +34,20 @@ class SiteBranding(models.Model):
max_length=160,
blank=True,
default="The people behind this article",
help_text="Small heading above the people cards on every blog article.",
help_text="Small heading above the people cards on blog and project articles.",
)
article_people_title = models.CharField(
max_length=200,
default="Organizers & collaborators",
help_text="Heading above the people cards on every blog article.",
help_text="Heading above the people cards on blog and project articles.",
)
project_people_eyebrow = models.CharField(
max_length=160, blank=True, default="The people behind this project",
help_text="Small heading above the people cards on main projects and sub-projects.",
)
project_people_title = models.CharField(
max_length=200, default="Organizers & collaborators",
help_text="Heading above the people cards on main projects and sub-projects.",
)
icon = models.ImageField(
+25 -4
View File
@@ -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,
},
),
]
+65
View File
@@ -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,
+38 -1
View File
@@ -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",
+5 -5
View File
@@ -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(