diff --git a/lametro/forms.py b/lametro/forms.py
index f42b7b12..87bf37f8 100644
--- a/lametro/forms.py
+++ b/lametro/forms.py
@@ -10,7 +10,6 @@
from haystack.query import EmptySearchQuerySet
from councilmatic_core.views import CouncilmaticSearchForm
-from lametro.models import LAMetroPerson
class LAMetroCouncilmaticSearchForm(CouncilmaticSearchForm):
@@ -151,33 +150,3 @@ def clean_agenda(self):
return agenda_pdf
else:
raise forms.ValidationError("File type not supported. Please submit a PDF.")
-
-
-class PersonHeadshotForm(forms.ModelForm):
- headshot_form = forms.BooleanField(widget=forms.HiddenInput, initial=True)
-
- def __init__(self, *args, **kwargs):
- super(PersonHeadshotForm, self).__init__(*args, **kwargs)
- self.fields["headshot"].widget.attrs.update(
- {
- "required": "True",
- }
- )
-
- class Meta:
- model = LAMetroPerson
- fields = ["headshot"]
-
-
-class PersonBioForm(forms.ModelForm):
- bio_form = forms.BooleanField(widget=forms.HiddenInput, initial=True)
-
- def __init__(self, *args, **kwargs):
- super(PersonBioForm, self).__init__(*args, **kwargs)
- self.fields["councilmatic_biography"].widget.attrs.update(
- {"rows": "5", "required": "True"}
- )
-
- class Meta:
- model = LAMetroPerson
- fields = ["councilmatic_biography"]
diff --git a/lametro/migrations/0018_boardmemberdetails.py b/lametro/migrations/0018_boardmemberdetails.py
new file mode 100644
index 00000000..84442d47
--- /dev/null
+++ b/lametro/migrations/0018_boardmemberdetails.py
@@ -0,0 +1,132 @@
+# Generated by Django 3.2.25 on 2024-12-20 15:25
+
+from django.db import migrations, models
+import django.db.models.deletion
+import wagtail.fields
+import wagtail.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("wagtailimages", "0025_alter_image_file_alter_rendition_file"),
+ ("wagtailcore", "0089_log_entry_data_json_null_to_object"),
+ ("lametro", "0017_alter_alert_description"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="BoardMemberDetails",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "live",
+ models.BooleanField(
+ default=True, editable=False, verbose_name="live"
+ ),
+ ),
+ (
+ "has_unpublished_changes",
+ models.BooleanField(
+ default=False,
+ editable=False,
+ verbose_name="has unpublished changes",
+ ),
+ ),
+ (
+ "first_published_at",
+ models.DateTimeField(
+ blank=True,
+ db_index=True,
+ null=True,
+ verbose_name="first published at",
+ ),
+ ),
+ (
+ "last_published_at",
+ models.DateTimeField(
+ editable=False, null=True, verbose_name="last published at"
+ ),
+ ),
+ (
+ "go_live_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="go live date/time"
+ ),
+ ),
+ (
+ "expire_at",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="expiry date/time"
+ ),
+ ),
+ (
+ "expired",
+ models.BooleanField(
+ default=False, editable=False, verbose_name="expired"
+ ),
+ ),
+ (
+ "headshot_source",
+ models.CharField(
+ blank=True, default="Metro", max_length=256, null=True
+ ),
+ ),
+ ("bio", wagtail.fields.RichTextField(blank=True, null=True)),
+ (
+ "headshot",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="+",
+ to="wagtailimages.image",
+ ),
+ ),
+ (
+ "latest_revision",
+ models.ForeignKey(
+ blank=True,
+ editable=False,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="+",
+ to="wagtailcore.revision",
+ verbose_name="latest revision",
+ ),
+ ),
+ (
+ "live_revision",
+ models.ForeignKey(
+ blank=True,
+ editable=False,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="+",
+ to="wagtailcore.revision",
+ verbose_name="live revision",
+ ),
+ ),
+ (
+ "person",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="details",
+ to="lametro.lametroperson",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name_plural": "Board Member Details",
+ },
+ bases=(wagtail.models.PreviewableMixin, models.Model),
+ ),
+ ]
diff --git a/lametro/models/cms.py b/lametro/models/cms.py
index 13aa6306..667422bc 100644
--- a/lametro/models/cms.py
+++ b/lametro/models/cms.py
@@ -1,7 +1,9 @@
+from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
+from django.urls import reverse
from django.utils.html import format_html, strip_tags
-from wagtail.models import Page
+from wagtail.models import Page, PreviewableMixin, DraftStateMixin, RevisionMixin
from wagtail.fields import StreamField, RichTextField
from wagtail.admin.panels import FieldPanel
from wagtail.rich_text import expand_db_html
@@ -24,6 +26,64 @@ class AboutPage(Page):
]
+class BoardMemberDetails(
+ DraftStateMixin, RevisionMixin, PreviewableMixin, models.Model
+):
+ include_in_dump = True
+
+ class Meta:
+ verbose_name_plural = "Board Member Details"
+
+ person = models.OneToOneField(
+ "lametro.LAMetroPerson", on_delete=models.CASCADE, related_name="details"
+ )
+ headshot = models.ForeignKey(
+ "wagtailimages.Image",
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name="+",
+ )
+ headshot_source = models.CharField(
+ max_length=256, blank=True, null=True, default="Metro"
+ )
+ bio = RichTextField(blank=True, null=True)
+ _revisions = GenericRelation(
+ "wagtailcore.Revision", related_query_name="member_details"
+ )
+
+ @property
+ def revisions(self):
+ return self._revisions
+
+ def get_url(self):
+ return reverse("lametro:person", kwargs={"slug": self.person.slug})
+
+ def __str__(self):
+ loaded_obj = (
+ type(self)
+ .objects.select_related("person")
+ .prefetch_related("person__memberships")
+ .get(id=self.id)
+ )
+ return f"{loaded_obj.person.name}{' (current)' if loaded_obj.person.current_memberships.exists() else ''}"
+
+ def get_preview_context(self, request, mode_name):
+ context = super().get_preview_context(request, mode_name)
+ context["person"] = self.person
+ context["person_details"] = self
+
+ council_post = self.person.latest_council_membership.post
+ context["qualifying_post"] = council_post.acting_label
+
+ context["preview"] = True
+
+ return context
+
+ def get_preview_template(self, request, mode_name):
+ return "person/person.html"
+
+
class Alert(models.Model):
include_in_dump = True
diff --git a/lametro/models/legislative.py b/lametro/models/legislative.py
index 6fb8d38c..633b3275 100644
--- a/lametro/models/legislative.py
+++ b/lametro/models/legislative.py
@@ -469,8 +469,9 @@ def ceo(cls):
@property
def headshot_url(self):
- if self.headshot:
- return self.headshot.url
+ print(self.details.headshot)
+ if self.details.headshot:
+ return self.details.headshot
file_directory = os.path.dirname(__file__)
absolute_file_directory = os.path.abspath(file_directory)
diff --git a/lametro/signals/handlers.py b/lametro/signals/handlers.py
new file mode 100644
index 00000000..88ec4f45
--- /dev/null
+++ b/lametro/signals/handlers.py
@@ -0,0 +1,12 @@
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+
+from lametro.models import LAMetroPerson, BoardMemberDetails
+
+
+@receiver(post_save, sender=LAMetroPerson)
+def create_member_details(sender, instance, created, **kwargs):
+ details_exist = BoardMemberDetails.objects.filter(person=instance).exists()
+
+ if not details_exist:
+ BoardMemberDetails.objects.create(person=instance)
diff --git a/lametro/static/css/city_custom.css b/lametro/static/css/city_custom.css
index 9808f930..0be0de96 100644
--- a/lametro/static/css/city_custom.css
+++ b/lametro/static/css/city_custom.css
@@ -367,10 +367,6 @@ hr .events-line {
.current-meeting-img {
max-width: 75%;
}
-
- #person-detail-headshot {
- max-height: none;
- }
}
@media only screen and (max-width : 415px) {
@@ -818,3 +814,8 @@ caption {
.alert p:last-of-type {
margin: unset;
}
+
+.thumbnail-square {
+ width: 75px;
+ height: 75px;
+}
diff --git a/lametro/templates/board_members/_council_member_table.html b/lametro/templates/board_members/_council_member_table.html
index 66b6c595..e155a9da 100644
--- a/lametro/templates/board_members/_council_member_table.html
+++ b/lametro/templates/board_members/_council_member_table.html
@@ -14,7 +14,9 @@
-
+ {% with person=membership.person aspect_ratio="fill-100x100" %}
+ {% include 'common/headshot.html' %}
+ {% endwith %}
diff --git a/lametro/templates/committee.html b/lametro/templates/committee.html
index 019bc2ba..696e532c 100644
--- a/lametro/templates/committee.html
+++ b/lametro/templates/committee.html
@@ -79,7 +79,9 @@
-
+ {% with person=membership.person %}
+ {% include "common/headshot.html" %}
+ {% endwith %}
@@ -107,7 +109,9 @@
-
+ {% with person=ceo %}
+ {% include "common/headshot.html" %}
+ {% endwith %}
{{ ceo.name }}
diff --git a/lametro/templates/common/headshot.html b/lametro/templates/common/headshot.html
new file mode 100644
index 00000000..a580a8bb
--- /dev/null
+++ b/lametro/templates/common/headshot.html
@@ -0,0 +1,11 @@
+{% load static wagtailimages_tags %}
+
+{% if person.details.headshot %}
+ {% if original %}
+ {% image person.details.headshot width-640 class="img-fluid rounded-3 p-1" alt=person.name %}
+ {% else %}
+ {% image person.details.headshot fill-100x100 class="img-fluid rounded-3 p-1" alt=person.name %}
+ {% endif %}
+{% else %}
+
+{% endif %}
diff --git a/lametro/templates/person/_person_ceo.html b/lametro/templates/person/_person_ceo.html
index f2ec4190..4824539d 100644
--- a/lametro/templates/person/_person_ceo.html
+++ b/lametro/templates/person/_person_ceo.html
@@ -1,10 +1,19 @@
{% load extras %}
-{% load lametro_extras %}
+{% load lametro_extras wagtailcore_tags %}
+
-
-
+
+ {% include "common/headshot.html" %}
+ {% if person_details.headshot %}
+
+
+ Credit: {{person_details.headshot_source}}
+
+ {% endif %}
+
+
{% if qualifying_post %}
@@ -12,16 +21,18 @@
{% endif %}
- {% if person.headshot_source %}
-
-
- Credit: {{person.headshot_source}}
-
- {% endif %}
-
+
+
+
+ More about Metro appointments
+
+
+
-
-
About {{ ceo.name }}
-
{{ member_bio | safe }}
-
+ {% if person_details.bio %}
+
+
About {{ person.name }}
+
{{ person_details.bio | richtext }}
+
+ {% endif %}
diff --git a/lametro/templates/person/partials/person_bio_form.html b/lametro/templates/person/partials/person_bio_form.html
deleted file mode 100644
index 997a3a97..00000000
--- a/lametro/templates/person/partials/person_bio_form.html
+++ /dev/null
@@ -1,15 +0,0 @@
-{% load extras %}
-{% load lametro_extras %}
-
-
diff --git a/lametro/templates/person/partials/person_headshot_form.html b/lametro/templates/person/partials/person_headshot_form.html
deleted file mode 100644
index 659daa16..00000000
--- a/lametro/templates/person/partials/person_headshot_form.html
+++ /dev/null
@@ -1,15 +0,0 @@
-{% load extras %}
-{% load lametro_extras %}
-
-
diff --git a/lametro/templates/person/person.html b/lametro/templates/person/person.html
index 991d79a3..f137e524 100644
--- a/lametro/templates/person/person.html
+++ b/lametro/templates/person/person.html
@@ -1,6 +1,6 @@
{% extends "base_with_margins.html" %}
-{% load adv_cache extras lametro_extras static %}
+{% load adv_cache extras lametro_extras static wagtailcore_tags %}
{% block title %}{{ person.name }}{% endblock %}
@@ -11,7 +11,6 @@
{% endblock %}
{% block content %}
-
{{ person.name }}
@@ -19,60 +18,42 @@
{{ person.name }}
{% if person.current_council_seat %}
{{ person.current_council_seat.role }}
- {% else %}
+ {% elif person.latest_council_membership.role %}
Former {{ person.latest_council_membership.role }}
{% endif %}
-
- {% comment %}
- The Metro CEO does not sponsor reports or serve as a committee member.
- His/her/their detail view provides a bio, in lieu of the 'Sponsorhsips' and 'Committees' lists.
- {% endcomment %}
- {% if qualifying_post == 'Chief Executive Officer' %}
- {% include 'person/_person_ceo.html' %}
- {% else %}
-
-
-
-
-
-
- {% if qualifying_post %}
-
-
- {{ qualifying_post | appointment_label }}
-
- {% endif %}
-
- {% if person.headshot_source %}
-
-
- Credit: {{person.headshot_source}}
-
- {% endif %}
+ {% with original=True %}
+ {% include "common/headshot.html" %}
+ {% endwith %}
+
+
+ {% if person_details.headshot %}
+
+ Credit: {{person_details.headshot_source}}
+ {% endif %}
+
+
+ {% if qualifying_post %}
+
+
+ {{ qualifying_post | appointment_label }}
+
+
+
+ More about Metro appointments
+
+ {% endif %}
-
-
-
- More about Metro appointments
-
-
+ {% if not preview and map_geojson %}
- {% if user.is_authenticated %}
- {% include './partials/person_headshot_form.html' %}
- {% endif %}
-
-
- {% if map_geojson %}
-
{% if person.current_district %}
{{ person.current_district | format_district }} map
@@ -85,17 +66,17 @@ {{ person.name }}
- {% if person.current_bio %}
+ {% if person_details.bio %}
About {{ person.name }}
-
{{person.current_bio | safe}}
- {% endif %}
-
- {% if user.is_authenticated %}
- {% include './partials/person_bio_form.html' %}
+
{{person_details.bio | richtext}}
{% endif %}
+ {% comment %}
+ The Metro CEO does not sponsor reports or serve as a committee member.
+ {% endcomment %}
+ {% if not preview and qualifying_post != 'Chief Executive Officer'%}
- {% endif %}
@@ -183,7 +163,7 @@
console.log('source: {{source}}');
{% endfor %}
- {% if map_geojson %}
+ {% if not preview and map_geojson %}
diff --git a/lametro/templates/snippets/related_object_status_tag.html b/lametro/templates/snippets/related_object_status_tag.html
new file mode 100644
index 00000000..e374797c
--- /dev/null
+++ b/lametro/templates/snippets/related_object_status_tag.html
@@ -0,0 +1,4 @@
+{% load wagtailadmin_tags %}
+
+ {% status value url=url title=_("Visit the live page") classname="w-status--primary" attrs='target="_blank" rel="noreferrer"' %}
+
diff --git a/lametro/views.py b/lametro/views.py
index bd348587..ce43c495 100644
--- a/lametro/views.py
+++ b/lametro/views.py
@@ -38,7 +38,6 @@
)
from django.core import management
from django.core.serializers import serialize
-from django.core.cache import cache
from councilmatic_core.views import (
IndexView,
@@ -64,13 +63,12 @@
LAMetroOrganization,
LAMetroSubject,
EventBroadcast,
+ BoardMemberDetails,
)
from lametro.forms import (
AgendaUrlForm,
AgendaPdfForm,
LAMetroCouncilmaticSearchForm,
- PersonHeadshotForm,
- PersonBioForm,
)
from councilmatic.settings_jurisdiction import MEMBER_BIOS
@@ -662,63 +660,26 @@ def dispatch(self, request, *args, **kwargs):
return response
- def post(self, request, *args, **kwargs):
- slug = self.kwargs["slug"]
- person = self.model.objects.get(slug=slug)
- self.object = self.get_object()
-
- # The submitted hidden field determines which form was used
- if "bio_form" in request.POST:
- form = PersonBioForm(request.POST, instance=person)
- bio_content = request.POST.get("councilmatic_biography")
-
- # Prevent whitespace from being submitted
- if bio_content.isspace():
- error = "Please fill out the bio"
- return self.render_to_response(
- self.get_context_data(form=form, bio_error=error)
- )
- else:
- form.save()
- return HttpResponseRedirect(self.request.path_info)
-
- elif "headshot_form" in request.POST:
- form = PersonHeadshotForm(request.POST, instance=person)
- file_obj = request.FILES.get("headshot", "")
-
- is_valid_file = self.validate_file(file_obj)
-
- if not is_valid_file:
- error = "Must be a valid image file, and size must be under 7.5mb."
- return self.render_to_response(
- self.get_context_data(form=form, headshot_error=error)
- )
-
- person.headshot = file_obj
- person.save()
-
- cache.clear()
-
- return HttpResponseRedirect(self.request.path_info)
-
- def validate_file(self, file):
- image_formats = (".png", ".jpeg", ".jpg", ".tif", ".tiff", ".webp", ".avif")
-
- is_image = file.name.endswith(tuple(image_formats))
- max_file_size = 7864320 # 7.5mb
-
- if is_image and file.size <= max_file_size:
- return True
- return False
-
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
person = context["person"]
- context["headshot_form"] = PersonHeadshotForm
- context["biography_form"] = PersonBioForm
+ try:
+ person_details = BoardMemberDetails.objects.get(person=person)
+ except BoardMemberDetails.DoesNotExist:
+ context["person_details"] = None
+ else:
+ context["person_details"] = (
+ person_details.live_revision.as_object()
+ if person_details.live_revision
+ else None
+ )
- council_post = person.latest_council_membership.post
+ council_post = (
+ person.latest_council_membership.post
+ if person.latest_council_membership
+ else ""
+ )
try:
context["qualifying_post"] = council_post.acting_label
diff --git a/lametro/wagtail_hooks.py b/lametro/wagtail_hooks.py
index 1d1e4ff9..7fabb5b0 100644
--- a/lametro/wagtail_hooks.py
+++ b/lametro/wagtail_hooks.py
@@ -1,10 +1,25 @@
+from django.contrib import admin # noqa
from django.templatetags.static import static
from django.utils.html import format_html
from wagtail import hooks
+from wagtail.admin.admin_url_finder import AdminURLFinder
+from wagtail.admin.panels import (
+ FieldPanel,
+ MultiFieldPanel,
+ ObjectList,
+ PublishingPanel,
+ HelpPanel,
+)
+from wagtail.admin.ui.tables import UpdatedAtColumn, LiveStatusTagColumn
from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register
+from wagtail.permissions import ModelPermissionPolicy
+from wagtail.snippets.models import register_snippet
+from wagtail.snippets.views.snippets import SnippetViewSet
-from .models import Alert
+import django_filters
+
+from lametro.models import Alert, BoardMemberDetails, LAMetroOrganization
class AlertAdmin(ModelAdmin):
@@ -23,22 +38,189 @@ class AlertAdmin(ModelAdmin):
)
+BOOLEAN_CHOICES = (
+ ("true", "Yes"),
+ ("false", "No"),
+)
+
+
+class NoAddModelPermissionPolicy(ModelPermissionPolicy):
+ """
+ Model permission that doesn't allow creating new instances.
+ """
+
+ def user_has_permission(self, user, action):
+ if action in ("add", "delete"):
+ return False
+ return user.has_perm(self._get_permission_name(action))
+
+
+class BoardMemberFilterSet(django_filters.FilterSet):
+ current_member = django_filters.ChoiceFilter(
+ label="Current member",
+ choices=BOOLEAN_CHOICES,
+ method="filter_current_member",
+ )
+ organization = django_filters.ModelChoiceFilter(
+ label="Member of",
+ queryset=LAMetroOrganization.objects.filter(memberships__isnull=False)
+ .order_by("name")
+ .distinct(),
+ method="filter_organization",
+ )
+
+ def filter_current_member(self, queryset, name, value):
+ people = [
+ page.person
+ for page in queryset.select_related("person")
+ .prefetch_related("person__memberships")
+ .distinct()
+ ]
+
+ if value == "true":
+ filter_func = lambda person: person.current_memberships.exists() # noqa
+ else:
+ filter_func = lambda person: not person.current_memberships.exists() # noqa
+
+ return queryset.filter(
+ person__id__in=(p.id for p in filter(filter_func, people))
+ )
+
+ def filter_organization(self, queryset, name, value):
+ return queryset.filter(person__memberships__organization=value).distinct()
+
+ class Meta:
+ model = BoardMemberDetails
+ fields = ["current_member"]
+
+
+class LinkedStatusTagColumn(LiveStatusTagColumn):
+ cell_template_name = "snippets/related_object_status_tag.html"
+
+ def get_cell_context_data(self, instance, parent_context):
+ print("getting context")
+ context = super().get_cell_context_data(instance, parent_context)
+ context["url"] = instance.get_url()
+ return context
+
+
+class BoardMemberDetailsViewSet(SnippetViewSet):
+ model = BoardMemberDetails
+ icon = "user"
+ add_to_admin_menu = True
+ menu_icon = "user"
+ menu_order = 200
+ filterset_class = BoardMemberFilterSet
+ ordering = ("person__name",)
+ search_fields = ("person__name",)
+ list_display = [
+ "__str__",
+ UpdatedAtColumn(),
+ LinkedStatusTagColumn(),
+ ]
+
+ @property
+ def permission_policy(self):
+ return NoAddModelPermissionPolicy(self.model)
+
+ edit_handler = ObjectList(
+ [
+ HelpPanel(
+ content=(
+ " On this page, you can manage a board member's headshot and "
+ "bio. All other details and relationships, e.g., memberships, "
+ "should be managed in Legistar.
"
+ "Note: The page preview excludes "
+ "the map and committee and board report modules displayed "
+ "on the live page.
"
+ )
+ ),
+ MultiFieldPanel(
+ [
+ FieldPanel("headshot", heading="Image"),
+ FieldPanel("headshot_source", heading="Source"),
+ ],
+ heading="Headshot",
+ ),
+ FieldPanel("bio"),
+ PublishingPanel(),
+ ]
+ )
+
+
modeladmin_register(AlertAdmin)
+register_snippet(BoardMemberDetailsViewSet)
+
+
+class UserBarLink:
+ icon_name = "edit"
+ def get_icon(self):
+ return format_html(
+ f"""
+ '
+
+
+ """
+ )
+
+ def render(self, request, href=None):
+ if href is None:
+ href = self.get_href(request)
+
+ link_text = self.get_link_text(request)
+
+ return format_html(
+ f"""
+
+
+ {self.get_icon()}
+ {link_text}
+
+
+ """
+ )
+
+
+class ModelAdminLink(UserBarLink):
+ icon_name = "plus"
-class ModelAdminLink:
def __init__(self, modeladmin_cls):
self.modeladmin = modeladmin_cls()
+ def get_href(self, request):
+ return self.modeladmin.url_helper.index_url
+
+ def get_link_text(self, request):
+ return f"Manage {self.modeladmin.get_menu_label().lower()}"
+
+
+class BoardMemberEditLink(UserBarLink):
def render(self, request):
- return (
- f'Manage {self.modeladmin.get_menu_label()} '
- )
+ if href := self.get_href(request):
+ return super().render(request, href=href)
+
+ def get_href(self, request):
+ try:
+ slug = request.resolver_match.kwargs["slug"]
+ except KeyError:
+ return
+
+ try:
+ snippet = BoardMemberDetails.objects.get(person__slug=slug)
+ except BoardMemberDetails.DoesNotExist:
+ return
+
+ finder = AdminURLFinder()
+ return finder.get_edit_url(snippet)
+
+ def get_link_text(self, request):
+ return "Edit this page"
@hooks.register("construct_wagtail_userbar")
def add_modeladmin_links(request, items):
+ items.append(BoardMemberEditLink())
items.append(ModelAdminLink(AlertAdmin))
return items