diff --git a/djangoproject/templates/members/team_archive.html b/djangoproject/templates/members/team_archive.html new file mode 100644 index 000000000..ecdca42ae --- /dev/null +++ b/djangoproject/templates/members/team_archive.html @@ -0,0 +1,52 @@ +{% extends "base_foundation.html" %} + +{% block og_title %}Team History | Django Software Foundation{% endblock %} +{% block og_description %}{% spaceless %} + This is a record of former team members and archived teams within the + Django Software Foundation. +{% endspaceless %}{% endblock %} + +{% block content %} + +

Team Archive

+

This is a record of former team members and archived teams.

+

See the Teams page for details on current teams.

+ +

Former Team Members

+ {% if teams %} + {% for team in teams %} +
+

{{ team.name }}

+

{{ team.description|safe }}

+ +
+ {% endfor %} + {% else %} +

We have no record of former members at this time.

+ {% endif %} + +

Archived Teams

+ {% if archived_teams %} + {% for team in archived_teams %} +
+

{{ team.name }}

+

{{ team.description|safe }}

+ +
+ {% endfor %} + {% else %} +

We have no record of archived teams at this time.

+ {% endif %} + +{% endblock %} diff --git a/djangoproject/templates/members/team_list.html b/djangoproject/templates/members/team_list.html index e8bd2ef62..8e32cf11c 100644 --- a/djangoproject/templates/members/team_list.html +++ b/djangoproject/templates/members/team_list.html @@ -12,6 +12,7 @@

Teams

Teams indicate who is actively contributing in certain areas.

+

See the Teams Archive page for details on former team members and archived teams.

{% for team in teams %}
diff --git a/members/admin.py b/members/admin.py index dbfc435bb..8c62e85b2 100644 --- a/members/admin.py +++ b/members/admin.py @@ -5,7 +5,13 @@ from django.utils.formats import localize from django.utils.html import format_html -from members.models import CorporateMember, IndividualMember, Invoice, Team +from members.models import ( + CorporateMember, + IndividualMember, + Invoice, + PreviousTeamMembership, + Team, +) @admin.register(IndividualMember) @@ -106,3 +112,15 @@ def membership_expires(self, obj): class TeamAdmin(admin.ModelAdmin): filter_horizontal = ["members"] prepopulated_fields = {"slug": ("name",)} + + +@admin.register(PreviousTeamMembership) +class PreviousTeamMembershipAdmin(admin.ModelAdmin): + list_display = [ + "member", + "team", + ] + search_fields = ["member__name", "team__name"] + + def get_queryset(self, request): + return super().get_queryset(request).select_related("team", "member") diff --git a/members/migrations/0010_team_archived_previousteammembership_and_more.py b/members/migrations/0010_team_archived_previousteammembership_and_more.py new file mode 100644 index 000000000..65e7b51c0 --- /dev/null +++ b/members/migrations/0010_team_archived_previousteammembership_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.9 on 2024-11-16 04:54 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0009_alter_individualmember_add_reason_help_text'), + ] + + operations = [ + migrations.AddField( + model_name='team', + name='archived', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='PreviousTeamMembership', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('details', models.TextField(blank=True, help_text='Use for details such as term dates. This is publicly displayed within brackets next to the members name. Do not include confidential details.')), + ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='members.individualmember')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='members.team')), + ], + ), + migrations.AddField( + model_name='team', + name='former_members', + field=models.ManyToManyField(related_name='teams_former_member', through='members.PreviousTeamMembership', to='members.individualmember'), + ), + ] diff --git a/members/models.py b/members/models.py index 866f87646..94d42c770 100644 --- a/members/models.py +++ b/members/models.py @@ -64,10 +64,30 @@ class Team(models.Model): description = models.TextField(help_text="HTML, without surrounding

tags.") members = models.ManyToManyField(IndividualMember) + archived = models.BooleanField(default=False) + former_members = models.ManyToManyField( + IndividualMember, + through="PreviousTeamMembership", + related_name="teams_former_member", + ) + def __str__(self): return self.name +class PreviousTeamMembership(models.Model): + team = models.ForeignKey(Team, on_delete=models.CASCADE) + member = models.ForeignKey(IndividualMember, on_delete=models.CASCADE) + details = models.TextField( + blank=True, + help_text=mark_safe( + "Use for details such as term dates. This is publicly displayed " + "within brackets next to the members name. " + "Do not include confidential details." + ), + ) + + class CorporateMemberManager(models.Manager): def for_public_display(self): objs = ( diff --git a/members/test_views.py b/members/test_views.py index 579c03b83..a19212213 100644 --- a/members/test_views.py +++ b/members/test_views.py @@ -3,7 +3,7 @@ from django.test import TestCase from django.urls import reverse -from .models import CorporateMember, IndividualMember, Team +from .models import CorporateMember, IndividualMember, PreviousTeamMembership, Team from .utils import get_temporary_image @@ -164,3 +164,70 @@ def test_get(self): ) self.assertContains(response, "

