diff --git a/src/core/admin.py b/src/core/admin.py index 740e7ae5ea..60789723f4 100755 --- a/src/core/admin.py +++ b/src/core/admin.py @@ -8,6 +8,7 @@ from django.contrib.auth.admin import UserAdmin from django.utils.safestring import mark_safe from django.template.defaultfilters import truncatewords +from django.conf import settings from utils import admin_utils from core import models, forms @@ -134,6 +135,12 @@ class AccountAdmin(UserAdmin): admin_utils.PasswordResetInline, ] + def get_readonly_fields(self, request, obj=None): + if settings.ENABLE_ORCID: + return ["orcid"] + else: + return [] + def _roles_in(self, obj): if obj: journals = journal_models.Journal.objects.filter( diff --git a/src/core/logic.py b/src/core/logic.py index 85479724e1..5f2014d574 100755 --- a/src/core/logic.py +++ b/src/core/logic.py @@ -148,6 +148,24 @@ def send_confirmation_link(request, new_user): ) +def send_orcid_request(request, user): + context = { + "user": user, + "user_profile_url": request.site_type.site_url( + reverse("core_edit_profile"), + ), + } + log_dict = {"level": "Info", "types": "ORCiD Request", "target": None} + notify_helpers.send_email_with_body_from_setting_template( + request, + "orcid_request", + "subject_orcid_request", + user.email, + context, + log_dict=log_dict, + ) + + def resize_and_crop( img_path, size=settings.DEFAULT_CROP_SIZE, diff --git a/src/core/migrations/0110_account_orcid_token_account_orcid_token_expiration_and_more.py b/src/core/migrations/0110_account_orcid_token_account_orcid_token_expiration_and_more.py new file mode 100644 index 0000000000..b4b3356419 --- /dev/null +++ b/src/core/migrations/0110_account_orcid_token_account_orcid_token_expiration_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.26 on 2026-01-21 20:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0109_salutation_name_20250707_1420"), + ] + + operations = [ + migrations.AddField( + model_name="account", + name="orcid_token", + field=models.CharField(blank=True, max_length=40, null=True), + ), + migrations.AddField( + model_name="account", + name="orcid_token_expiration", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="orcidtoken", + name="access_token", + field=models.CharField(blank=True, max_length=40, null=True), + ), + migrations.AddField( + model_name="orcidtoken", + name="access_token_expiration", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/src/core/models.py b/src/core/models.py index e02a2ab8b3..8cad9a76a6 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -71,6 +71,7 @@ from utils import logic as utils_logic from utils.forms import plain_text_validator from production import logic as production_logic +from utils.orcid import is_token_valid fs = JanewayFileSystemStorage() logger = get_logger(__name__) @@ -485,6 +486,8 @@ class Account(AbstractBaseUser, PermissionsMixin): orcid = models.CharField( max_length=40, null=True, blank=True, verbose_name=_("ORCiD") ) + orcid_token = models.CharField(max_length=40, null=True, blank=True) + orcid_token_expiration = models.DateTimeField(null=True, blank=True) twitter = models.CharField( max_length=300, null=True, blank=True, verbose_name=_("Twitter Handle") ) @@ -948,6 +951,12 @@ def hypothesis_username(self): )[:30] return username.lower() + def get_orcid_url(self): + return f"{settings.ORCID_URL.replace('oauth/authorize', '')}{self.orcid}" + + def is_orcid_token_valid(self): + return is_token_valid(self.orcid, self.orcid_token) + def generate_expiry_date(): return timezone.now() + timedelta(days=1) @@ -959,6 +968,8 @@ class OrcidToken(models.Model): expiry = models.DateTimeField( default=generate_expiry_date, verbose_name=_("Expires on") ) + access_token = models.CharField(max_length=40, null=True, blank=True) + access_token_expiration = models.DateTimeField(null=True, blank=True) def __str__(self): return "ORCiD Token [{0}] - {1}".format(self.orcid, self.token) diff --git a/src/core/tests/test_app.py b/src/core/tests/test_app.py index 280f5a83cf..eeef8e3ed9 100755 --- a/src/core/tests/test_app.py +++ b/src/core/tests/test_app.py @@ -15,6 +15,7 @@ from django.urls.base import clear_script_prefix from django.utils import timezone from django.core import mail +from journal.tests.utils import make_test_journal from utils.testing import helpers from utils import setting_handler, install @@ -582,3 +583,80 @@ def setUp(self): ) clear_script_prefix() + + @override_settings(ENABLE_ORCID=False) + def test_profile_orcid_disabled(self): + self.client.force_login(self.admin_user) + response = self.client.get(reverse("core_edit_profile")) + self.assertContains( + response, '' + ) + + def test_profile_orcid_enabled_no_orcid(self): + # Profile should offer to connect orcid + self.client.force_login(self.admin_user) + response = self.client.get(reverse("core_edit_profile")) + self.assertNotContains(response, "ORCiD could not be validated.") + self.assertContains(response, "Connect your ORCiD") + + @override_settings(ORCID_URL="https://sandbox.orcid.org/oauth/authorize") + def test_profile_orcid_unverified(self): + self.admin_user.orcid = "0000-0000-0000-0000" + self.admin_user.save() + self.client.force_login(self.admin_user) + response = self.client.get(reverse("core_edit_profile")) + self.assertContains(response, "ORCiD could not be validated.") + self.assertContains(response, "Connect your ORCiD") + self.assertContains(response, "https://sandbox.orcid.org/0000-0000-0000-0000") + + @patch.object(models.Account, "is_orcid_token_valid") + @override_settings(ORCID_URL="https://sandbox.orcid.org/oauth/authorize") + def test_profile_orcid(self, mock_method): + # override is_orcid_token valid make if valid + mock_method.return_value = True + self.admin_user.orcid = "0000-0000-0000-0000" + self.admin_user.orcid_token = "0a0aaaaa-0aa0-0000-aa00-a00aa0a00000" + self.admin_user.save() + self.client.force_login(self.admin_user) + response = self.client.get(reverse("core_edit_profile")) + self.assertContains(response, "https://sandbox.orcid.org/0000-0000-0000-0000") + self.assertContains(response, "remove_orcid") + self.assertContains( + response, '' + ) + self.assertNotContains(response, "ORCiD could not be validated.") + + @patch.object(models.Account, "is_orcid_token_valid") + @override_settings( + URL_CONFIG="domain", ORCID_URL="https://sandbox.orcid.org/oauth/authorize" + ) + def test_profile_orcid_not_admin(self, mock_method): + mock_method.return_value = True + + journal_kwargs = { + "code": "fetests", + "domain": "fetests.janeway.systems", + } + journal = make_test_journal(**journal_kwargs) + + journal_manager = helpers.create_user( + "jmanager@mailinator.com", ["journal-manager"], journal=journal + ) + journal_manager.is_active = True + journal_manager.save() + + self.regular_user.orcid = "0000-0000-0000-0000" + self.regular_user.orcid_token = "0a0aaaaa-0aa0-0000-aa00-a00aa0a00000" + self.regular_user.save() + + self.client.force_login(journal_manager) + + url = reverse("core_user_edit", kwargs={"user_id": self.regular_user.pk}) + response = self.client.get(url, SERVER_NAME=journal.domain) + + self.assertContains(response, "https://sandbox.orcid.org/0000-0000-0000-0000") + self.assertContains( + response, '' + ) + self.assertNotContains(response, "ORCiD could not be validated.") + self.assertNotContains(response, "remove_orcid") diff --git a/src/core/tests/test_views.py b/src/core/tests/test_views.py index 3747d32fa5..2f33ccd85f 100644 --- a/src/core/tests/test_views.py +++ b/src/core/tests/test_views.py @@ -379,7 +379,7 @@ def test_no_orcid_code_redirects_with_next(self): @override_settings(URL_CONFIG="domain") @override_settings(ENABLE_ORCID=True) def test_no_orcid_id_redirects_with_next(self, retrieve_tokens): - retrieve_tokens.return_value = None + retrieve_tokens.return_value = None, None, None get_data = { "code": "12345", "next": self.next_url_raw, @@ -399,7 +399,7 @@ def test_action_login_account_found_redirects_to_next( self, retrieve_tokens, ): - retrieve_tokens.return_value = self.user_orcid_uri + retrieve_tokens.return_value = None, None, self.user_orcid_uri get_data = { "code": "12345", "next": self.next_url_raw, @@ -422,7 +422,11 @@ def test_action_login_matching_email_redirects_to_next( orcid_details, ): # Change ORCID so it doesn't work - retrieve_tokens.return_value = "https://orcid.org/0000-0001-2312-3123" + retrieve_tokens.return_value = ( + None, + None, + "https://orcid.org/0000-0001-2312-3123", + ) # Return an email that will work orcid_details.return_value = {"emails": [self.user_email]} @@ -449,7 +453,11 @@ def test_action_login_failure_redirects_with_next( orcid_details, ): # Change ORCID so it doesn't work - retrieve_tokens.return_value = "https://orcid.org/0000-0001-2312-3123" + retrieve_tokens.return_value = ( + None, + None, + "https://orcid.org/0000-0001-2312-3123", + ) orcid_details.return_value = {"emails": []} get_data = { @@ -471,7 +479,7 @@ def test_action_login_failure_redirects_with_next( @override_settings(URL_CONFIG="domain") @override_settings(ENABLE_ORCID=True) def test_action_register_redirects_with_next(self, retrieve_tokens): - retrieve_tokens.return_value = self.user_orcid_uri + retrieve_tokens.return_value = None, None, self.user_orcid_uri get_data = { "code": "12345", "next": self.next_url_raw, diff --git a/src/core/views.py b/src/core/views.py index 174c6a3712..f49e4dfd80 100755 --- a/src/core/views.py +++ b/src/core/views.py @@ -215,7 +215,9 @@ def user_login_orcid(request): # There is an orcid code, meaning the user has authenticated on orcid.org. # Make another request to orcid.org to verify it. - orcid_id = orcid.retrieve_tokens(orcid_code, request.site_type) + access_token, expiration, orcid_id = orcid.retrieve_tokens( + orcid_code, request.site_type + ) # If verification did not work, send them to the regular login page. if not orcid_id: @@ -261,7 +263,11 @@ def user_login_orcid(request): # Then send the user to a decision page that tells them # the ORCID login did not work and they will need to register. models.OrcidToken.objects.filter(orcid=orcid_id).delete() - new_token = models.OrcidToken.objects.create(orcid=orcid_id) + new_token = models.OrcidToken.objects.create( + orcid=orcid_id, + access_token=access_token, + access_token_expiration=expiration, + ) return redirect( logic.reverse_with_next( "core_orcid_registration", @@ -282,6 +288,24 @@ def user_login_orcid(request): kwargs={"orcid_token": str(new_token.token)}, ) ) + elif action == "add_profile_orcid": + if not request.user.is_authenticated: + messages.add_message( + request, + messages.WARNING, + _("You must be logged in to connect an ORCID to your account."), + ) + return redirect(logic.reverse_with_next("core_login", next_url)) + request.user.orcid = orcid_id + request.user.orcid_token = access_token + request.user.orcid_expiration = expiration + request.user.save() + messages.add_message( + request, + messages.SUCCESS, + _("Your ORCID has been connected to your account."), + ) + return redirect(logic.reverse_with_next("core_edit_profile", next_url)) @login_required @@ -434,6 +458,9 @@ def register(request, orcid_token=None): if form.is_valid(): if token_obj: new_user = form.save() + new_user.orcid_token = token_obj.access_token + new_user.orcid_expiration = token_obj.access_token_expiration + new_user.save() if new_user.orcid: orcid_details = orcid.get_orcid_record_details(token_obj.orcid) for orcid_affil in orcid_details.get("affiliations", []): @@ -545,6 +572,7 @@ def edit_profile(request): :return: HttpResponse object """ user = request.user + form = forms.EditAccountForm(instance=user) send_reader_notifications = False next_url = request.GET.get("next", "") @@ -659,6 +687,12 @@ def edit_profile(request): elif "export" in request.POST: return logic.export_gdpr_user_profile(user) + elif "remove_orcid" in request.POST: + if orcid.revoke_token(user.orcid_token): + user.orcid = None + user.orcid_token = None + user.save() + form = forms.EditAccountForm(instance=user) template = "admin/core/accounts/edit_profile.html" context = { @@ -1534,24 +1568,32 @@ def user_edit(request, user_id): next_url = request.GET.get("next", "") if request.POST: - form = forms.EditAccountForm(request.POST, request.FILES, instance=user) - registration_form = forms.AdminUserForm( - request.POST, instance=user, request=request - ) - - if form.is_valid() and registration_form.is_valid(): - registration_form.save() - form.save() + if "request_orcid" in request.POST: + logic.send_orcid_request(request, user) messages.add_message( request, messages.SUCCESS, - "User account updated.", + _("Successfully requested ORCiD from user."), + ) + else: + form = forms.EditAccountForm(request.POST, request.FILES, instance=user) + registration_form = forms.AdminUserForm( + request.POST, instance=user, request=request ) - if next_url: - return redirect(next_url) - else: - return redirect(reverse("core_manager_users")) + if form.is_valid() and registration_form.is_valid(): + registration_form.save() + form.save() + messages.add_message( + request, + messages.SUCCESS, + "User account updated.", + ) + + if next_url: + return redirect(next_url) + else: + return redirect(reverse("core_manager_users")) template = "core/manager/users/edit.html" context = { diff --git a/src/templates/admin/elements/accounts/orcid_field.html b/src/templates/admin/elements/accounts/orcid_field.html new file mode 100644 index 0000000000..00b77a03fe --- /dev/null +++ b/src/templates/admin/elements/accounts/orcid_field.html @@ -0,0 +1,50 @@ +{% load orcid %} +{% load static %} + +
+
+
{{ form.instance.get_orcid_url }}
+
+
+ +
+ {% endif %} + {% else %} +ORCiD could not be validated.
+ {% endif %} + {% endif %} + {% if not form.orcid.value or not form.instance.is_orcid_token_valid %} + {% if form.instance == request.user %} + + Connect your ORCiD + + {% else %} + + {% endif %} + {% endif %} + {% if field.help_text %} +{{ field.help_text|safe }}
+ {% endif %} +Dear {{ user.full_name }}
Your co-author has requested your ORCiD. You can add it by through your profile {{ user_profile_url }}.