Skip to content

Commit 3ecb604

Browse files
committed
Implemented institution feature.
Signed-off-by: Connor Brock <BrockC2@cardiff.ac.uk>
1 parent dda5c29 commit 3ecb604

12 files changed

Lines changed: 274 additions & 6 deletions

File tree

coldfront/config/core.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,9 @@
120120

121121
PROJECT_CODE = ENV.str("PROJECT_CODE", default=None)
122122
PROJECT_CODE_PADDING = ENV.int("PROJECT_CODE_PADDING", default=None)
123+
124+
# ------------------------------------------------------------------------------
125+
# Enable project institution code feature.
126+
# ------------------------------------------------------------------------------
127+
128+
PROJECT_INSTITUTION_EMAIL_MAP = ENV.dict("PROJECT_INSTITUTION_EMAIL_MAP", default={})

coldfront/core/project/admin.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from coldfront.core.utils.common import import_from_settings
2626

2727
PROJECT_CODE = import_from_settings("PROJECT_CODE", False)
28+
PROJECT_INSTITUTION_EMAIL_MAP = import_from_settings("PROJECT_INSTITUTION_EMAIL_MAP", False)
2829

2930

3031
@admin.register(ProjectStatusChoice)
@@ -363,12 +364,18 @@ def get_inline_instances(self, request, obj=None):
363364
return super().get_inline_instances(request)
364365

365366
def get_list_display(self, request):
367+
if not (PROJECT_CODE or PROJECT_INSTITUTION_EMAIL_MAP):
368+
return self.list_display
369+
370+
list_display = list(self.list_display)
371+
366372
if PROJECT_CODE:
367-
list_display = list(self.list_display)
368373
list_display.insert(1, "project_code")
369-
return tuple(list_display)
370374

371-
return self.list_display
375+
if PROJECT_INSTITUTION_EMAIL_MAP:
376+
list_display.insert(2, "institution")
377+
378+
return tuple(list_display)
372379

373380
def save_formset(self, request, form, formset, change):
374381
if formset.model in [ProjectAdminComment, ProjectUserMessage]:
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# SPDX-FileCopyrightText: (C) ColdFront Authors
2+
#
3+
# SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+
from django.core.management.base import BaseCommand
6+
7+
from coldfront.core.project.models import Project
8+
from coldfront.core.project.utils import determine_automated_institution_choice
9+
from coldfront.core.utils.common import import_from_settings
10+
11+
PROJECT_INSTITUTION_EMAIL_MAP = import_from_settings("PROJECT_INSTITUTION_EMAIL_MAP", False)
12+
13+
14+
class Command(BaseCommand):
15+
help = "Update existing projects with institutions based on PIs email address"
16+
17+
def add_arguments(self, parser):
18+
parser.add_argument(
19+
"--dry-run",
20+
action="store_true",
21+
help="Outputs each project, followed by the assigned institution, without making changes.",
22+
)
23+
24+
def update_project_institution(self, projects):
25+
if not PROJECT_INSTITUTION_EMAIL_MAP:
26+
self.stdout.write(
27+
"Error, no changes made. Please set PROJECT_INSTITUTION_EMAIL_MAP as a dictionary value inside configuration file."
28+
)
29+
return
30+
31+
def _update_project_institution(self, projects):
32+
user_input = input(
33+
"Assign all existing projects with institutions? You can use the --dry-run flag to preview changes first. [y/N] "
34+
)
35+
36+
try:
37+
if user_input == "y" or user_input == "Y":
38+
for project in projects:
39+
project.institution = determine_automated_institution_choice(project, PROJECT_INSTITUTION_EMAIL_MAP)
40+
project.save(update_fields=["institution"])
41+
self.stdout.write(f"Updated {projects.count()} projects with institutions.")
42+
else:
43+
self.stdout.write("No changes made")
44+
except Exception as e:
45+
self.stdout.write(f"Error: {e}")
46+
47+
def _institution_dry_run(self, projects):
48+
try:
49+
for project in projects:
50+
new_institution = determine_automated_institution_choice(project, PROJECT_INSTITUTION_EMAIL_MAP)
51+
self.stdout.write(
52+
f"Project {project.pk}, called {project.title}. Institution would be '{new_institution}'"
53+
)
54+
except Exception as e:
55+
self.stdout.write(f"Error: {e}")
56+
57+
def handle(self, *args, **options):
58+
dry_run = options["dry_run"]
59+
60+
if not PROJECT_INSTITUTION_EMAIL_MAP:
61+
self.stdout.write(
62+
"Error, no changes made. Please set PROJECT_INSTITUTION_EMAIL_MAP as a dictionary value inside configuration file."
63+
)
64+
return
65+
66+
projects_without_institution = Project.objects.filter(institution="None")
67+
68+
if dry_run:
69+
self._institution_dry_run(projects_without_institution)
70+
else:
71+
self._update_project_institution(projects_without_institution)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# SPDX-FileCopyrightText: (C) ColdFront Authors
2+
#
3+
# SPDX-License-Identifier: AGPL-3.0-or-later
4+
5+
# Generated by Django 4.2.11 on 2025-04-27 19:07
6+
7+
from django.db import migrations, models
8+
9+
10+
class Migration(migrations.Migration):
11+
dependencies = [
12+
("project", "0005_alter_historicalproject_options_and_more"),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name="historicalproject",
18+
name="institution",
19+
field=models.CharField(blank=True, default="None", max_length=10),
20+
),
21+
migrations.AddField(
22+
model_name="project",
23+
name="institution",
24+
field=models.CharField(blank=True, default="None", max_length=10),
25+
),
26+
]

