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 @@
- {{ membership.person.name }} + {% 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 @@

- {{membership.person.name}} + {% with person=membership.person %} + {% include "common/headshot.html" %} + {% endwith %}
@@ -107,7 +109,9 @@

- {{ceo.name}} + {% 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 %} + {{person.name}} +{% 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 %} +
-
- {{person.name}} +
+ {% 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 %} - -
- {% csrf_token %} - - {% if bio_error %} -

*{{bio_error}}

- {% endif %} - {{biography_form.bio_form}} - - {{biography_form.councilmatic_biography}} - - -
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 %} - -
- {% csrf_token %} - - {% if headshot_error %} -

*{{headshot_error}}

- {% endif %} - {{headshot_form.headshot_form}} - - - - -
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 %}
-
-
- {{person.name}} -
- -
- {% 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""" +
+ """ + ) + + +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'' - ) + 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