Skip to content

Commit 7a08ec8

Browse files
authored
Merge pull request #1427 from makeabilitylab/1426-api-position-title-school
Expose school on people and position-during-role on project people (#1426)
2 parents 354ff7f + 0018310 commit 7a08ec8

9 files changed

Lines changed: 403 additions & 12 deletions

File tree

docs/API.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,16 @@ sub-resources are keyed by `short_name`:
7171
- `GET /api/v1/projects/<short_name>/publications/` — the project's pubs.
7272
- `GET /api/v1/projects/<short_name>/grants/` — grants funding the project.
7373
- `GET /api/v1/projects/<short_name>/people/` — everyone with a role on the
74-
project, each as a `{ person, role, lead_project_role, start_date, end_date,
74+
project, each as a `{ person, role, lead_project_role, position_title,
75+
position_school, position_school_abbreviated, start_date, end_date,
7576
is_active }` record (a person may appear more than once for multiple roles).
77+
`role` is the editor-written free-text description of what they did; the
78+
`position_*` fields are what they **were at the time** — the title and school
79+
from the Position they held when the role started, so a 2015 stint reads
80+
`"Undergrad"` / `"UMD"` even if that person is a professor today. Where the
81+
role's start date falls outside every recorded position, the most recent
82+
position already begun by then is used — or, for a role predating them all,
83+
the earliest. All three are `null` for someone with no Position on record.
7684
- `GET /api/v1/projects/<short_name>/leadership/`**all** leadership across
7785
all time (current *and* past), grouped:
7886
`{ pis, co_pis, student_leads, postdoc_leads, research_scientist_leads }`,
@@ -91,8 +99,14 @@ Funding amounts are intentionally **not** exposed by the API.
9199

92100
Actual lab members (people with at least one Position); external co-authors are
93101
not listed here even though they appear as publication `authors`. Detail by
94-
`url_name`: `GET /api/v1/people/<url_name>/` — name, current title, bio,
95-
thumbnail, and public social/web links (ORCID, Google Scholar, GitHub, etc.).
102+
`url_name`: `GET /api/v1/people/<url_name>/` — name, `current_title`,
103+
`current_school`, `current_department`, bio, thumbnail, and public social/web
104+
links (ORCID, Google Scholar, GitHub, etc.).
105+
106+
> **Note:** the `current_*` fields come from the person's *latest* Position, so
107+
> for an alum they describe their last lab position, not their present-day
108+
> employer. For what someone was during a specific project stint, use the
109+
> `position_*` fields on `/projects/<short_name>/people/`.
96110
97111
> **Note:** `email` is intentionally **not** exposed by the API to avoid making
98112
> it an email-harvesting surface, even where it appears on a member page.

makeabilitylab/settings.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@
8686
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
8787

8888
# Makeability Lab Global Variables, including Makeability Lab version
89-
ML_WEBSITE_VERSION = "2.27.4" # Keep this updated with each release and also change the short description below
90-
ML_WEBSITE_VERSION_DESCRIPTION = "Infrastructure-only release: hardens the auto-deploy webhook (auto-deploy/index.php, #1422) after the test server spent Jul 18-22 rebuilding on every push while serving source frozen at Jul 18. Root cause was a one-character bug -- the deploy condition read `$OPERATION = 'TAG'` (assignment) instead of `==`, so ANY tag push satisfied it on EVERY configured host and reassigned $OPERATION for the rest of the request; a tag push therefore drove the branch-tracking test host down the tag code path and left its checkout detached at that tag. It could not recover, because `git pull` aborts from a detached HEAD and the pull ran before the branch checkout. Compounding it, the container build ran regardless of whether the pull or checkout succeeded, so a failed update still produced a successful-looking rebuild -- which is why it went unnoticed for four days. Fixed: `==`; branch hosts now check out the branch before pulling (tags keep fetch-first, since the tag is not local until after the fetch); and the container build is gated on the checked-out HEAD actually matching the pushed commit, aborting with a log line instead of silently shipping stale source. The gate peels annotated tags with ^{commit} (our release tags are annotated, and `after` carries the tag object's sha, not the commit's) and fails safe -- if the sha cannot be resolved locally it deploys as before. Also fixed three more instances of the same =/== footgun (_trace/_debug were always on), a misspelled $reqest variable that made the branch fast-path dead code, and the incoming ref is now validated against refs/(heads|tags)/[A-Za-z0-9._/-]+ before it reaches a shell. No application code changed."
89+
ML_WEBSITE_VERSION = "2.28.0" # Keep this updated with each release and also change the short description below
90+
ML_WEBSITE_VERSION_DESCRIPTION = "Adds five fields to the public v1 REST API so Project Sidewalk's new About page can render its team section straight from this site instead of hand-maintaining a roster (#1426). /api/v1/people/<url_name>/ gains current_school and current_department alongside the existing current_title; /api/v1/projects/<short_name>/people/ gains position_title, position_school, and position_school_abbreviated -- what the person WAS when they joined the project, not what they are now, so a 2015 stint reads 'Undergrad, UMD' even though that person may be a professor today. (Jon's own 2012 Sidewalk role now correctly reports Assistant Professor / UMD rather than Professor / UW.) The new fields are named position_* rather than a bare 'title' so they don't read as part of 'role', the editor-written free-text description sitting beside them in the same record. Which position gets picked is an explicit, documented rule on ProjectRole.position_during_role: the position containing the role's start date, else the latest one already begun (the role started in a gap between positions), else the earliest (the role predates every recorded position -- common in older imported data), and null only for someone with no Position at all; ties on start date break on pk so two positions beginning the same day can't let DB row order decide the answer from one request to the next. Both project sub-resources now prefetch person__position_set and the people list prefetches position_set, so resolving a position per row rides the prefetch instead of costing a query each -- that matters at Sidewalk's ~150-person roster, and it also fixes a pre-existing N+1 behind current_title; a regression test pins the query count as flat as the roster grows. Separately, get_school_abbreviated is tightened twice: it no longer acronyms one-word affiliations (not every school is a university, and 'Easterseals' -> 'E' (or 'Cornell' -> 'C') is worse than the name itself), and its UW/UMD special cases now match the phrase 'university of washington/maryland' rather than a bare 'washington'/'maryland' substring, which used to hand UW's initials to Washington State and Washington University in St. Louis, and UMD's to UMBC. That is a visible fix on the public People page too, not just in the API. No model changes and no migration; v1 stays additive-only, so existing consumers are unaffected."
9191
DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed
9292
MAX_BANNERS = 7 # Maximum number of banners on a page
9393

website/api/serializers.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,27 @@ def get_thumbnail(self, obj):
7575
class PersonSerializer(PersonSummarySerializer):
7676
"""Full person representation for the people detail/list endpoints.
7777
78-
Extends the summary with bio, current title, and the public social/web
78+
Extends the summary with bio, current affiliation, and the public social/web
7979
links. ``email`` is intentionally omitted (see module docstring).
80+
81+
``current_title`` / ``current_school`` / ``current_department`` all come from
82+
the person's *latest* Position, so for an alum they describe their last lab
83+
position, not their present-day employer. For what someone was during a
84+
specific project stint, use ``ProjectRoleSerializer``'s ``position_*`` fields.
8085
"""
8186

8287
current_title = serializers.SerializerMethodField()
88+
current_school = serializers.SerializerMethodField()
89+
current_department = serializers.SerializerMethodField()
8390

8491
class Meta(PersonSummarySerializer.Meta):
8592
fields = PersonSummarySerializer.Meta.fields + [
8693
"first_name",
8794
"middle_name",
8895
"last_name",
8996
"current_title",
97+
"current_school",
98+
"current_department",
9099
"bio",
91100
"personal_website",
92101
"github",
@@ -103,6 +112,14 @@ def get_current_title(self, obj):
103112
# Person.get_current_title is a cached_property, not a method.
104113
return obj.get_current_title
105114

115+
def get_current_school(self, obj):
116+
# Person.get_current_school is a cached_property, not a method.
117+
return obj.get_current_school
118+
119+
def get_current_department(self, obj):
120+
# Person.get_current_department is a cached_property, not a method.
121+
return obj.get_current_department
122+
106123

107124
class ProjectSummarySerializer(serializers.ModelSerializer):
108125
"""Compact project representation, used when nested in publications/grants."""
@@ -262,21 +279,50 @@ def get_bibtex(self, obj):
262279

263280

264281
class ProjectRoleSerializer(serializers.ModelSerializer):
265-
"""A person's role on a project (start/end, lead type, active flag)."""
282+
"""A person's role on a project (start/end, lead type, active flag).
283+
284+
The ``position_*`` fields describe what the person *was* when they joined the
285+
project -- title and school from the Position held at the role's start date
286+
(#1426) -- so a 2015 stint reads "Undergrad, UMD" even if that person is a
287+
professor today. They're ``null`` for someone with no Position on record.
288+
Named ``position_*`` (not bare ``title``) so they don't read as part of
289+
``role``, which is the editor-written free-text description of the work.
290+
"""
266291

267292
person = PersonSummarySerializer(read_only=True)
268293
is_active = serializers.SerializerMethodField()
294+
position_title = serializers.SerializerMethodField()
295+
position_school = serializers.SerializerMethodField()
296+
position_school_abbreviated = serializers.SerializerMethodField()
269297

270298
class Meta:
271299
model = ProjectRole
272300
fields = [
273301
"person",
274302
"role",
275303
"lead_project_role",
304+
"position_title",
305+
"position_school",
306+
"position_school_abbreviated",
276307
"start_date",
277308
"end_date",
278309
"is_active",
279310
]
280311

281312
def get_is_active(self, obj):
282313
return obj.is_active()
314+
315+
# ProjectRole.position_during_role is a cached_property, so the three fields
316+
# below resolve it once per row (and ride the view's prefetch -- see the
317+
# model helper).
318+
def get_position_title(self, obj):
319+
position = obj.position_during_role
320+
return position.title if position else None
321+
322+
def get_position_school(self, obj):
323+
position = obj.position_during_role
324+
return position.school if position else None
325+
326+
def get_position_school_abbreviated(self, obj):
327+
position = obj.position_during_role
328+
return position.get_school_abbreviated() if position else None

website/api/views.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,12 @@ class PersonViewSet(ReadOnlyModelViewSet):
122122
lookup_field = "url_name"
123123

124124
def get_queryset(self):
125+
# position_set is prefetched because current_title/current_school/
126+
# current_department all funnel through Person.get_latest_position,
127+
# which resolves in Python off position_set.all().
125128
return (
126129
Person.objects.filter(position__isnull=False)
130+
.prefetch_related("position_set")
127131
.distinct()
128132
.order_by("last_name", "first_name")
129133
)
@@ -199,6 +203,7 @@ def people(self, request, short_name=None):
199203
qs = (
200204
ProjectRole.objects.filter(project=project)
201205
.select_related("person")
206+
.prefetch_related("person__position_set")
202207
.order_by("person__last_name", "person__first_name", "start_date")
203208
)
204209
return self._paginated(qs, ProjectRoleSerializer)
@@ -218,6 +223,7 @@ def leadership(self, request, short_name=None):
218223
project=project, lead_project_role__in=list(_LEAD_BUCKETS)
219224
)
220225
.select_related("person")
226+
.prefetch_related("person__position_set")
221227
.order_by("-start_date")
222228
)
223229
context = self.get_serializer_context()

website/models/project_role.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.db import models
2+
from django.utils.functional import cached_property
23
from datetime import date, datetime, timedelta
34

45
class LeadProjectRoleTypes(models.TextChoices):
@@ -70,6 +71,62 @@ def get_date_range_as_str(self):
7071
else:
7172
return f"{self.start_date.year}-{self.end_date.year}"
7273

74+
@cached_property
75+
def position_during_role(self):
76+
"""The Position this person held when they started this project role.
77+
78+
This is deliberately *not* the person's current title: a 2015 undergrad
79+
who is now a professor should read "Undergrad" next to a 2015 project
80+
stint (see issue #1426). Where someone's title changed mid-stint (an
81+
undergrad who stayed on for an MS), we report what they were when they
82+
joined the project.
83+
84+
Resolution order, most to least faithful:
85+
86+
1. The latest-starting Position whose date range contains this role's
87+
``start_date``.
88+
2. Failing that, the latest-starting Position that had already begun by
89+
``start_date`` (the role started in a gap between positions).
90+
3. Failing that, the earliest Position (the role predates every recorded
91+
position -- data drift, common for older imported roles).
92+
4. ``None`` only if the person has no Positions at all.
93+
94+
Ties on ``start_date`` (a person holding two positions that begin the
95+
same day -- concurrent Member/Collaborator rows, or a duplicate entry)
96+
break on ``pk``, so the answer is stable across requests: ``position_set``
97+
has no ``Meta.ordering``, so without an explicit tie-break the winner
98+
would follow whatever order the DB happened to return.
99+
100+
A ``cached_property`` (like :meth:`Person.get_current_title`) because the
101+
API reads it three times per row -- for title, school, and abbreviated
102+
school. Filters ``position_set`` in Python rather than issuing a query,
103+
so a caller with ``prefetch_related('person__position_set')`` (e.g. the
104+
API's project-people endpoint) resolves it with zero extra queries. This
105+
mirrors :meth:`Person.get_latest_position`; positions-per-person is tiny.
106+
107+
Example:
108+
>>> role.position_during_role.title
109+
'Undergrad'
110+
"""
111+
positions = list(self.person.position_set.all())
112+
if not positions:
113+
return None
114+
115+
# Sorts rather than max()/min() so the pk tie-break is stated once.
116+
positions.sort(key=lambda p: (p.start_date, p.pk))
117+
118+
containing = [p for p in positions
119+
if p.start_date <= self.start_date
120+
and (p.end_date is None or p.end_date >= self.start_date)]
121+
if containing:
122+
return containing[-1]
123+
124+
already_started = [p for p in positions if p.start_date <= self.start_date]
125+
if already_started:
126+
return already_started[-1]
127+
128+
return positions[0]
129+
73130
def get_pi_status_index(self):
74131
if self.lead_project_role is not None and self.lead_project_role in self.LEAD_PROJECT_ROLE_MAPPING:
75132
return self.LEAD_PROJECT_ROLE_MAPPING[self.lead_project_role]

0 commit comments

Comments
 (0)