from datetime import date
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel
from taggit.managers import TaggableManager
from wagtail.admin.edit_handlers import (
FieldPanel,
FieldRowPanel,
InlinePanel,
MultiFieldPanel,
StreamFieldPanel,
)
from wagtail.core.fields import RichTextField
from wagtail.core.models import Page
from wagtail.images.edit_handlers import ImageChooserPanel
from wagtail.search import index
from cdhweb.pages.models import (
PARAGRAPH_FEATURES,
BasePage,
DateRange,
LandingPage,
LinkPage,
RelatedLink,
)
[docs]class Title(models.Model):
"""Job titles for people"""
title = models.CharField(max_length=255, unique=True)
sort_order = models.PositiveIntegerField(default=0, blank=False, null=False)
positions = models.ManyToManyField("people.Person", through="Position")
class Meta:
ordering = ["sort_order"]
def __str__(self):
return self.title
[docs] def num_people(self):
"""Number of people with this position title"""
# NOTE: maybe more meaningful if count restrict to _current_ titles?
return self.positions.distinct().count()
num_people.short_description = "# People"
[docs]class PersonQuerySet(models.QuerySet):
#: position titles that indicate a person is a postdoc
postdoc_titles = [
"Postdoctoral Fellow",
"Postdoctoral Fellow and Communications Lead",
]
#: position titles that indicate a person is a project director
director_roles = ["Project Director", "Co-PI: Research Lead"]
#: position titles that indicate a staff person is a student
student_titles = [
"Graduate Fellow",
"Graduate Assistant",
"Undergraduate Assistant",
"Postgraduate Research Associate",
]
#: membership roles that indicate someone is an affiliate
project_roles = ["Project Director", "Project Manager", "Co-PI: Research Lead"]
#: student status codes from LDAP
student_pu_status = ["graduate", "undergraduate"]
#: executive committee member titles
exec_member_title = "Executive Committee Member"
with_exec_title = "Sits with Executive Committee"
exec_committee_titles = [exec_member_title, with_exec_title]
[docs] def cdh_staff(self):
"""Return only CDH staff members"""
return self.filter(cdh_staff=True)
[docs] def student_affiliates(self):
"""Return CDH student staff members, grantees, and PGRAs based on
Project Director or Project Manager role."""
return (
self.filter(
models.Q(pu_status__in=self.student_pu_status)
| models.Q(positions__title__title__in=self.student_titles)
)
.filter(
models.Q(cdh_staff=True)
| models.Q(membership__role__title__in=self.project_roles)
)
.exclude(pu_status="stf")
)
[docs] def not_students(self):
"""Filter out students based on PU status"""
return self.exclude(pu_status__in=self.student_pu_status)
[docs] def affiliates(self):
"""Faculty and staff affiliates based on PU status and Project Director
project role. Excludes CDH staff."""
return self.filter(
pu_status__in=("fac", "stf"),
membership__role__title__in=self.director_roles,
).exclude(cdh_staff=True)
[docs] def executive_committee(self):
"""Executive committee members; based on position title."""
return self.filter(positions__title__title__in=self.exec_committee_titles)
[docs] def exec_member(self):
"""Executive committee members"""
return self.filter(positions__title__title=self.exec_member_title)
[docs] def sits_with_exec(self):
"""Non-faculty Executive committee members"""
return self.filter(positions__title__title=self.with_exec_title)
[docs] def grant_years(self):
"""Annotate with first start and last end grant year for grants
that a person was project director."""
# NOTE: filters within the aggregation query on project director
# but not on the entire query so that e.g. on the students
# page student staff without grants are still included
return self.annotate(
first_start=models.Min(
models.Case(
models.When(
membership__role__title__in=self.director_roles,
then="membership__start_date",
)
)
),
last_end=models.Max(
models.Case(
models.When(
membership__role__title__in=self.director_roles,
then="membership__end_date",
)
)
),
)
[docs] def project_manager_years(self):
"""Annotate with first start and last end grant year for grants
that a person was project manager."""
# NOTE: filters within the aggregation query on project manager
# but not on the entire query so that e.g. on the students
# page student staff without grants are still included
return self.annotate(
pm_start=models.Min(
models.Case(
models.When(
membership__role__title="Project Manager",
then="membership__start_date",
)
)
),
pm_end=models.Max(
models.Case(
models.When(
membership__role__title="Project Manager",
then="membership__end_date",
)
)
),
)
def _current_position_query(self):
# query to find a person with a current cdh position
# person *has* a position and it has no end date or date after today
today = date.today()
return models.Q(positions__start_date__lte=today) & (
models.Q(positions__end_date__isnull=True)
| models.Q(positions__end_date__gte=date.today())
)
def _current_project_member_query(self):
today = timezone.now()
return (
# in one of the allowed roles (project director/project manager)
models.Q(membership__role__title__in=self.project_roles)
&
# current based on membership dates
(
models.Q(membership__start_date__lte=today)
& (
models.Q(membership__end_date__gte=today)
| models.Q(membership__end_date__isnull=True)
)
)
)
[docs] def current(self):
"""Return people with a current position or current grant based on
start and end dates."""
# annotate with an is_current flag to make template logic simpler
return self.filter(
models.Q(self._current_position_query())
| models.Q(self._current_project_member_query())
).extra(select={"is_current": True})
# NOTE: couldn't get annotate to work
# .annotate(is_current=models.Value(True, output_field=models.BooleanField))
[docs] def current_grant(self):
"""Return profiles for users with a current grant."""
return self.filter(self._current_project_member_query())
[docs] def current_position(self):
"""Return profiles for users with a current position."""
return self.filter(self._current_position_query())
[docs] def current_position_nonexec(self):
"""Return profiles for users with a current position, excluding
current executive committee positions."""
cpq = self._current_position_query()
return (
self.filter(cpq)
.annotate(current_position_title=models.F("positions__title__title"))
.exclude(current_position_title__in=self.exec_committee_titles)
)
[docs] def order_by_position(self):
"""order by job title sort order and then by start date"""
# annotate to avoid duplicates in the queryset due to multiple positions
# sort on highest position title (= lowest number) and earliest start date (may
# not be from the same position)
return self.annotate(
min_title=models.Min("positions__title__sort_order"),
min_start=models.Min("positions__start_date"),
).order_by("min_title", "min_start", "last_name")
[docs]class Person(ClusterableModel):
# in cdhweb 2.x this was a proxy model for User;
# in 3.x it is a distinct model with 1-1 optional relationship to User
first_name = models.CharField("first name", max_length=150)
last_name = models.CharField("last name", max_length=150)
email = models.EmailField(null=True, blank=True)
user = models.OneToOneField(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Corresponding user account for this person (optional)",
)
cdh_staff = models.BooleanField(
"CDH Staff", default=False, help_text="CDH staff or Postdoctoral Fellow."
)
job_title = models.CharField(
max_length=255,
blank=True,
help_text="Professional title, e.g. Professor or Assistant Professor",
)
department = models.CharField(
max_length=255,
blank=True,
help_text="Academic Department at Princeton or other institution",
)
institution = models.CharField(
max_length=255,
blank=True,
help_text="Institutional affiliation (for people not associated with Princeton)",
)
phone_number = models.CharField(
max_length=50, blank=True, help_text="Office phone number"
)
office_location = models.CharField(
max_length=255, blank=True, help_text="Office number and building"
)
PU_STATUS_CHOICES = (
("fac", "Faculty"),
("stf", "Staff"),
("graduate", "Graduate Student"),
("undergraduate", "Undergraduate Student"),
("external", "Not associated with Princeton"),
)
pu_status = models.CharField(
"Princeton Status",
choices=PU_STATUS_CHOICES,
max_length=15,
blank=True,
default="",
)
image = models.ForeignKey(
"wagtailimages.image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
) # no reverse relationship
#: update timestamp
updated_at = models.DateTimeField(auto_now=True, null=True, editable=False)
# wagtail admin setup
panels = [
ImageChooserPanel("image"),
FieldRowPanel((FieldPanel("first_name"), FieldPanel("last_name")), "Name"),
FieldPanel("user"),
FieldRowPanel((FieldPanel("pu_status"), FieldPanel("cdh_staff")), "Status"),
MultiFieldPanel(
(
FieldPanel("job_title"),
FieldPanel("department"),
FieldPanel("institution"),
),
heading="Employment",
),
MultiFieldPanel(
(
FieldPanel("phone_number"),
FieldPanel("email"),
FieldPanel("office_location"),
),
heading="Contact",
),
InlinePanel("positions", heading="Positions"),
InlinePanel("related_links", heading="Related Links"),
]
# custom manager for querying
objects = PersonQuerySet.as_manager()
class Meta:
ordering = ("last_name",)
verbose_name_plural = "people"
def __str__(self):
"""Person display for listing in admin menus. Uses first and last name
if set, otherwise falls back to username. If no user, uses pk as a last
resort."""
if self.first_name or self.last_name:
return " ".join([self.first_name, self.last_name]).strip()
if self.user:
return self.user.username
return "Person %d" % self.pk
@property
def lastname_first(self):
"""display person as lastname, first for sorting"""
return ", ".join([n for n in [self.last_name, self.first_name] if n])
def __lt__(self, other):
# NOTE we need to order Memberships using person name by default,
# but modelcluster doesn't support ordering via related lookups, so
# we can't order by person__last_name on Membership. Instead we do this.
# see: https://github.com/wagtail/django-modelcluster/issues/45
return self.lastname_first < other.lastname_first
@property
def current_title(self):
"""Return the first of any non-expired titles held by this Person."""
current_positions = self.positions.filter(end_date__isnull=True)
if current_positions.exists():
return current_positions.first().title
@property
def most_recent_title(self):
"""Return the most recent of any titles held by this Person."""
most_recent_position = self.positions.first()
if most_recent_position:
return most_recent_position.title
@property
def latest_grant(self):
"""most recent grants where this person has director role"""
# find projects where they are director, then get newest grant
# that overlaps with their directorship dates
mship = (
self.membership_set.filter(role__title__in=PersonQuerySet.director_roles)
.order_by("-start_date")
.first()
)
# if there is one, find the most recent grant overlapping with their
# directorship dates
if mship:
# find most recent grant that overlaps with membership dates
# - grant start before membership end AND
# - grant end after membership start OR
# - grant has no end
# a membership might have no end date set, in which
# case it can't be used in a filter — if grant has no end date,
# it's an automatic overlap
grant_overlap = models.Q(end_date__gte=mship.start_date) | models.Q(
end_date__isnull=True
)
if mship.end_date:
grant_overlap &= models.Q(start_date__lte=mship.end_date)
return (
mship.project.grants.filter(grant_overlap)
.order_by("-start_date")
.first()
)
@property
def profile_url(self):
"""Provide the link to published profile on this site if there is one;
otherwise look for an associated website url"""
try:
profile = self.profile
if profile.live:
return profile.get_url()
except Profile.DoesNotExist:
website = self.related_links.filter(type__name="Website").first()
if website:
return website.url
[docs]@receiver(pre_delete, sender=Person)
def cleanup_profile(sender, **kwargs):
"""Handler to delete the corresponding Profile on Person deletion."""
# see NOTE on Profile save() method
try:
Profile.objects.get(person=kwargs["instance"]).delete()
except Profile.DoesNotExist:
pass
[docs]class Profile(BasePage):
"""Profile page for a Person, managed via wagtail."""
person = models.OneToOneField(
Person,
help_text="Corresponding person for this profile",
null=True,
on_delete=models.SET_NULL,
)
image = models.ForeignKey(
"wagtailimages.image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
) # no reverse relationship
education = RichTextField(features=PARAGRAPH_FEATURES, blank=True)
# admin edit configuration
content_panels = Page.content_panels + [
FieldRowPanel((FieldPanel("person"), ImageChooserPanel("image")), "Person"),
FieldPanel("education"),
StreamFieldPanel("body"),
StreamFieldPanel("attachments"),
]
parent_page_types = ["people.PeopleLandingPage"]
subpage_types = []
# index fields
search_fields = BasePage.search_fields + [index.SearchField("education")]
[docs] def get_context(self, request):
"""Add recent BlogPosts by this Person to their Profile."""
context = super().get_context(request)
# get 3 most recent published posts with this person as author;
# add to context and set open graph metadata
context.update(
{
"opengraph_type": "profile",
"recent_posts": self.person.posts.live().recent()[:3],
}
)
return context
[docs] def clean(self):
"""Validate that a Person was specified for this profile."""
# NOTE we can't cascade deletes to wagtail pages without corrupting the
# page tree. Instead, we use SET_NULL, and then add a delete handler
# to Person to delete the corresponding profile manually. We still need
# to make sure that it's impossible to create a Profile without a
# corresponding Person. More info:
# https://github.com/wagtail/wagtail/issues/1602
if not self.person:
raise ValidationError("Profile page must be for existing person.")
[docs]class PeopleLandingPage(LandingPage):
"""LandingPage subtype for People that holds Profiles."""
# NOTE this page can't be created in the page editor; it is only ever made
# via a script or the console, since there's only one.
parent_page_types = []
# NOTE the only allowed child page type is a Profile; this is so that
# Profiles made in the admin automatically are created here.
subpage_types = [Profile, LinkPage]
# use the regular landing page template
template = LandingPage.template
[docs]class Position(DateRange):
"""Through model for many-to-many relation between people
and titles. Adds start and end dates to the join table."""
person = ParentalKey(Person, on_delete=models.CASCADE, related_name="positions")
title = models.ForeignKey(Title, on_delete=models.CASCADE)
class Meta:
ordering = ["-start_date"]
def __str__(self):
return "%s %s (%s)" % (self.person, self.title, self.start_date.year)
[docs]def init_person_from_ldap(user, ldapinfo):
"""Extra User init logic for creating and auto-populating a corresponding
Person with data from LDAP."""
# update User to use ldap email
user.email = user.email.lower()
user.save()
# populate Person fields with data we can pull from ldap, if they are empty
person, _created = Person.objects.get_or_create(user=user)
# if no email set for Person, use ldap email
if not person.email:
person.email = user.email
# set first/last name basic on basic whitespace split; can adjust later
first_name, *_others, last_name = str(ldapinfo.displayName).split()
person.first_name = person.first_name or first_name
person.last_name = person.last_name or last_name
# set phone number
if ldapinfo.telephoneNumber and not person.phone_number:
person.phone_number = str(ldapinfo.telephoneNumber)
# set office location ("street" in ldap)
if ldapinfo.street and not person.office_location:
person.office_location = str(ldapinfo.street)
# set department (organizational unit or "ou" in ldap)
if ldapinfo.ou and not person.department:
person.department = str(ldapinfo.ou)
# set job title (split and use only first portion from ldap)
# NOTE titles for cdh staff are managed via Title/Position instead; we
# don't want to create Titles for every possible job at Princeton. This is
# a change from v2.x behavior.
if ldapinfo.title and not person.job_title:
person.job_title = str(ldapinfo.title).split(",")[0]
# always update PU status to current
person.pu_status = str(ldapinfo.pustatus)
person.save()