8000 Institution feature implementation. by co505 · Pull Request #670 · ubccr/coldfront · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Institution feature implementation. #670

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 2, 2025
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
6 changes: 6 additions & 0 deletions coldfront/config/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,9 @@

PROJECT_CODE = ENV.str("PROJECT_CODE", default=None)
PROJECT_CODE_PADDING = ENV.int("PROJECT_CODE_PADDING", default=None)

# ------------------------------------------------------------------------------
# Enable project institution code feature.
# ------------------------------------------------------------------------------

PROJECT_INSTITUTION_EMAIL_MAP = ENV.dict("PROJECT_INSTITUTION_EMAIL_MAP", default={})
13 changes: 10 additions & 3 deletions coldfront/core/project/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from coldfront.core.utils.common import import_from_settings

PROJECT_CODE = import_from_settings("PROJECT_CODE", False)
PROJECT_INSTITUTION_EMAIL_MAP = import_from_settings("PROJECT_INSTITUTION_EMAIL_MAP", False)


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

def get_list_display(self, request):
if not (PROJECT_CODE or PROJECT_INSTITUTION_EMAIL_MAP):
return self.list_display

list_display = list(self.list_display)

if PROJECT_CODE:
list_display = list(self.list_display)
list_display.insert(1, "project_code")
return tuple(list_display)

return self.list_display
if PROJECT_INSTITUTION_EMAIL_MAP:
list_display.insert(2, "institution")

return tuple(list_display)

def save_formset(self, request, form, formset, change):
if formset.model in [ProjectAdminComment, ProjectUserMessage]:
Expand Down
71 changes: 71 additions & 0 deletions coldfront/core/project/management/commands/add_institutions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# SPDX-FileCopyrightText: (C) ColdFront Authors
#
# SPDX-License-Identifier: AGPL-3.0-or-later

from django.core.management.base import BaseCommand

from coldfront.core.project.models import Project
from coldfront.core.project.utils import determine_automated_institution_choice
from coldfront.core.utils.common import import_from_settings

PROJECT_INSTITUTION_EMAIL_MAP = import_from_settings("PROJECT_INSTITUTION_EMAIL_MAP", False)


class Command(BaseCommand):
help = "Update existing projects with institutions based on PIs email address"

def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Outputs each project, followed by the assigned institution, without making changes.",
)

def update_project_institution(self, projects):
if not PROJECT_INSTITUTION_EMAIL_MAP:
self.stdout.write(
"Error, no changes made. Please set PROJECT_INSTITUTION_EMAIL_MAP as a dictionary value inside configuration file."
)
return

def _update_project_institution(self, projects):
user_input = input(
"Assign all existing projects with institutions? You can use the --dry-run flag to preview changes first. [y/N] "
)

try:
if user_input == "y" or user_input == "Y":
for project in projects:
project.institution = determine_automated_institution_choice(project, PROJECT_INSTITUTION_EMAIL_MAP)
project.save(update_fields=["institution"])
self.stdout.write(f"Updated {projects.count()} projects with institutions.")
else:
self.stdout.write("No changes made")
except Exception as e:
self.stdout.write(f"Error: {e}")

def _institution_dry_run(self, projects):
try:
for project in projects:
new_institution = determine_automated_institution_choice(project, PROJECT_INSTITUTION_EMAIL_MAP)
self.stdout.write(
f"Project {project.pk}, called {project.title}. Institution would be '{new_institution}'"
)
except Exception as e:
self.stdout.write(f"Error: {e}")

def handle(self, *args, **options):
dry_run = options["dry_run"]

if not PROJECT_INSTITUTION_EMAIL_MAP:
self.stdout.write(
"Error, no changes made. Please set PROJECT_INSTITUTION_EMAIL_MAP as a dictionary value inside configuration file."
)
return

projects_without_institution = Project.objects.filter(institution="None")

if dry_run:
self._institution_dry_run(projects_without_institution)
else:
self._update_project_institution(projects_without_institution)
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: (C) ColdFront Authors
#
# SPDX-License-Identifier: AGPL-3.0-or-later

# Generated by Django 4.2.11 on 2025-04-27 19:07

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("project", "0005_alter_historicalproject_options_and_more"),
]

operations = [
migrations.AddField(
model_name="historicalproject",
name="institution",
field=models.CharField(blank=True, default="None", max_length=80),
),
migrations.AddField(
model_name="project",
name="institution",
field=models.CharField(blank=True, default="None", max_length=80),
),
]
1 change: 1 addition & 0 deletions coldfront/core/project/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ def get_by_natural_key(self, title, pi_username):
history = HistoricalRecords()
objects = ProjectManager()
project_code = models.CharField(max_length=10, blank=True)
institution = models.CharField(max_length=80, blank=True, default="None")

