Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions makeabilitylab/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

# Makeability Lab Global Variables, including Makeability Lab version
ML_WEBSITE_VERSION = "2.31.1" # Keep this updated with each release and also change the short description below
ML_WEBSITE_VERSION_DESCRIPTION = "The Django log path is now derived from the project root instead of a hardcoded container path, and an unwritable log directory degrades gracefully instead of killing startup (#1283). Because the servers have no console, /version.json now reports whether file logging is actually live."
ML_WEBSITE_VERSION = "2.32.0" # Keep this updated with each release and also change the short description below
ML_WEBSITE_VERSION_DESCRIPTION = "Positions can now be titled \"Research Software Engineer\", and the affiliation fields are relabeled \"Institution or organization\" and \"Department or unit\" — collaborators come from nonprofits and companies, not just universities (#1437). This release also carries the 2.31.1 log-path fix (#1283)."
DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed
MAX_BANNERS = 7 # Maximum number of banners on a page

Expand Down
2 changes: 2 additions & 0 deletions website/admin/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ def get_active_mentors_queryset():
- PHD_STUDENT
- MS_STUDENT
- RESEARCH_SCIENTIST
- RESEARCH_SOFTWARE_ENGINEER
- DIRECTOR
- SOFTWARE_DEVELOPER
- DESIGNER
Expand All @@ -170,6 +171,7 @@ def get_active_mentors_queryset():
Title.PHD_STUDENT,
Title.MS_STUDENT,
Title.RESEARCH_SCIENTIST,
Title.RESEARCH_SOFTWARE_ENGINEER,
Title.DIRECTOR,
Title.SOFTWARE_DEVELOPER,
Title.DESIGNER,
Expand Down
6 changes: 4 additions & 2 deletions website/models/person.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,9 @@ def get_current_department(self):
else:
return None

get_current_department.short_description = "Department"
# Column labels match Position's verbose_names — the affiliation isn't always
# academic (nonprofits, companies, medical centers).
get_current_department.short_description = "Department or unit"

@cached_property
def get_current_school(self):
Expand All @@ -241,7 +243,7 @@ def get_current_school(self):
else:
return None

get_current_school.short_description = "School"
get_current_school.short_description = "Institution or organization"

@cached_property
def get_current_role(self):
Expand Down
53 changes: 38 additions & 15 deletions website/models/position.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ class Title(models.TextChoices):
ASSOCIATE_PROF = "Associate Professor"
FULL_PROF = "Professor"
RESEARCH_SCIENTIST = "Research Scientist"
# Non-student staff who build and maintain the lab's research software
# (research systems, study apparatus, data pipelines). "Research Software
# Engineer" (RSE) is the term of art for this role in academia; it's
# engineering-led rather than research-led, unlike Research Scientist.
RESEARCH_SOFTWARE_ENGINEER = "Research Software Engineer"
# Non-student staff who coordinate a project's operations (curriculum,
# IRB, recruitment, partner logistics) rather than run its research.
# Added for the UIC subaward on Project Sidewalk; "Research Scientist"
Expand Down Expand Up @@ -72,8 +77,23 @@ class Position(models.Model):
role = models.CharField(max_length=50, choices=Role.choices, default=Role.MEMBER)
title = models.CharField(max_length=50, choices=Title.choices)

department = models.CharField(max_length=50, blank=True, default="Allen School of Computer Science and Engineering")
school = models.CharField(max_length=60, default="University of Washington")
# Both fields hold the person's affiliation, which is *usually* but not always
# academic: collaborators come from nonprofits (Easterseals), companies, and
# medical centers too. The DB columns keep their historical names — this repo
# regenerates migrations non-interactively per environment, where a field
# rename can't be confirmed and would drop the column (same reason
# ``grad_mentor`` kept its name in #806) — so the fix is verbose_name only,
# which carries no schema change. The defaults stay UW/Allen School because
# that's right for the overwhelming majority of rows.
department = models.CharField(max_length=50, blank=True,
default="Allen School of Computer Science and Engineering",
verbose_name="Department or unit",
help_text="The unit within the institution or organization. "
"Clear the default for affiliations that don't have one.")
school = models.CharField(max_length=60, default="University of Washington",
verbose_name="Institution or organization",
help_text="University, company, nonprofit, or school "
"(e.g. \"University of Washington\", \"Easterseals\").")