coldfront/core/project/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ def get_by_natural_key(self, title, pi_username):
106106
history = HistoricalRecords()
107107
objects = ProjectManager()
108108
project_code = models.CharField(max_length=10, blank=True)
109+
institution = models.CharField(max_length=80, blank=True, default="None")
109110

110111
def clean(self):
111112
"""Validates the project and raises errors if the project is invalid."""

coldfront/core/project/templates/project/project_archived_list.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ <h2>Archived Projects</h2>
8686
<br> <strong>Description: </strong>{{ project.description }}</td>
8787
<td>{{ project.field_of_science.description }}</td>
8888
<td>{{ project.status.name }}</td>
89+
{% if PROJECT_INSTITUTION_EMAIL_MAP %}
90+
<p class="card-text text-justify"><strong>Institution: </strong>{{ project.institution }}</p>
91+
{% endif %}
8992
</tr>
9093
{% endfor %}
9194
</tbody>

coldfront/core/project/templates/project/project_detail.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ <h3 class="card-title">
8181
<span class="badge badge-pill badge-info">project review pending</span>
8282
{% endif %}
8383
</p>
84+
{% if PROJECT_INSTITUTION_EMAIL_MAP %}
85+
<p class="card-text text-justify"><strong>Institution: </strong>{{ project.institution }}</p>
86+
{% endif %}
8487
<p class="card-text text-justify"><strong>Created: </strong>{{ project.created|date:"M. d, Y" }}</p>
8588
</div>
8689
</div>

coldfront/core/project/templates/project/project_list.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ <h2>Projects</h2>
7171
<a href="?order_by=status&direction=asc&{{filter_parameters}}"><i class="fas fa-sort-up" aria-hidden="true"></i><span class="sr-only">Sort Status asc</span></a>
7272
<a href="?order_by=status&direction=des&{{filter_parameters}}"><i class="fas fa-sort-down" aria-hidden="true"></i><span class="sr-only">Sort Status desc</span></a>
7373
</th>
74+
{% if PROJECT_INSTITUTION_EMAIL_MAP %}
75+
<th scope="col" class="text-nowrap">
76+
Institution
77+
<a href="?order_by=id&direction=asc&{{filter_parameters}}"><i class="fas fa-sort-up" aria-hidden="true"></i><span class="sr-only">Sort Institution asc</span></a>
78+
<a href="?order_by=id&direction=des&{{filter_parameters}}"><i class="fas fa-sort-down" aria-hidden="true"></i><span class="sr-only">Sort Institution desc</span></a>
79+
</th>
80+
{% endif %}
7481
</tr>
7582
</thead>
7683
<tbody>
@@ -85,6 +92,9 @@ <h2>Projects</h2>
8592
<td style="text-align: justify; text-justify: inter-word;">{{ project.title }}</td>
8693
<td>{{ project.field_of_science.description }}</td>
8794
<td>{{ project.status.name }}</td>
95+
{% if PROJECT_INSTITUTION_EMAIL_MAP %}
96+
<td>{{ project.institution }}</td>
97+
{% endif %}
8898
</tr>
8999
{% endfor %}
90100
</tbody>