def clean(self):
"""Validates the project and raises errors if the project is invalid."""
Expand Down
3 changes: 3 additions & 0 deletions coldfront/core/project/templates/project/project_archived_list.html 6D40
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ <h2>Archived Projects</h2>
<br> <strong>Description: </strong>{{ project.description }}</td>
<td>{{ project.field_of_science.description }}</td>
<td>{{ project.status.name }}</td>
{% if PROJECT_INSTITUTION_EMAIL_MAP %}
<p class="card-text text-justify"><strong>Institution: </strong>{{ project.institution }}</p>
{% endif %}
</tr>
{% endfor %}
</tbody>
Expand Down
3 changes: 3 additions & 0 deletions coldfront/core/project/templates/project/project_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ <h3 class="card-title">
<span class="badge badge-pill badge-info">project review pending</span>
{% endif %}
</p>
{% if PROJECT_INSTITUTION_EMAIL_MAP %}
<p class="card-text text-justify"><strong>Institution: </strong>{{ project.institution }}</p>
{% endif %}
<p class="card-text text-justify"><strong>Created: </strong>{{ project.created|date:"M. d, Y" }}</p>
</div>
</div>
Expand Down
10 changes: 10 additions & 0 deletions coldfront/core/project/templates/project/project_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ <h2>Projects</h2>
<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>
<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>
</th>
{% if PROJECT_INSTITUTION_EMAIL_MAP %}
<th scope="col" class="text-nowrap">
Institution
<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>
<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>
</th>
{% endif %}
</tr>
</thead>
<tbody>
Expand All @@ -85,6 +92,9 @@ <h2>Projects</h2>
<td style="text-align: justify; text-justify: inter-word;">{{ project.title }}</td>
<td>{{ project.field_of_science.description }}</td>
<td>{{ project.status.name }}</td>
{% if PROJECT_INSTITUTION_EMAIL_MAP %}
<td>{{ project.institution }}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
Expand Down
105 changes: 104 additions & 1 deletion coldfront/core/project/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
ProjectAttribute,
ProjectAttributeType,
)
from coldfront.core.project.utils import generate_project_code
from coldfront.core.project.utils import (
determine_automated_institution_choice,
generate_project_code,
)
from coldfront.core.test_helpers.factories import (
FieldOfScienceFactory,
PAttributeTypeFactory,
Expand Down Expand Up @@ -276,3 +279,103 @@ def test_different_prefix_padding(self):
# Test the generated project codes
self.assertEqual(project_with_code_padding1, "BFO001")
self.assertEqual(project_with_code_padding2, "BFO002")


class TestInstitution(TestCase):
def setUp(self):
self.user = UserFactory(username="capeo")
self.field_of_science = FieldOfScienceFactory(description="Physics")
self.status = ProjectStatusChoiceFactory(name="Active")

def create_project_with_institution(self, title, institution_dict=None):
"""Helper method to create a project and assign a institution value based on the argument passed"""
# Project Creation
project = Project.objects.create(
title=title,
pi=self.user,
status=self.status,
field_of_science=self.field_of_science,
)

if institution_dict:
determine_automated_institution_choice(project, institution_dict)

project.save()

return project.institution

@patch(
"coldfront.config.core.PROJECT_INSTITUTION_EMAIL_MAP",
{"inst.ac.com": "AC", "inst.edu.com": "EDU", "bfo.ac.uk": "BFO"},
)
def test_institution_is_none(self):
from coldfront.config.core import PROJECT_INSTITUTION_EMAIL_MAP

"""Test to check if institution is none after both env vars are enabled. """

# Create project with both institution
project_institution = self.create_project_with_institution("Project 1", PROJECT_INSTITUTION_EMAIL_MAP)

# Create the first project
self.assertEqual(project_institution, "None")

@patch(
"coldfront.config.core.PROJECT_INSTITUTION_EMAIL_MAP",
{"inst.ac.com": "AC", "inst.edu.com": "EDU", "bfo.ac.uk": "BFO"},
)
def test_institution_multiple_users(self):
from coldfront.config.core import PROJECT_INSTITUTION_EMAIL_MAP

"""Test to check multiple projects with different user email addresses, """

# Create project for user 1
self.user.email = "user@inst.ac.com"
self.user.save()
project_institution_one = self.create_project_with_institution("Project 1", PROJECT_INSTITUTION_EMAIL_MAP)
self.assertEqual(project_institution_one, "AC")

# Create project for user 2
self.user.email = "user@bfo.ac.uk"
self.user.save()
project_institution_two = self.create_project_with_institution("Project 2", PROJECT_INSTITUTION_EMAIL_MAP)
self.assertEqual(project_institution_two, "BFO")

# Create project for user 3
self.user.email = "user@inst.edu.com"
self.user.save()
project_institution_three = self.create_project_with_institution("Project 3", PROJECT_INSTITUTION_EMAIL_MAP)
self.assertEqual(project_institution_three, "EDU")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since add_automated_institution_choice(project, institution_dict) (or whatever you rename it to) does not make changes to the database I believe it would be beneficial to add a test case to ensure that that is the case. If someone gets confused down the road and does not realize that and changes the function later, that test should ding the change made to the API for add_automated_institution_choice.


@patch(
"coldfront.config.core.PROJECT_INSTITUTION_EMAIL_MAP",
{"inst.ac.com": "AC", "inst.edu.com": "EDU", "bfo.ac.uk": "BFO"},
)
def test_determine_automated_institution_choice_does_not_save_to_database(self):
from coldfront.config.core import PROJECT_INSTITUTION_EMAIL_MAP

"""Test that the function only modifies project in memory, not in database"""

self.user.email = "user@inst.ac.com"
self.user.save()

# Create project, similar to create_project_with_institution, but without the save function.
project = Project.objects.create(
title="Test Project",
pi=self.user,
status=self.status,
field_of_science=self.field_of_science,
institution="Default",
)

original_db_project = Project.objects.get(id=project.id)
self.assertEqual(original_db_project.institution, "Default")

# Call the function and check object was modified in memory.
determine_automated_institution_choice(project, PROJECT_INSTITUTION_EMAIL_MAP)
self.assertEqual(project.institution, "AC")

# Check that database was NOT modified
current_db_project = Project.objects.get(id=project.id)
self.assertEqual(original_db_project.institution, "Default")

self.assertNotEqual(project.institution, current_db_project.institution)
32 changes: 32 additions & 0 deletions coldfront/core/project/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,35 @@ def generate_project_code(project_code: str, project_pk: int, padding: int = 0)
"""

return f"{project_code.upper()}{str(project_pk).zfill(padding)}"


def determine_automated_institution_choice(project, institution_map: dict):
"""
Determine automated institution choice for a project. Taking PI email of current project
and comparing to domain key from institution_map. Will first try to match a domain exactly
as provided in institution_map, if a direct match cannot be found an indirect match will be
attempted by looking for the first occurrence of an institution domain that occurs as a substring
in the PI's email address. This does not save changes to the database. The project object in
memory will have the institution field modified.
:param project: Project to add automated institution choice to.
:param institution_map: Dictionary of institution keys, values.
"""
email: str = project.pi.email

try:
_, pi_email_domain = email.split("@")
except ValueError:
pi_email_domain = None

direct_institution_match = institution_map.get(pi_email_domain)

if direct_institution_match:
project.institution = direct_institution_match
return direct_institution_match
else:
for institution_email_domain, indirect_institution_match in institution_map.items():
if institution_email_domain in pi_email_domain:
project.institution = indirect_institution_match
return indirect_institution_match

return project.institution
8 changes: 7 additions & 1 deletion coldfront/core/project/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
project_remove_user,
project_update,
)
from coldfront.core.project.utils import generate_project_code
from coldfront.core.project.utils import determine_automated_institution_choice, generate_project_code
from coldfront.core.publication.models import Publication
from coldfront.core.research_output.models import ResearchOutput
from coldfront.core.user.forms import UserSearchForm
Expand All @@ -84,6 +84,7 @@
PROJECT_CODE_PADDING = import_from_settings("PROJECT_CODE_PADDING", False)

logger = logging.getLogger(__name__)
PROJECT_INSTITUTION_EMAIL_MAP = import_from_settings("PROJECT_INSTITUTION_EMAIL_MAP", False)


class ProjectDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView):
Expand Down Expand Up @@ -215,6 +216,7 @@ def get_context_data(self, **kwargs):
context["attributes_with_usage"] = attributes_with_usage
context["project_users"] = project_users
context["ALLOCATION_ENABLE_ALLOCATION_RENEWAL"] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL
context["PROJECT_INSTITUTION_EMAIL_MAP"] = PROJECT_INSTITUTION_EMAIL_MAP

try:
context["ondemand_url"] = settings.ONDEMAND_URL
Expand Down Expand Up @@ -358,6 +360,7 @@ def get_context_data(self, **kwargs):

context["filter_parameters"] = filter_parameters
context["filter_parameters_with_order_by"] = filter_parameters_with_order_by
context["PROJECT_INSTITUTION_EMAIL_MAP"] = PROJECT_INSTITUTION_EMAIL_MAP

project_list = context.get("project_list")
paginator = Paginator(project_list, self.paginate_by)
Expand Down Expand Up @@ -597,6 +600,9 @@ def form_valid(self, form):
project_obj.project_code = generate_project_code(PROJECT_CODE, project_obj.pk, PROJECT_CODE_PADDING or 0)
project_obj.save(update_fields=["project_code"])

if PROJECT_INSTITUTION_EMAIL_MAP:
determine_automated_institution_choice(project_obj, PROJECT_INSTITUTION_EMAIL_MAP)

# project signals
project_new.send(sender=self.__class__, project_obj=project_obj)

Expand Down
2 changes: 1 addition & 1 deletion docs/pages/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ The following settings are ColdFront specific settings related to the core appli
| PUBLICATION_ENABLE | Enable or disable publications. Default True |
| PROJECT_CODE | Specifies a custom internal project identifier. Default False, provide string value to enable.|
| PROJECT_CODE_PADDING | Defines a optional padding value to be added before the Primary Key section of PROJECT_CODE. Default False, provide integer value to enable.|

| PROJECT_INSTITUTION_EMAIL_MAP | Defines a dictionary where PI domain email addresses are keys and their corresponding institutions are values. Default is False, provide key-value pairs to enable this feature.|
### Database settings

The following settings configure the database server to use, if not set will default to using SQLite:
Expand Down
0