TITLE_ORDER_MAPPING = {
Title.FULL_PROF: 0,
Expand All @@ -82,16 +102,17 @@ class Position(models.Model):
Title.POST_DOC: 3,
Title.DIRECTOR: 4,
Title.RESEARCH_SCIENTIST: 5,
Title.PROJECT_COORDINATOR: 6,
Title.MEDICAL_DOCTOR: 7,
Title.PHD_STUDENT: 8,
Title.MEDICAL_STUDENT: 9,
Title.MS_STUDENT: 10,
Title.SOFTWARE_DEVELOPER: 11,
Title.DESIGNER: 12,
Title.UGRAD: 13,
Title.HIGH_SCHOOL: 14,
Title.UNKNOWN: 15
Title.RESEARCH_SOFTWARE_ENGINEER: 6,
Title.PROJECT_COORDINATOR: 7,
Title.MEDICAL_DOCTOR: 8,
Title.PHD_STUDENT: 9,
Title.MEDICAL_STUDENT: 10,
Title.MS_STUDENT: 11,
Title.SOFTWARE_DEVELOPER: 12,
Title.DESIGNER: 13,
Title.UGRAD: 14,
Title.HIGH_SCHOOL: 15,
Title.UNKNOWN: 16
}

# BETTER - Use class constant:
Expand Down Expand Up @@ -335,13 +356,15 @@ def is_professorial_position(position):
def is_professional_position(position):
"""Static method returns true if position is a professional"""
if(type(position) is Position):
return (position.title == Title.RESEARCH_SCIENTIST or
return (position.title == Title.RESEARCH_SCIENTIST or
position.title == Title.RESEARCH_SOFTWARE_ENGINEER or
position.title == Title.SOFTWARE_DEVELOPER or
position.title == Title.DIRECTOR or
position.title == Title.DIRECTOR or
position.title == Title.DESIGNER or
position.title == Title.MEDICAL_DOCTOR)
elif(type(position) is str):
return (position == Title.RESEARCH_SCIENTIST or
return (position == Title.RESEARCH_SCIENTIST or
position == Title.RESEARCH_SOFTWARE_ENGINEER or
position == Title.SOFTWARE_DEVELOPER or
position == Title.DIRECTOR or
position == Title.DESIGNER or
Expand Down
18 changes: 18 additions & 0 deletions website/tests/test_ml_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,24 @@ def test_ischool(self):
def test_hcde(self):
self.assertEqual(get_department_abbreviated("HCDE"), "HCDE")

def test_unmatched_department_acronyms_without_filler_words(self):
# Used to return the first five characters ("Natio", "Disab").
self.assertEqual(
get_department_abbreviated("National Center for Mobility Management"), "NCMM"
)
self.assertEqual(
get_department_abbreviated("Disability and Human Development"), "DHD"
)

def test_single_word_department_is_not_acronymed(self):
# Same escape hatch as get_school_abbreviated: "P" helps nobody.
self.assertEqual(get_department_abbreviated("Psychology"), "Psychology")
self.assertEqual(get_department_abbreviated("Math"), "Math")

def test_empty_department_is_passed_through(self):
# Non-academic affiliations often have no unit at all.
self.assertEqual(get_department_abbreviated(""), "")


class GetVideoEmbedTests(SimpleTestCase):
def test_youtube_short_url(self):
Expand Down
41 changes: 34 additions & 7 deletions website/tests/test_position_mentor_label.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,46 @@
"""
Regression test for issue #806.
Pins the admin-facing labels on Position fields whose DB column names are
deliberately *not* what editors should see.

The Position.grad_mentor field keeps its historical Python/DB name, but the
mentor dropdown is no longer grad-only (see get_active_mentors_queryset), so the
user-facing admin label was changed to simply "Mentor" via verbose_name. This
pins that label so a future edit to the field doesn't silently revert it to the
auto-generated "Grad mentor".
The columns keep their historical names because this repo regenerates migrations
non-interactively per environment, where a field rename can't be confirmed and
would drop the column — so each of these was fixed with verbose_name, which
carries no schema change. These tests keep a future edit from silently reverting
to the auto-generated label.
"""

from django.test import SimpleTestCase

from website.models import Position
from website.models import Person, Position


class MentorLabelTests(SimpleTestCase):
"""Issue #806: the mentor dropdown is no longer grad-only (see
get_active_mentors_queryset), so ``grad_mentor`` is labeled just "Mentor"."""

def test_grad_mentor_field_labeled_mentor(self):
field = Position._meta.get_field("grad_mentor")
self.assertEqual(field.verbose_name, "Mentor")


class AffiliationLabelTests(SimpleTestCase):
"""``school``/``department`` also hold non-academic affiliations (nonprofits,
companies, medical centers), so "School" was the wrong prompt to give editors."""

def test_school_field_labeled_institution_or_organization(self):
field = Position._meta.get_field("school")
self.assertEqual(field.verbose_name, "Institution or organization")
self.assertTrue(field.help_text)

def test_department_field_labeled_department_or_unit(self):
field = Position._meta.get_field("department")
self.assertEqual(field.verbose_name, "Department or unit")
self.assertTrue(field.help_text)

def test_person_admin_columns_match_the_field_labels(self):
self.assertEqual(
Person.get_current_school.short_description, "Institution or organization"
)
self.assertEqual(
Person.get_current_department.short_description, "Department or unit"
)
61 changes: 61 additions & 0 deletions website/tests/test_position_titles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""
Wards around the ``Title`` choices on ``Position``.

Adding a title is a three-place edit: the enum member, ``TITLE_ORDER_MAPPING``
(``get_sorted_titles`` raises ``KeyError`` for a title missing from the map), and
whichever ``is_*_position`` bucket the title belongs to (that bucket drives the
abstracted-title grouping used by the people pages). These tests pin all three so
the next title added doesn't silently land half-wired.
"""

from django.test import SimpleTestCase

from website.models import Position
from website.models.position import AbstractedTitle, Title


class TitleOrderMappingTests(SimpleTestCase):
def test_every_title_has_an_order(self):
"""Every Title needs an order entry or get_sorted_titles() blows up."""
missing = [t for t in Title if t not in Position.TITLE_ORDER_MAPPING]
self.assertEqual(missing, [], f"Titles missing from TITLE_ORDER_MAPPING: {missing}")

def test_get_sorted_titles_covers_all_titles(self):
self.assertEqual(set(Position.get_sorted_titles()), set(Title))

def test_orders_are_unique(self):
orders = list(Position.TITLE_ORDER_MAPPING.values())
self.assertEqual(len(orders), len(set(orders)), "duplicate order values")


class ResearchSoftwareEngineerTitleTests(SimpleTestCase):
"""The Research Software Engineer title (added alongside Research Scientist)."""

def test_title_choice_exists(self):
self.assertEqual(Title.RESEARCH_SOFTWARE_ENGINEER, "Research Software Engineer")
self.assertIn(("Research Software Engineer", "Research Software Engineer"), Title.choices)

def test_fits_in_the_title_column(self):
max_length = Position._meta.get_field("title").max_length
self.assertLessEqual(len(Title.RESEARCH_SOFTWARE_ENGINEER.value), max_length)

def test_sorts_just_after_research_scientist(self):
sorted_titles = Position.get_sorted_titles()
self.assertEqual(
sorted_titles.index(Title.RESEARCH_SOFTWARE_ENGINEER),
sorted_titles.index(Title.RESEARCH_SCIENTIST) + 1,
)

def test_is_a_professional_position(self):
"""Both the str and Position forms — they're separate code paths."""
self.assertTrue(Position.is_professional_position(Title.RESEARCH_SOFTWARE_ENGINEER.value))
self.assertTrue(
Position.is_professional_position(Position(title=Title.RESEARCH_SOFTWARE_ENGINEER.value))
)

def test_abstracts_to_professional(self):
# Use the raw str, as a Position loaded from the DB would carry.
self.assertEqual(
Position.get_abstracted_title(Position(title=Title.RESEARCH_SOFTWARE_ENGINEER.value)),
AbstractedTitle.PROFESSIONAL.value,
)
33 changes: 29 additions & 4 deletions website/utils/ml_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,32 @@ def create_acronym(name):
return acronym


# Filler words dropped before acronyming a department name, so "Disability and
# Human Development" abbreviates to DHD rather than DAHD.
_DEPT_ACRONYM_STOPWORDS = {"of", "and", "for", "the", "in", "at"}

def get_department_abbreviated(dept_name):
"""Returns the department abbreviation for a given department name"""
"""Returns the department abbreviation for a given department name.

The lab's common departments are special-cased; anything else acronyms with
filler words dropped, and names that would acronym to a single letter are
returned unchanged. This mirrors get_school_abbreviated, and for the same
reason: not every affiliation is a university, so the unit within it may be
an arbitrary phrase. (It used to return the first five characters, which
turned "National Center for Mobility Management" into "Natio" and
"Psychology" into "Psych".)

Examples:
>>> get_department_abbreviated("Computer Science & Engineering")
'CSE'
>>> get_department_abbreviated("Disability and Human Development")
'DHD'
>>> get_department_abbreviated("Psychology")
'Psychology'
"""
if not dept_name:
return ""

dept_low = dept_name.lower()

if ("computer science" in dept_low and "engineering" in dept_low) or \
Expand All @@ -106,10 +130,11 @@ def get_department_abbreviated(dept_name):
return "EE"
elif "mhci" in dept_low or "hci+d" in dept_low or "hcid" in dept_low:
return "MHCI+D"
elif dept_name is not None:
return dept_name[:5]
else:
return "Unknown"
words = [w for w in dept_low.replace("&", " ").replace(",", " ").split()
if w not in _DEPT_ACRONYM_STOPWORDS]
acronym = "".join(word[0] for word in words).upper()
return acronym if len(acronym) > 1 else dept_name

def _get_youtube_id(video_url):
"""Extract the YouTube video id from the common URL forms.
Expand Down
Loading