coldfront/core/project/tests.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
ProjectAttribute,
1414
ProjectAttributeType,
1515
)
16-
from coldfront.core.project.utils import generate_project_code
16+
from coldfront.core.project.utils import (
17+
determine_automated_institution_choice,
18+
generate_project_code,
19+
)
1720
from coldfront.core.test_helpers.factories import (
1821
FieldOfScienceFactory,
1922
PAttributeTypeFactory,
@@ -276,3 +279,103 @@ def test_different_prefix_padding(self):
276279
# Test the generated project codes
277280
self.assertEqual(project_with_code_padding1, "BFO001")
278281
self.assertEqual(project_with_code_padding2, "BFO002")
282+
283+
284+
class TestInstitution(TestCase):
285+
def setUp(self):
286+
self.user = UserFactory(username="capeo")
287+
self.field_of_science = FieldOfScienceFactory(description="Physics")
288+
self.status = ProjectStatusChoiceFactory(name="Active")
289+
290+
def create_project_with_institution(self, title, institution_dict=None):
291+
"""Helper method to create a project and assign a institution value based on the argument passed"""
292+
# Project Creation
293+
project = Project.objects.create(
294+
title=title,
295+
pi=self.user,
296+
status=self.status,
297+
field_of_science=self.field_of_science,
298+
)
299+
300+
if institution_dict:
301+
determine_automated_institution_choice(project, institution_dict)
302+
303+
project.save()
304+
305+
return project.institution
306+
307+
@patch(
308+
"coldfront.config.core.PROJECT_INSTITUTION_EMAIL_MAP",
309+
{"inst.ac.com": "AC", "inst.edu.com": "EDU", "bfo.ac.uk": "BFO"},
310+
)
311+
def test_institution_is_none(self):
312+
from coldfront.config.core import PROJECT_INSTITUTION_EMAIL_MAP
313+
314+
"""Test to check if institution is none after both env vars are enabled. """
315+
316+
# Create project with both institution
317+
project_institution = self.create_project_with_institution("Project 1", PROJECT_INSTITUTION_EMAIL_MAP)
318+
319+
# Create the first project
320+
self.assertEqual(project_institution, "None")
321+
322+
@patch(
323+
"coldfront.config.core.PROJECT_INSTITUTION_EMAIL_MAP",
324+
{"inst.ac.com": "AC", "inst.edu.com": "EDU", "bfo.ac.uk": "BFO"},
325+
)
326+
def test_institution_multiple_users(self):
327+
from coldfront.config.core import PROJECT_INSTITUTION_EMAIL_MAP
328+
329+
"""Test to check multiple projects with different user email addresses, """
330+
331+
# Create project for user 1
332+
self.user.email = "user@inst.ac.com"
333+
self.user.save()
334+
project_institution_one = self.create_project_with_institution("Project 1", PROJECT_INSTITUTION_EMAIL_MAP)
335+
self.assertEqual(project_institution_one, "AC")
336+
337+
# Create project for user 2
338+
self.user.email = "user@bfo.ac.uk"
339+
self.user.save()
340+
project_institution_two = self.create_project_with_institution("Project 2", PROJECT_INSTITUTION_EMAIL_MAP)
341+
self.assertEqual(project_institution_two, "BFO")
342+
343+
# Create project for user 3
344+
self.user.email = "user@inst.edu.com"
345+
self.user.save()
346+
project_institution_three = self.create_project_with_institution("Project 3", PROJECT_INSTITUTION_EMAIL_MAP)
347+
self.assertEqual(project_institution_three, "EDU")
348+
349+
@patch(
350+
"coldfront.config.core.PROJECT_INSTITUTION_EMAIL_MAP",
351+
{"inst.ac.com": "AC", "inst.edu.com": "EDU", "bfo.ac.uk": "BFO"},
352+
)
353+
def test_determine_automated_institution_choice_does_not_save_to_database(self):
354+
from coldfront.config.core import PROJECT_INSTITUTION_EMAIL_MAP
355+
356+
"""Test that the function only modifies project in memory, not in database"""
357+
358+
self.user.email = "user@inst.ac.com"
359+
self.user.save()
360+
361+
# Create project, similar to create_project_with_institution, but without the save function.
362+
project = Project.objects.create(
363+
title="Test Project",
364+
pi=self.user,
365+
status=self.status,
366+
field_of_science=self.field_of_science,
367+
institution="Default",
368+
)
369+
370+
original_db_project = Project.objects.get(id=project.id)
371+
self.assertEqual(original_db_project.institution, "Default")
372+
373+
# Call the function and check object was modified in memory.
374+
determine_automated_institution_choice(project, PROJECT_INSTITUTION_EMAIL_MAP)
375+
self.assertEqual(project.institution, "AC")
376+
377+
# Check that database was NOT modified
378+
current_db_project = Project.objects.get(id=project.id)
379+
self.assertEqual(original_db_project.institution, "Default")
380+
381+
self.assertNotEqual(project.institution, current_db_project.institution)

coldfront/core/project/utils.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,35 @@ def generate_project_code(project_code: str, project_pk: int, padding: int = 0)
4848
"""
4949

5050
return f"{project_code.upper()}{str(project_pk).zfill(padding)}"
51+
52+
53+
def determine_automated_institution_choice(project, institution_map: dict):
54+
"""
55+
Determine automated institution choice for a project. Taking PI email of current project
56+
and comparing to domain key from institution_map. Will first try to match a domain exactly
57+
as provided in institution_map, if a direct match cannot be found an indirect match will be
58+
attempted by looking for the first occurrence of an institution domain that occurs as a substring
59+
in the PI's email address. This does not save changes to the database. The project object in
60+
memory will have the institution field modified.
61+
:param project: Project to add automated institution choice to.
62+
:param institution_map: Dictionary of institution keys, values.
63+
"""
64+
email: str = project.pi.email
65+
66+
try:
67+
_, pi_email_domain = email.split("@")
68+
except ValueError:
69+
pi_email_domain = None
70+
71+
direct_institution_match = institution_map.get(pi_email_domain)
72+
73+
if direct_institution_match:
74+
project.institution = direct_institution_match
75+
return direct_institution_match
76+
else:
77+
for institution_email_domain, indirect_institution_match in institution_map.items():
78+
if institution_email_domain in pi_email_domain:
79+
project.institution = indirect_institution_match
80+
return indirect_institution_match
81+
82+
return project.institution

0 commit comments

Comments
 (0)