diff --git a/makeabilitylab/settings.py b/makeabilitylab/settings.py index 1e6b4e52..46699cc0 100644 --- a/makeabilitylab/settings.py +++ b/makeabilitylab/settings.py @@ -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 diff --git a/website/admin/utils.py b/website/admin/utils.py index c4a334a3..caf0b8bb 100644 --- a/website/admin/utils.py +++ b/website/admin/utils.py @@ -148,6 +148,7 @@ def get_active_mentors_queryset(): - PHD_STUDENT - MS_STUDENT - RESEARCH_SCIENTIST + - RESEARCH_SOFTWARE_ENGINEER - DIRECTOR - SOFTWARE_DEVELOPER - DESIGNER @@ -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, diff --git a/website/models/person.py b/website/models/person.py index 43ab3b6b..db05c92b 100644 --- a/website/models/person.py +++ b/website/models/person.py @@ -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): @@ -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): diff --git a/website/models/position.py b/website/models/position.py index 6054335c..3bfd32cb 100644 --- a/website/models/position.py +++ b/website/models/position.py @@ -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" @@ -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, @@ -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: @@ -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 diff --git a/website/tests/test_ml_utils.py b/website/tests/test_ml_utils.py index 1742f0ec..2f51db91 100644 --- a/website/tests/test_ml_utils.py +++ b/website/tests/test_ml_utils.py @@ -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): diff --git a/website/tests/test_position_mentor_label.py b/website/tests/test_position_mentor_label.py index 6eb48110..c1415b95 100644 --- a/website/tests/test_position_mentor_label.py +++ b/website/tests/test_position_mentor_label.py @@ -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" + ) diff --git a/website/tests/test_position_titles.py b/website/tests/test_position_titles.py new file mode 100644 index 00000000..529d0f84 --- /dev/null +++ b/website/tests/test_position_titles.py @@ -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, + ) diff --git a/website/utils/ml_utils.py b/website/utils/ml_utils.py index fd5e3490..21045377 100644 --- a/website/utils/ml_utils.py +++ b/website/utils/ml_utils.py @@ -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 \ @@ -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.