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 %}
+
+
This is a record of former team members and archived teams.
+ We have no record of former members at this time.
+ {% endif %}
+
+ 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 indicate who is actively contributing in certain areas.
+
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