diff --git a/accounts/forms.py b/accounts/forms.py
index f863c06787..6a05eeeb98 100644
--- a/accounts/forms.py
+++ b/accounts/forms.py
@@ -64,13 +64,6 @@ def clean(self):
raise forms.ValidationError(_("Staff users cannot be deleted"))
return cleaned_data
- def add_errors_from_protectederror(self, exception):
- """
- Convert the given ProtectedError exception object into validation
- errors on the instance.
- """
- self.add_error(None, _("User has protected data and cannot be deleted"))
-
@transaction.atomic()
def delete(self):
"""
diff --git a/accounts/tests.py b/accounts/tests.py
index c41d893b1a..4b0787ad0f 100644
--- a/accounts/tests.py
+++ b/accounts/tests.py
@@ -6,7 +6,6 @@
from django_hosts.resolvers import reverse
from accounts.forms import DeleteProfileForm
-from foundation import models as foundationmodels
from tracdb.models import Revision, Ticket, TicketChange
from tracdb.testutils import TracDBCreateDatabaseMixin
@@ -218,17 +217,6 @@ def test_deletion_staff_forbidden(self):
form = self.create_user_and_form(is_staff=True)
self.assertFormError(form, None, ["Staff users cannot be deleted"])
- def test_user_with_protected_data(self):
- form = self.create_user_and_form()
- form.user.boardmember_set.create(
- office=foundationmodels.Office.objects.create(name="test"),
- term=foundationmodels.Term.objects.create(year=2000),
- )
- form.delete()
- self.assertFormError(
- form, None, ["User has protected data and cannot be deleted"]
- )
-
def test_form_delete_method_requires_valid_form(self):
form = self.create_user_and_form(is_staff=True)
self.assertRaises(form.InvalidFormError, form.delete)
diff --git a/djangoproject/templates/base_foundation.html b/djangoproject/templates/base_foundation.html
index 877368019f..9ca599d9e6 100644
--- a/djangoproject/templates/base_foundation.html
+++ b/djangoproject/templates/base_foundation.html
@@ -1,5 +1,5 @@
{% extends "base.html" %}
-{% load fundraising_extras meetings i18n %}
+{% load fundraising_extras i18n %}
{% block og_title %}Django Software Foundation{% endblock %}
@@ -26,7 +26,10 @@
About the foundation
Organizing a Django conference
{% translate "Latest DSF meeting minutes" %}
- {% render_latest_meeting_minute_entries 2 %}
- {% translate "More meeting minutes" %}
+
+ {% blocktrans with minutes_url="https://github.com/django/dsf-minutes" %}
+ The latest meeting minutes can be found on the minutes repository .
+ {% endblocktrans %}
+
{% endblock %}
diff --git a/djangoproject/templates/foundation/meeting_archive.html b/djangoproject/templates/foundation/meeting_archive.html
deleted file mode 100644
index ef724d229f..0000000000
--- a/djangoproject/templates/foundation/meeting_archive.html
+++ /dev/null
@@ -1,21 +0,0 @@
-{% extends "base_foundation.html" %}
-{% load i18n %}
-
-{% block og_title %}{% translate "Meeting minutes archive" %}{% endblock %}
-{% block og_description %}{% translate "View meeting minutes" %}{% endblock %}
-
-{% block head_extra %}
-
-{% endblock %}
-
-{% block content %}
- {% translate "Meeting minutes archive" %}
-
- {% translate "Select a year to view meeting minutes:" %}
-
-
-{% endblock %}
diff --git a/djangoproject/templates/foundation/meeting_archive_day.html b/djangoproject/templates/foundation/meeting_archive_day.html
deleted file mode 100644
index 0cdc2ce431..0000000000
--- a/djangoproject/templates/foundation/meeting_archive_day.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{% extends "base_foundation.html" %}
-{% load i18n %}
-
-{% block og_title %}{% translate "Meeting minutes archive" %}{% endblock %}
-{% block og_description %}{% blocktranslate trimmed with day=day|date:"DATE_FORMAT" %}
- View meeting minutes for {{ day }}{% endblocktranslate %}{% endblock %}
-
-{% block content %}
- {% blocktranslate trimmed with day=day|date:"DATE_FORMAT" %}
- Meeting minutes archive: {{ day }}{% endblocktranslate %}
-
-
- {% for meeting in object_list %}
- {{ meeting }}
- {% endfor %}
-
-{% endblock %}
diff --git a/djangoproject/templates/foundation/meeting_archive_month.html b/djangoproject/templates/foundation/meeting_archive_month.html
deleted file mode 100644
index 9c8ac61c3e..0000000000
--- a/djangoproject/templates/foundation/meeting_archive_month.html
+++ /dev/null
@@ -1,15 +0,0 @@
-{% extends "base_foundation.html" %}
-{% load i18n %}
-
-{% block og_title %}{% translate "Meeting minutes archive" %}{% endblock %}
-{% block og_description %}{% blocktranslate with month=month|date:"F, Y" %}View meeting minutes for {{ month }}{% endblocktranslate %}{% endblock %}
-
-{% block content %}
- {% blocktranslate with month=month|date:"F, Y" %}Meeting minutes archive: {{ month }}{% endblocktranslate %}
-
-
- {% for meeting in object_list %}
- {{ meeting }}
- {% endfor %}
-
-{% endblock %}
diff --git a/djangoproject/templates/foundation/meeting_archive_year.html b/djangoproject/templates/foundation/meeting_archive_year.html
deleted file mode 100644
index b869455fa9..0000000000
--- a/djangoproject/templates/foundation/meeting_archive_year.html
+++ /dev/null
@@ -1,15 +0,0 @@
-{% extends "base_foundation.html" %}
-{% load i18n %}
-
-{% block og_title %}{% translate "Meeting minutes archive" %}{% endblock %}
-{% block og_description %}{% blocktranslate with year=year|date:"Y" %}View meeting minutes for {{ year }}{% endblocktranslate %}{% endblock %}
-
-{% block content %}
- {% blocktranslate with year=year|date:"Y" %}Meeting minutes archive: {{ year }}{% endblocktranslate %}
-
-
- {% for meeting in object_list %}
- {{ meeting }}
- {% endfor %}
-
-{% endblock %}
diff --git a/djangoproject/templates/foundation/meeting_detail.html b/djangoproject/templates/foundation/meeting_detail.html
deleted file mode 100644
index 8f767b8e85..0000000000
--- a/djangoproject/templates/foundation/meeting_detail.html
+++ /dev/null
@@ -1,106 +0,0 @@
-{% extends "base_foundation.html" %}
-{% load i18n %}
-
-{% block og_title %}{% blocktranslate %}Meeting minutes: {{ meeting }}{% endblocktranslate %}{% endblock %}
-{% block og_description %}{% blocktranslate %}Meeting minutes for {{ meeting }}{% endblocktranslate %}{% endblock %}
-
-{% block content %}
- {% load djmoney %}
-
- {{ meeting }}
-
-
- {% blocktranslate trimmed with name=meeting.leader.account.get_full_name %}
- The meeting was led by {{ name }}.
- {% endblocktranslate %}
-
-
- {% translate "Board members in attendance were:" %}
-
-
- {% for attendee in meeting.board_attendees.all %}
- {{ attendee.account.get_full_name }}
- {% endfor %}
-
-
- {% if meeting.non_board_attendees.all %}
- {% translate "Also in attendance were:" %}
-
-
- {% for attendee in meeting.non_board_attendees.all %}
- {{ attendee.name }} ({{ attendee.role }})
- {% endfor %}
-
- {% endif %}
-
- {% translate "Finances" %}
-
- {% translate "Balance" %}
-
- {{ meeting.treasurer_balance.currency.code }} {% money_localize meeting.treasurer_balance %}
-
- {% if meeting.treasurer_report %}
- {% translate "Treasurer’s report" %}
- {{ meeting.treasurer_report_html|safe }}
- {% endif %}
-
- {% if meeting.grants_approved.all %}
- {% translate "Grants approved" %}
-
-
- {% for grant in meeting.grants_approved.all %}
- {{ grant.entity }}: {{ grant.amount.currency.code }} {% money_localize grant.amount %}
- {% endfor %}
-
- {% endif %}
-
- {% if meeting.individual_members_approved.all %}
- {% translate "Individual members approved" %}
-
-
- {% for member in meeting.individual_members_approved.all %}
- {{ member.name }}
- {% endfor %}
-
- {% endif %}
-
- {% if meeting.corporate_members_approved.all %}
- {% translate "Corporate members approved" %}
-
-
- {% for member in meeting.corporate_members_approved.all %}
- {{ member.name }}
- {% endfor %}
-
- {% endif %}
-
- {% if ongoing_business %}
- {% translate "Ongoing business" %}
-
- {% for business in ongoing_business %}
- {{ business.title }}
-
- {{ business.body_html|safe }}
- {% endfor %}
- {% endif %}
-
- {% if new_business %}
- {% translate "New business" %}
-
- {% for business in new_business %}
- {{ business.title }}
-
- {{ business.body_html|safe }}
- {% endfor %}
- {% endif %}
-
- {% if meeting.action_items.all %}
- {% translate "Action items" %}
-
-
- {% for action_item in meeting.action_items.all %}
- {{ action_item.responsible }}: {{ action_item.task }}
- {% endfor %}
-
- {% endif %}
-{% endblock %}
diff --git a/djangoproject/templates/foundation/meeting_snippet.html b/djangoproject/templates/foundation/meeting_snippet.html
deleted file mode 100644
index 0e72198eb0..0000000000
--- a/djangoproject/templates/foundation/meeting_snippet.html
+++ /dev/null
@@ -1,25 +0,0 @@
-{% load i18n %}
-
diff --git a/djangoproject/urls/www.py b/djangoproject/urls/www.py
index 22edfa7361..6ac010f646 100644
--- a/djangoproject/urls/www.py
+++ b/djangoproject/urls/www.py
@@ -13,8 +13,7 @@
from aggregator.feeds import CommunityAggregatorFeed, CommunityAggregatorFirehoseFeed
from blog.feeds import WeblogEntryFeed
from blog.sitemaps import WeblogSitemap
-from foundation.feeds import FoundationMinutesFeed
-from foundation.views import CoreDevelopers
+from foundation.views import CoreDevelopers, minutes_redirect
admin.autodiscover()
@@ -96,7 +95,11 @@
),
path("contact/", include("contact.urls")),
path("foundation/django_core/", CoreDevelopers.as_view()),
- path("foundation/minutes/", include("foundation.urls.meetings")),
+ path(
+ "foundation/minutes/////",
+ minutes_redirect,
+ name="minutes_redirect",
+ ),
path("foundation/", include("members.urls")),
path("fundraising/", include("fundraising.urls")),
# Used by docs search suggestions
@@ -116,11 +119,6 @@
name="aggregator-firehose-feed",
),
path("rss/community//", CommunityAggregatorFeed(), name="aggregator-feed"),
- path(
- "rss/foundation/minutes/",
- FoundationMinutesFeed(),
- name="foundation-minutes-feed",
- ),
# django-push
path("subscriber/", include("django_push.subscriber.urls")),
# Trac schtuff
diff --git a/foundation/admin.py b/foundation/admin.py
index 1c905040e6..ae36372c4b 100644
--- a/foundation/admin.py
+++ b/foundation/admin.py
@@ -1,100 +1,8 @@
from django.contrib import admin
-from django.utils.text import slugify
-from django.utils.translation import gettext as _
from . import models
-@admin.register(models.Office)
-class OfficeAdmin(admin.ModelAdmin):
- pass
-
-
-@admin.register(models.Term)
-class TermAdmin(admin.ModelAdmin):
- pass
-
-
-@admin.register(models.BoardMember)
-class BoardMemberAdmin(admin.ModelAdmin):
- list_display = ("full_name", "office", "term")
- list_filter = ("office", "term")
- list_select_related = True
- raw_id_fields = ("account",)
-
- @admin.display(ordering="account__last_name")
- def full_name(self, obj):
- return obj.account.get_full_name()
-
-
-@admin.register(models.NonBoardAttendee)
-class NonBoardAttendeeAdmin(admin.ModelAdmin):
- pass
-
-
-class GrantInline(admin.TabularInline):
- model = models.ApprovedGrant
-
-
-class IndividualMemberInline(admin.TabularInline):
- model = models.ApprovedIndividualMember
-
-
-class CorporateMemberInline(admin.TabularInline):
- model = models.ApprovedCorporateMember
-
-
-class BusinessInline(admin.StackedInline):
- model = models.Business
-
-
-class ActionItemInline(admin.StackedInline):
- model = models.ActionItem
-
-
-@admin.register(models.Meeting)
-class MeetingAdmin(admin.ModelAdmin):
- fieldsets = (
- (
- "Metadata",
- {
- "fields": (
- "title",
- "slug",
- "date",
- "leader",
- "board_attendees",
- "non_board_attendees",
- ),
- },
- ),
- (
- "Treasurer report",
- {
- "fields": ("treasurer_balance", "treasurer_report"),
- },
- ),
- )
- filter_horizontal = ("board_attendees", "non_board_attendees")
- inlines = [
- GrantInline,
- IndividualMemberInline,
- CorporateMemberInline,
- BusinessInline,
- ActionItemInline,
- ]
- list_display = ("title", "date")
- list_filter = ("date",)
- prepopulated_fields = {"slug": ("title",)}
-
- def get_changeform_initial_data(self, request):
- title = _("DSF Board monthly meeting")
- return {
- "title": title,
- "slug": slugify(title),
- }
-
-
class CoreAwardAdmin(admin.ModelAdmin):
list_display = ["recipient", "cohort"]
diff --git a/foundation/feeds.py b/foundation/feeds.py
deleted file mode 100644
index 072a9c8e0a..0000000000
--- a/foundation/feeds.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from datetime import datetime, time
-
-from django.contrib.syndication.views import Feed
-from django.utils.timezone import make_aware
-from django.utils.translation import gettext_lazy as _
-
-from .models import Meeting
-
-
-class FoundationMinutesFeed(Feed):
- title = _("The DSF meeting minutes")
- link = "https://www.djangoproject.com/foundation/minutes/"
- description = _("The meeting minutes of the Django Software Foundation's board.")
-
- def items(self):
- return Meeting.objects.order_by("-date")[:10]
-
- def item_pubdate(self, item):
- return make_aware(datetime.combine(item.date, time.min))
-
- def item_author_name(self, item):
- return _("DSF Board")
-
- def item_title(self, item):
- return str(item)
diff --git a/foundation/migrations/0008_remove_approvedcorporatemember_approved_at_and_more.py b/foundation/migrations/0008_remove_approvedcorporatemember_approved_at_and_more.py
new file mode 100644
index 0000000000..3670dde7c4
--- /dev/null
+++ b/foundation/migrations/0008_remove_approvedcorporatemember_approved_at_and_more.py
@@ -0,0 +1,83 @@
+# Generated by Django 5.2 on 2025-09-23 05:51
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('foundation', '0007_boardmember_account_protect'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='approvedcorporatemember',
+ name='approved_at',
+ ),
+ migrations.RemoveField(
+ model_name='approvedgrant',
+ name='approved_at',
+ ),
+ migrations.RemoveField(
+ model_name='approvedindividualmember',
+ name='approved_at',
+ ),
+ migrations.RemoveField(
+ model_name='boardmember',
+ name='account',
+ ),
+ migrations.RemoveField(
+ model_name='boardmember',
+ name='office',
+ ),
+ migrations.RemoveField(
+ model_name='boardmember',
+ name='term',
+ ),
+ migrations.RemoveField(
+ model_name='meeting',
+ name='board_attendees',
+ ),
+ migrations.RemoveField(
+ model_name='meeting',
+ name='leader',
+ ),
+ migrations.RemoveField(
+ model_name='business',
+ name='meeting',
+ ),
+ migrations.RemoveField(
+ model_name='meeting',
+ name='non_board_attendees',
+ ),
+ migrations.DeleteModel(
+ name='ActionItem',
+ ),
+ migrations.DeleteModel(
+ name='ApprovedCorporateMember',
+ ),
+ migrations.DeleteModel(
+ name='ApprovedGrant',
+ ),
+ migrations.DeleteModel(
+ name='ApprovedIndividualMember',
+ ),
+ migrations.DeleteModel(
+ name='Office',
+ ),
+ migrations.DeleteModel(
+ name='Term',
+ ),
+ migrations.DeleteModel(
+ name='BoardMember',
+ ),
+ migrations.DeleteModel(
+ name='Business',
+ ),
+ migrations.DeleteModel(
+ name='Meeting',
+ ),
+ migrations.DeleteModel(
+ name='NonBoardAttendee',
+ ),
+ ]
diff --git a/foundation/models.py b/foundation/models.py
index 7ae1fa4acf..8540d3f0ff 100644
--- a/foundation/models.py
+++ b/foundation/models.py
@@ -1,243 +1,5 @@
-from decimal import Decimal
-
-from django.conf import settings
from django.db import models
-from django.urls import reverse
-from django.utils.dateformat import format as date_format
from django.utils.translation import gettext_lazy as _
-from djmoney.models.fields import MoneyField
-from djmoney.settings import CURRENCIES
-from docutils.core import publish_parts
-
-from blog.models import BLOG_DOCUTILS_SETTINGS
-
-
-class Office(models.Model):
- """
- An office held by a DSF Board member.
-
- """
-
- name = models.CharField(max_length=100, unique=True)
-
- def __str__(self):
- return self.name
-
-
-class Term(models.Model):
- """
- A term in which DSF Board members served.
-
- """
-
- year = models.CharField(max_length=4, unique=True)
-
- def __str__(self):
- return self.year
-
-
-class BoardMember(models.Model):
- """
- A DSF Board member.
-
- """
-
- account = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
- office = models.ForeignKey(Office, related_name="holders", on_delete=models.CASCADE)
- term = models.ForeignKey(
- Term, related_name="board_members", on_delete=models.CASCADE
- )
-
- def __str__(self):
- return f"{self.account.get_full_name()} ({self.office} - {self.term.year})"
-
-
-class NonBoardAttendee(models.Model):
- """
- A non-Board member attending a Board meeting.
-
- """
-
- name = models.CharField(max_length=255)
- role = models.CharField(max_length=100)
-
- class Meta:
- verbose_name = _("Non-board attendee")
- verbose_name_plural = _("Non-board attendees")
-
- def __str__(self):
- return f"{self.name} ({self.role})"
-
-
-class Meeting(models.Model):
- """
- A meeting of the DSF Board.
-
- """
-
- date = models.DateField()
- title = models.CharField(max_length=255)
- slug = models.SlugField()
- leader = models.ForeignKey(
- BoardMember, related_name="meetings_led", on_delete=models.CASCADE
- )
- board_attendees = models.ManyToManyField(
- BoardMember, related_name="meetings_attended"
- )
- non_board_attendees = models.ManyToManyField(
- NonBoardAttendee, related_name="meetings_attended", blank=True
- )
- treasurer_balance = MoneyField(
- max_digits=10,
- decimal_places=2,
- default_currency="USD",
- default=Decimal("0.0"),
- )
- treasurer_report = models.TextField(blank=True)
- treasurer_report_html = models.TextField(editable=False)
-
- def __str__(self):
- return "{}, {}".format(self.title, date_format(self.date, "F j, Y"))
-
- def save(self, *args, **kwargs):
- if self.treasurer_report:
- self.treasurer_report_html = publish_parts(
- source=self.treasurer_report,
- writer_name="html",
- settings_overrides=BLOG_DOCUTILS_SETTINGS,
- )["fragment"]
- super().save(*args, **kwargs)
-
- def get_absolute_url(self):
- return reverse(
- "foundation_meeting_detail",
- args=(),
- kwargs={
- "year": self.date.strftime("%Y"),
- "month": self.date.strftime("%b").lower(),
- "day": self.date.strftime("%d"),
- "slug": self.slug,
- },
- )
-
-
-class ApprovedGrant(models.Model):
- """
- A grant approved by the DSF Board.
-
- """
-
- entity = models.CharField(max_length=255)
- amount = MoneyField(
- max_digits=10,
- decimal_places=2,
- default_currency="USD",
- default=Decimal("0.0"),
- currency_choices=[
- (c.code, c.name)
- for i, c in CURRENCIES.items()
- if c.code
- in {
- "USD",
- "EUR",
- "AUD",
- "NGN",
- } # This set of currencies was extracted from current usage
- ],
- )
- approved_at = models.ForeignKey(
- Meeting, related_name="grants_approved", on_delete=models.CASCADE
- )
-
- class Meta:
- ordering = ("entity",)
-
- def __str__(self):
- return f"{self.entity}: {self.amount}"
-
-
-class ApprovedIndividualMember(models.Model):
- """
- An individual DSF member approved by the Board.
-
- """
-
- name = models.CharField(max_length=255)
- approved_at = models.ForeignKey(
- Meeting, related_name="individual_members_approved", on_delete=models.CASCADE
- )
-
- def __str__(self):
- return self.name
-
-
-class ApprovedCorporateMember(models.Model):
- """
- A corporate DSF member approved by the Board.
-
- """
-
- name = models.CharField(max_length=255)
- approved_at = models.ForeignKey(
- Meeting, related_name="corporate_members_approved", on_delete=models.CASCADE
- )
-
- def __str__(self):
- return self.name
-
-
-class Business(models.Model):
- """
- Business of the DSF Board.
-
- """
-
- NEW = "new"
- ONGOING = "ongoing"
-
- TYPE_CHOICES = (
- (NEW, _("New")),
- (ONGOING, _("Ongoing")),
- )
-
- title = models.CharField(max_length=255)
- body = models.TextField()
- body_html = models.TextField(editable=False)
- business_type = models.CharField(max_length=25, choices=TYPE_CHOICES)
- meeting = models.ForeignKey(
- Meeting, related_name="business", on_delete=models.CASCADE
- )
-
- class Meta:
- ordering = ("title",)
- verbose_name_plural = _("Business")
-
- def __str__(self):
- return self.title
-
- def save(self, *args, **kwargs):
- self.body_html = publish_parts(
- source=self.body,
- writer_name="html",
- settings_overrides=BLOG_DOCUTILS_SETTINGS,
- )["fragment"]
- super().save(*args, **kwargs)
-
-
-class ActionItem(models.Model):
- """
- A task to be completed by an attendee of a DSF Board meeting.
-
- """
-
- responsible = models.CharField(max_length=255)
- task = models.TextField()
- meeting = models.ForeignKey(
- Meeting, related_name="action_items", on_delete=models.CASCADE
- )
-
- def __str__(self):
- return self.task
class CoreAwardCohort(models.Model):
diff --git a/foundation/redirects.py b/foundation/redirects.py
new file mode 100644
index 0000000000..5ffc6b4082
--- /dev/null
+++ b/foundation/redirects.py
@@ -0,0 +1,79 @@
+MINUTES_BASE_URL = "https://github.com/django/dsf-minutes/blob/main/"
+
+MINUTES_DATES = {
+ (2019, 1, 10),
+ (2019, 2, 14),
+ (2019, 3, 14),
+ (2019, 4, 25),
+ (2019, 6, 13),
+ (2019, 7, 11),
+ (2019, 8, 8),
+ (2019, 9, 26),
+ (2019, 10, 10),
+ (2019, 11, 21),
+ (2019, 12, 17),
+ (2020, 1, 9),
+ (2020, 2, 13),
+ (2020, 3, 12),
+ (2020, 4, 9),
+ (2020, 5, 14),
+ (2020, 6, 11),
+ (2020, 7, 9),
+ (2020, 8, 13),
+ (2020, 9, 10),
+ (2020, 10, 8),
+ (2020, 11, 12),
+ (2020, 12, 16),
+ (2021, 1, 14),
+ (2021, 2, 11),
+ (2021, 3, 11),
+ (2021, 4, 8),
+ (2021, 5, 13),
+ (2021, 6, 17),
+ (2021, 7, 8),
+ (2021, 8, 11),
+ (2021, 9, 9),
+ (2021, 10, 14),
+ (2021, 11, 11),
+ (2021, 12, 16),
+ (2022, 1, 13),
+ (2022, 2, 10),
+ (2022, 3, 10),
+ (2022, 4, 14),
+ (2022, 5, 12),
+ (2022, 6, 9),
+ (2022, 7, 14),
+ (2022, 8, 11),
+ (2022, 9, 8),
+ (2022, 10, 13),
+ (2022, 11, 11),
+ (2022, 12, 8),
+ (2023, 1, 12),
+ (2023, 2, 8),
+ (2023, 3, 9),
+ (2023, 4, 13),
+ (2023, 5, 13),
+ (2023, 6, 8),
+ (2023, 7, 14),
+ (2023, 9, 14),
+ (2023, 10, 12),
+ (2023, 11, 9),
+ (2023, 12, 14),
+ (2024, 1, 11),
+ (2024, 2, 8),
+ (2024, 3, 14),
+ (2024, 4, 11),
+ (2024, 5, 16),
+ (2024, 6, 6),
+ (2024, 6, 13),
+ (2024, 7, 18),
+ (2024, 8, 8),
+ (2024, 9, 12),
+ (2024, 10, 10),
+ (2024, 11, 19),
+ (2024, 12, 10),
+ (2025, 1, 9),
+ (2025, 2, 13),
+ (2025, 3, 13),
+ (2025, 4, 10),
+}
diff --git a/foundation/templatetags/__init__.py b/foundation/templatetags/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/foundation/templatetags/meetings.py b/foundation/templatetags/meetings.py
deleted file mode 100644
index 80aeec8e83..0000000000
--- a/foundation/templatetags/meetings.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from django import template
-
-from ..models import Meeting
-
-register = template.Library()
-
-
-@register.inclusion_tag("foundation/meeting_snippet.html")
-def render_latest_meeting_minute_entries(num):
- meetings = Meeting.objects.order_by("-date").prefetch_related("business")[:num]
- return {"meetings": meetings}
diff --git a/foundation/tests.py b/foundation/tests.py
index 38569f3756..efbb8c7211 100644
--- a/foundation/tests.py
+++ b/foundation/tests.py
@@ -1,11 +1,8 @@
-from datetime import date
-
from django.contrib.auth.models import User
+from django.contrib.flatpages.models import FlatPage
+from django.contrib.sites.models import Site
from django.test import TestCase
from django.urls import reverse
-from djmoney.money import Money
-
-from .models import ApprovedGrant, BoardMember, Business, Meeting, Office, Term
class MeetingTestCase(TestCase):
@@ -14,102 +11,38 @@ def setUpTestData(cls):
cls.user = User.objects.create_superuser(
"admin", "admin@example.com", "password"
)
- cls.member = BoardMember.objects.create(
- account=cls.user,
- office=Office.objects.create(name="treasurer"),
- term=Term.objects.create(year=2023),
- )
-
- def test_meeting_initial(self):
- self.client.force_login(self.user)
- response = self.client.get(reverse("admin:foundation_meeting_add"))
- self.assertContains(response, "DSF Board monthly meeting")
- self.assertContains(response, "dsf-board-monthly-meeting")
-
- def test_meeting_minutes_feed(self):
- """
- Make sure that the meeting minutes RSS feed works
- """
- Meeting.objects.create(
- date=date.today(),
- title="DSF Board monthly meeting",
- slug="dsf-board-monthly-meeting",
- leader=self.member,
- treasurer_report="Hello World",
- )
-
- response = self.client.get(reverse("foundation-minutes-feed"))
- self.assertEqual(response.status_code, 200)
- self.assertIn(b"DSF Board monthly meeting", response.content)
-
- def test_meeting_details(self):
- meeting = Meeting.objects.create(
- date=date(2023, 1, 12),
- title="DSF Board monthly meeting",
- slug="dsf-board-monthly-meeting",
- leader=self.member,
- treasurer_report="Hello World",
- )
- ApprovedGrant.objects.create(
- entity="Django girls",
- amount=Money("10000", "USD"),
- approved_at=meeting,
- )
- ApprovedGrant.objects.create(
- entity="DjangoCon EU",
- amount=Money(5000, "EUR"),
- approved_at=meeting,
- )
- response = self.client.get(
- reverse(
- "foundation_meeting_detail",
- kwargs={
- "year": 2023,
- "month": "jan",
- "day": 12,
- "slug": "dsf-board-monthly-meeting",
- },
- )
- )
- self.assertContains(response, "DSF Board monthly meeting")
- self.assertContains(response, "USD $10,000.00")
- self.assertContains(response, "EUR €5,000.00")
+ cls.site = Site.objects.get_current()
def test_latest_meeting_minutes(self):
- common_meeting_data = {
- "slug": "dsf-board-monthly-meeting",
- "leader": self.member,
- "treasurer_report": "Hello World",
- "title": "DSF Board monthly meeting",
- }
- latest_meeting = Meeting.objects.create(
- date=date(2023, 5, 12), **common_meeting_data
+ page = FlatPage.objects.create(
+ title="Foundation",
+ url="/foundation/",
+ template_name="flatpages/foundation.html",
)
- previous_meeting = Meeting.objects.create(
- date=date(2023, 4, 12), **common_meeting_data
- )
- Meeting.objects.create(date=date(2023, 3, 12), **common_meeting_data)
- common_business_data = {
- "body": "Example",
- "body_html": "Example",
- "business_type": "New",
- "meeting": latest_meeting,
- }
- Business.objects.create(title="Business item 1", **common_business_data)
- Business.objects.create(title="Business item 2", **common_business_data)
- Business.objects.create(title="Business item 3", **common_business_data)
+ page.sites.add(self.site)
- response = self.client.get(reverse("foundation_meeting_archive_index"))
+ response = self.client.get("/foundation/")
self.assertContains(response, "Latest DSF meeting minutes")
+ self.assertContains(response, "https://github.com/django/dsf-minutes")
- self.assertContains(response, "DSF Board monthly meeting, May 12, 2023")
- self.assertContains(response, latest_meeting.get_absolute_url())
- self.assertContains(response, "DSF Board monthly meeting, April 12, 2023")
- self.assertContains(response, previous_meeting.get_absolute_url())
- self.assertNotContains(response, "DSF Board monthly meeting, March 12, 2023")
+ def test_minutes_redirect(self):
+ url = reverse(
+ "minutes_redirect",
+ kwargs={"year": 2025, "month": "Feb", "day": 13, "slug": "foo"},
+ )
+ response = self.client.get(url)
+ self.assertRedirects(
+ response,
+ "https://github.com/django/dsf-minutes/blob/main/2025/2025-02-13.md",
+ status_code=301,
+ fetch_redirect_response=False,
+ )
- self.assertContains(response, "New and Ongoing business", count=1)
- self.assertContains(response, "Business item 1")
- self.assertContains(response, "Business item 2")
- self.assertContains(response, "Business item 3")
+ def test_minutes_redirect_not_found(self):
+ url = reverse(
+ "minutes_redirect",
+ kwargs={"year": 2025, "month": "Jan", "day": 13, "slug": "foo"},
+ )
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
diff --git a/foundation/urls/__init__.py b/foundation/urls/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/foundation/urls/meetings.py b/foundation/urls/meetings.py
deleted file mode 100644
index 8198567a97..0000000000
--- a/foundation/urls/meetings.py
+++ /dev/null
@@ -1,29 +0,0 @@
-from django.urls import path
-
-from .. import views
-
-urlpatterns = [
- path(
- "", views.MeetingArchiveIndex.as_view(), name="foundation_meeting_archive_index"
- ),
- path(
- "/",
- views.MeetingArchiveYear.as_view(),
- name="foundation_meeting_archive_year",
- ),
- path(
- "//",
- views.MeetingArchiveMonth.as_view(),
- name="foundation_meeting_archive_month",
- ),
- path(
- "///",
- views.MeetingArchiveDay.as_view(),
- name="foundation_meeting_archive_day",
- ),
- path(
- "////",
- views.MeetingDetail.as_view(),
- name="foundation_meeting_detail",
- ),
-]
diff --git a/foundation/views.py b/foundation/views.py
index 0cef304de2..f959816e64 100644
--- a/foundation/views.py
+++ b/foundation/views.py
@@ -1,61 +1,24 @@
-from django.views import generic
-
-from . import models
-
-
-class MeetingMixin:
- date_field = "date"
- model = models.Meeting
-
-
-class MeetingArchiveIndex(MeetingMixin, generic.ArchiveIndexView):
- pass
-
-
-class MeetingArchiveYear(MeetingMixin, generic.YearArchiveView):
- make_object_list = True
-
+from datetime import datetime
-class MeetingArchiveMonth(MeetingMixin, generic.MonthArchiveView):
- pass
-
-
-class MeetingArchiveDay(MeetingMixin, generic.DayArchiveView):
- pass
-
-
-class MeetingDetail(MeetingMixin, generic.DateDetailView):
- context_object_name = "meeting"
-
- def get_queryset(self):
- return (
- super()
- .get_queryset()
- .select_related("leader")
- .prefetch_related(
- "grants_approved",
- "individual_members_approved",
- "corporate_members_approved",
- "business",
- "action_items",
- "board_attendees",
- "non_board_attendees",
- )
- )
+from django.http import Http404
+from django.shortcuts import redirect
+from django.views import generic
- def get_context_data(self, **kwargs):
- context_data = super().get_context_data(**kwargs)
- meeting = context_data["object"]
- context_data["ongoing_business"] = meeting.business.filter(
- business_type=models.Business.ONGOING
- )
- context_data["new_business"] = meeting.business.filter(
- business_type=models.Business.NEW
- )
- return context_data
+from . import models, redirects
class CoreDevelopers(generic.ListView):
queryset = models.CoreAwardCohort.objects.prefetch_related("recipients").order_by(
"-cohort_date"
)
+
+
+def minutes_redirect(request, year, month, day, slug):
+ minutes_date = datetime.strptime(f"{year}-{month}-{day}", "%Y-%b-%d").date()
+ year, month, day = minutes_date.timetuple()[:3]
+ if (year, month, day) not in redirects.MINUTES_DATES:
+ raise Http404
+ return redirect(
+ f"{redirects.MINUTES_BASE_URL}{year}/{year}-{month:02}-{day:02}.md",
+ permanent=True,
+ )