Ops stuff.

") self.assertContains(response, "", html=True) + + def test_archived_team_excluded(self): + Team.objects.create(name="Technical team", archived=True) + response = self.client.get(self.url) + self.assertNotContains(response, "Technical team") + + +class TeamsArchiveViewTests(TestCase): + url = reverse("members:teams-archive") + + def test_no_data(self): + response = self.client.get(self.url) + + self.assertContains(response, "

Former Team Members

", html=True) + self.assertContains( + response, "We have no record of former members at this time." + ) + self.assertContains(response, "

Archived Teams

", html=True) + self.assertContains( + response, "We have no record of archived teams at this time." + ) + + def test_former_team_member(self): + alice = IndividualMember.objects.create(name="Alice", email="a@example.com") + jessica = IndividualMember.objects.create(name="Jessica", email="j@example.com") + priya = IndividualMember.objects.create(name="Priya", email="p@example.com") + security_team = Team.objects.create(name="Security team") + security_team.members.add(alice) + + PreviousTeamMembership.objects.create( + team=security_team, member=jessica, details="2010-2011" + ) + PreviousTeamMembership.objects.create(team=security_team, member=priya) + + response = self.client.get(self.url) + + self.assertContains(response, "

Former Team Members

", html=True) + self.assertNotContains( + response, "We have no record of former members at this time." + ) + self.assertContains(response, "Security team") + self.assertNotContains(response, "Alice") + self.assertContains(response, "Jessica (2010-2011)") + self.assertContains(response, "Priya") + + def test_archived_team(self): + alice = IndividualMember.objects.create(name="Alice", email="a@example.com") + jessica = IndividualMember.objects.create(name="Jessica", email="j@example.com") + priya = IndividualMember.objects.create(name="Priya", email="p@example.com") + technical_team = Team.objects.create(name="Technical team", archived=True) + technical_team.members.add(alice) + + PreviousTeamMembership.objects.create( + team=technical_team, member=jessica, details="2010-2011" + ) + PreviousTeamMembership.objects.create(team=technical_team, member=priya) + + response = self.client.get(self.url) + + self.assertContains(response, "

Archived Teams

", html=True) + self.assertNotContains( + response, "We have no record of archived teams at this time." + ) + self.assertContains(response, "Technical team") + self.assertContains(response, "Alice") + self.assertContains(response, "Jessica (2010-2011)") + self.assertContains(response, "Priya") diff --git a/members/urls.py b/members/urls.py index 11313500b..1defe1d07 100644 --- a/members/urls.py +++ b/members/urls.py @@ -6,6 +6,7 @@ CorporateMemberRenewView, CorporateMemberSignUpView, IndividualMemberListView, + TeamsArchiveView, TeamsListView, corporate_member_list_view, ) @@ -46,4 +47,5 @@ name="corporate-members-badges", ), path("teams/", TeamsListView.as_view(), name="teams"), + path("teams/archive/", TeamsArchiveView.as_view(), name="teams-archive"), ] diff --git a/members/views.py b/members/views.py index adb6812a2..658dc8e4f 100644 --- a/members/views.py +++ b/members/views.py @@ -1,4 +1,5 @@ from django.core import signing +from django.db.models import Count, Prefetch from django.http import Http404 from django.shortcuts import render from django.urls import reverse @@ -10,6 +11,7 @@ CORPORATE_MEMBERSHIP_AMOUNTS, CorporateMember, IndividualMember, + PreviousTeamMembership, Team, ) @@ -99,4 +101,47 @@ class TeamsListView(ListView): context_object_name = "teams" def get_queryset(self): - return self.model.objects.prefetch_related("members").order_by("name") + return ( + self.model.objects.filter(archived=False) + .prefetch_related("members") + .order_by("name") + ) + + +class TeamsArchiveView(TemplateView): + template_name = "members/team_archive.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + previous_team_membership_qs = PreviousTeamMembership.objects.select_related( + "member" + ) + archived_teams = ( + Team.objects.filter(archived=True) + .prefetch_related( + "members", + Prefetch( + "previousteammembership_set", + queryset=previous_team_membership_qs, + ), + ) + .order_by("name") + ) + + # Active teams with former members. + teams = ( + Team.objects.prefetch_related( + Prefetch( + "previousteammembership_set", + queryset=previous_team_membership_qs, + ) + ) + .annotate(former_member_count=Count("former_members", distinct=True)) + .filter(former_member_count__gt=0, archived=False) + .order_by("name") + ) + + context["teams"] = teams + context["archived_teams"] = archived_teams + return context