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/forms/forms.py b/src/core/forms/forms.py index 2063fba894..aeee79f5ac 100755 --- a/src/core/forms/forms.py +++ b/src/core/forms/forms.py @@ -15,6 +15,7 @@ from django.utils.translation import gettext_lazy as _ from django.contrib.auth.forms import UserCreationForm from django.core.validators import validate_email, ValidationError +from django.conf import settings as django_settings from tinymce.widgets import TinyMCE @@ -278,6 +279,12 @@ class Meta: "enable_public_profile": YesNoRadio, } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if django_settings.ENABLE_ORCID: + self.fields["orcid"].widget = forms.HiddenInput() + def save(self, commit=True): user = super(EditAccountForm, self).save(commit=False) user.clean() diff --git a/src/core/include_urls.py b/src/core/include_urls.py index 165c320da9..94f5b6b594 100644 --- a/src/core/include_urls.py +++ b/src/core/include_urls.py @@ -431,6 +431,11 @@ core_views.manage_access_requests, name="manage_access_requests", ), + re_path( + r"^manager/account/(?P\d+)/request_orcid/$", + core_views.request_orcid, + name="request_orcid", + ), ] # Journal homepage block loading diff --git a/src/core/logic.py b/src/core/logic.py index 85479724e1..fe493d8297 100755 --- a/src/core/logic.py +++ b/src/core/logic.py @@ -148,6 +148,42 @@ def send_confirmation_link(request, new_user): ) +def send_orcid_request(request, user): + if request.journal: + publication_name = request.journal.name + elif request.repository: + publication_name = request.repository.name + else: + publication_name = request.press.name + context = { + "user": user, + "user_profile_url": request.site_type.site_url( + reverse("core_edit_profile"), + ), + "publication_name": publication_name, + } + log_dict = {"level": "Info", "types": "ORCID Request", "target": None} + + user.date_orcid_requested = timezone.now() + user.save() + + if user.is_active: + template = "orcid_request" + subject = "subject_orcid_request" + else: + template = "orcid_activate_request" + subject = "subject_orcid_activate_request" + + notify_helpers.send_email_with_body_from_setting_template( + request, + template, + subject, + user.email, + context, + log_dict=log_dict, + ) + + def resize_and_crop( img_path, size=settings.DEFAULT_CROP_SIZE, @@ -1035,22 +1071,27 @@ def password_policy_check(request): password = request.POST.get("password_1") rules = [ - lambda s: len(password) >= request.press.password_length - or _("Your password must be {} characters long").format( - request.press.password_length + lambda s: ( + len(password) >= request.press.password_length + or _("Your password must be {} characters long").format( + request.press.password_length + ) ) ] if request.press.password_upper: rules.append( - lambda password: any(x.isupper() for x in password) - or _("An uppercase character is required") + lambda password: ( + any(x.isupper() for x in password) + or _("An uppercase character is required") + ) ) if request.press.password_number: rules.append( - lambda password: any(x.isdigit() for x in password) - or _("A number is required") + lambda password: ( + any(x.isdigit() for x in password) or _("A number is required") + ) ) problems = [p for p in [r(password) for r in rules] if p != True] diff --git a/src/core/migrations/0110_account_date_orcid_requested_account_orcid_token_and_more.py b/src/core/migrations/0110_account_date_orcid_requested_account_orcid_token_and_more.py new file mode 100644 index 0000000000..08734f80f3 --- /dev/null +++ b/src/core/migrations/0110_account_date_orcid_requested_account_orcid_token_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.26 on 2026-02-18 21:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0109_salutation_name_20250707_1420"), + ] + + operations = [ + migrations.AddField( + model_name="account", + name="date_orcid_requested", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="account", + name="orcid_token", + field=models.CharField(blank=True, default="", max_length=40), + ), + 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, default="", max_length=40), + ), + 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..01b057ac42 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,9 @@ class Account(AbstractBaseUser, PermissionsMixin): orcid = models.CharField( max_length=40, null=True, blank=True, verbose_name=_("ORCiD") ) + orcid_token = models.CharField(max_length=40, blank=True, default="") + orcid_token_expiration = models.DateTimeField(null=True, blank=True) + date_orcid_requested = models.DateTimeField(blank=True, null=True) twitter = models.CharField( max_length=300, null=True, blank=True, verbose_name=_("Twitter Handle") ) @@ -884,6 +888,10 @@ def snapshot_as_author(self, article, force_update=True): "order": article.next_frozen_author_order(), } + if self.orcid: + frozen_dict["frozen_orcid"] = self.orcid + frozen_dict["is_frozen_orcid_valid"] = self.is_orcid_token_valid() + frozen_author, created = submission_models.FrozenAuthor.objects.get_or_create( author=self, article=article, @@ -948,6 +956,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,9 +973,11 @@ class OrcidToken(models.Model): expiry = models.DateTimeField( default=generate_expiry_date, verbose_name=_("Expires on") ) + access_token = models.CharField(max_length=40, blank=True, default="") + access_token_expiration = models.DateTimeField(null=True, blank=True) def __str__(self): - return "ORCiD Token [{0}] - {1}".format(self.orcid, self.token) + return "ORCID iD Token [{0}] - {1}".format(self.orcid, self.token) class PasswordResetToken(models.Model): diff --git a/src/core/tests/test_app.py b/src/core/tests/test_app.py index 280f5a83cf..d710f96b4e 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 @@ -219,7 +220,7 @@ def test_register_with_orcid_token(self, record_mock): self.assertContains(response, "Campbell") self.assertContains(response, "Kasey") self.assertContains(response, "campbell@evu.edu") - self.assertNotContains(response, "Register with ORCiD") + self.assertNotContains(response, "Register with ORCID") self.assertContains(response, "http://sandbox.orcid.org/0000-0000-0000-0000") self.assertContains( response, @@ -252,13 +253,13 @@ def test_register_with_orcid_token(self, record_mock): def test_registration(self): response = self.client.get(reverse("core_register")) self.assertEqual(response.status_code, 200) - self.assertContains(response, "Register with ORCiD") + self.assertContains(response, "Register with ORCID") @override_settings(ENABLE_ORCID=False) def test_registration(self): response = self.client.get(reverse("core_register")) self.assertEqual(response.status_code, 200) - self.assertNotContains(response, "Register with ORCiD") + self.assertNotContains(response, "Register with ORCID") @override_settings(URL_CONFIG="domain", CAPTCHA_TYPE=None) def test_mixed_case_login_different_case(self): @@ -582,3 +583,88 @@ 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, '' + ) + + @override_settings(ENABLE_ORCID=True) + 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( + ENABLE_ORCID=True, 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 iD 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( + ENABLE_ORCID=True, 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 iD could not be validated.") + + @patch.object(models.Account, "is_orcid_token_valid") + @override_settings( + ENABLE_ORCID=True, + 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 iD 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..56e279d892 100644 --- a/src/core/tests/test_views.py +++ b/src/core/tests/test_views.py @@ -5,9 +5,11 @@ from mock import patch from uuid import uuid4 +from datetime import datetime from django.urls.base import clear_script_prefix from django.shortcuts import reverse from django.test import Client, TestCase, override_settings +from django.utils import timezone from core import models as core_models from core import views as core_views @@ -34,6 +36,7 @@ def setUpTestData(cls): cls.user_orcid_uri = f"https://orcid.org/{cls.user_orcid}/" cls.user.orcid = cls.user_orcid_uri cls.orcid_token_uuid = uuid4() + cls.orcid_access_token_uuid = uuid4() cls.orcid_token = core_models.OrcidToken.objects.create( token=cls.orcid_token_uuid, orcid=cls.user_orcid_uri, @@ -379,7 +382,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 get_data = { "code": "12345", "next": self.next_url_raw, @@ -399,7 +402,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 = "", "", self.user_orcid_uri get_data = { "code": "12345", "next": self.next_url_raw, @@ -422,7 +425,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 = ( + "", + "", + "https://orcid.org/0000-0001-2312-3123", + ) # Return an email that will work orcid_details.return_value = {"emails": [self.user_email]} @@ -449,7 +456,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 = ( + self.orcid_access_token_uuid, + datetime(2050, 5, 17, 10, 30, 0, tzinfo=timezone.get_current_timezone()), + "https://orcid.org/0000-0001-2312-3123", + ) orcid_details.return_value = {"emails": []} get_data = { @@ -471,7 +482,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 = "", "", self.user_orcid_uri get_data = { "code": "12345", "next": self.next_url_raw, @@ -488,6 +499,69 @@ def test_action_register_redirects_with_next(self, retrieve_tokens): response.redirect_chain[0][0], ) + @patch("core.views.orcid.get_orcid_record_details") + @patch("core.views.orcid.retrieve_tokens") + @override_settings(URL_CONFIG="domain") + @override_settings(ENABLE_ORCID=True) + def test_orcid_inactive_account(self, retrieve_tokens, orcid_details): + inactive_email = "2LKEgc2a23@example.org" + inactive_user = core_models.Account.objects.create_user( + inactive_email, password="RFBsviApaN6jfAdHyHXY", orcid="" + ) + inactive_user.is_active = False + retrieve_tokens.return_value = ( + self.orcid_access_token_uuid, + datetime(2050, 5, 17, 10, 30, 0, tzinfo=timezone.get_current_timezone()), + self.user_orcid, + ) + orcid_details.return_value = {"emails": []} + get_data = { + "code": "12345", + "state": self.state_login, + } + response = self.client.get( + "/login/orcid/", + get_data, + follow=True, + SERVER_NAME=self.journal_one.domain, + ) + self.assertIn( + f"/register/step/orcid/", + response.redirect_chain[0][0], + ) + + @patch("core.views.orcid.get_orcid_record_details") + @patch("core.views.orcid.retrieve_tokens") + @override_settings(URL_CONFIG="domain") + @override_settings(ENABLE_ORCID=True) + def test_duplicate_orcid_inactive(self, retrieve_tokens, orcid_details): + inactive_email = "2LKEgc2a23@example.org" + inactive_orcid = "0000-0001-1111-1111" + inactive_user = core_models.Account.objects.create_user( + inactive_email, password="RFBsviApaN6jfAdHyHXY", orcid=inactive_orcid + ) + inactive_user.is_active = False + retrieve_tokens.return_value = ( + self.orcid_access_token_uuid, + datetime(2050, 5, 17, 10, 30, 0, tzinfo=timezone.get_current_timezone()), + inactive_orcid, + ) + orcid_details.return_value = {"emails": [inactive_email]} + get_data = { + "code": "12345", + "state": self.state_login, + } + response = self.client.get( + "/login/orcid/", + get_data, + follow=True, + SERVER_NAME=self.journal_one.domain, + ) + self.assertIn( + f"/register/step/orcid/", + response.redirect_chain[0][0], + ) + class GetResetTokenTests(CoreViewTestsWithData): @patch("core.views.logic.start_reset_process") diff --git a/src/core/views.py b/src/core/views.py index 174c6a3712..a31d51f380 100755 --- a/src/core/views.py +++ b/src/core/views.py @@ -191,7 +191,7 @@ def user_login_orcid(request): messages.add_message( request, messages.WARNING, - _("ORCID is not enabled.Please log in with your username and password."), + _("ORCID is not enabled. Please log in with your username and password."), ) return redirect(logic.reverse_with_next("core_login", next_url)) @@ -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: @@ -227,11 +229,47 @@ def user_login_orcid(request): ) return redirect(logic.reverse_with_next("core_login", next_url)) - # The verification worked. - # If the user wanted to log in, try to log them in. if action == "login": - try: - user = models.Account.objects.get(orcid=orcid_id) + orcid_accounts = models.Account.objects.filter(orcid=orcid_id, is_active=True) + # if we have exactly one account with this orcid do the login + if orcid_accounts.count() == 1: + user = orcid_accounts.first() + else: + user = None + orcid_details = orcid.get_orcid_record_details(orcid_id) + emails = orcid_details.get("emails", []) + # if we have more than one account with this orcid + # find the best match (first, if orcid is validated + # second if email is listed in data from orcid) + if orcid_accounts.count() > 1: + user_token_valid = False + user_email_index = len(emails) + 2 + + for a in orcid_accounts: + a_token_valid = a.is_orcid_token_valid() + a_email_index = ( + emails.index(a.email) if a.email in emails else len(emails) + 1 + ) + if not user_token_valid and a_token_valid: + user = a + user_token_valid = a_token_valid + user_email_index = a_email_index + elif a_email_index < user_email_index: + user = a + user_token_valid = a_token_valid + user_email_index = a_email_index + else: + # if there are no accounts with this orcid + # look for an account with emails reported by orcid + for e in emails: + email_accounts = models.Account.objects.filter( + email=e, is_active=True + ) + if email_accounts.exists(): + user = email_accounts.first() + break + + if user is not None: login( request, user, @@ -239,29 +277,16 @@ def user_login_orcid(request): ) return redirect(request.site_type.auth_success_url(next_url=next_url)) - except models.Account.DoesNotExist: - # Lookup ORCID email addresses - orcid_details = orcid.get_orcid_record_details(orcid_id) - for email in orcid_details.get("emails", []): - candidates = models.Account.objects.filter(email=email) - if candidates.exists(): - # Store ORCID for future authentication requests - candidates.update(orcid=orcid_id) - login( - request, - candidates.first(), - backend="django.contrib.auth.backends.ModelBackend", - ) - return redirect( - request.site_type.auth_success_url(next_url=next_url) - ) - # If no account was found for login, # then prepare an ORCID token for registration. # 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", @@ -274,14 +299,83 @@ def user_login_orcid(request): # and pass along their orcid token so information can be pre-filled. elif action == "register": models.OrcidToken.objects.filter(orcid=orcid_id).delete() - new_token = models.OrcidToken.objects.create(orcid=orcid_id) - return redirect( - logic.reverse_with_next( - "core_register_with_orcid_token", - next_url, - kwargs={"orcid_token": str(new_token.token)}, + orcid_accounts = models.Account.objects.filter(orcid=orcid_id, is_active=True) + validated_accounts = [a for a in orcid_accounts if a.is_orcid_token_valid()] + if len(validated_accounts) > 0: + messages.add_message( + request, + messages.WARNING, + _("An account with that ORCID iD already exists. Please login."), ) - ) + return redirect( + logic.reverse_with_next( + "core_login", + next_url, + ) + ) + else: + new_token = models.OrcidToken.objects.create(orcid=orcid_id) + return redirect( + logic.reverse_with_next( + "core_register_with_orcid_token", + next_url, + kwargs={"orcid_token": str(new_token.token)}, + ) + ) + elif action == "add_profile_orcid": # user is adding orcid through their profile + if not request.user.is_authenticated: + # this case is very unlikely but since this view + # doesn't require a login check to ensure they are + # already logged in, if not just redirect to + # non-orcid login + messages.add_message( + request, + messages.WARNING, + _("You must be logged in to connect an ORCID iD to your account."), + ) + return redirect(logic.reverse_with_next("core_login", next_url)) + # Make sure there isn't already a validated account with this ORCID + orcid_accounts = models.Account.objects.filter( + orcid=orcid_id, is_active=True + ).exclude(pk=request.user.pk) + validated_accounts = [a for a in orcid_accounts if a.is_orcid_token_valid()] + if len(validated_accounts) > 0: + messages.add_message( + request, + messages.ERROR, + _("An account with that ORCID iD already exists."), + ) + else: + # user is adding orcid so save it to logged in user's profile + 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 iD has been connected to your account."), + ) + # return to profile page + return redirect(logic.reverse_with_next("core_edit_profile", next_url)) + + +@login_required +@require_POST +def request_orcid(request, account_id): + user = get_object_or_404( + core_models.Account, + pk=account_id, + ) + logic.send_orcid_request(request, user) + messages.add_message( + request, + messages.SUCCESS, + f"Successfully requested ORCID iD from {user.full_name()}", + ) + + next_url = request.GET.get("next", "") + return redirect(next_url) @login_required @@ -434,6 +528,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 +642,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 +757,13 @@ 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 = "" + user.orcid_token = "" + user.orcid_token_expiration = None + user.save() + form = forms.EditAccountForm(instance=user) template = "admin/core/accounts/edit_profile.html" context = { diff --git a/src/repository/models.py b/src/repository/models.py index 5d70a1f272..f7146250c0 100755 --- a/src/repository/models.py +++ b/src/repository/models.py @@ -938,6 +938,30 @@ def __str__(self): preprint=self.preprint.title, ) + # These orcid properties mirror those in FrozenAuthor + # it allows us to use the same template to display orcids + @property + def orcid(self): + if self.account: + return self.account.orcid + return None + + @property + def orcid_uri(self): + if not self.orcid: + return "" + result = submission_models.COMPILED_ORCID_REGEX.search(self.orcid) + if result: + return f"https://orcid.org/{result.group(0)}" + else: + return "" + + @property + def is_orcid_valid(self): + if self.account: + return self.account.is_orcid_token_valid() + return False + @property def affiliation(self): """ diff --git a/src/repository/views.py b/src/repository/views.py index 82c7015219..7823af6b81 100644 --- a/src/repository/views.py +++ b/src/repository/views.py @@ -27,6 +27,7 @@ models as core_models, forms as core_forms, views as core_views, + logic as core_logic, ) from journal import models as journal_models from utils import ( diff --git a/src/submission/migrations/0089_frozenauthor_is_frozen_orcid_valid.py b/src/submission/migrations/0089_frozenauthor_is_frozen_orcid_valid.py new file mode 100644 index 0000000000..078966786f --- /dev/null +++ b/src/submission/migrations/0089_frozenauthor_is_frozen_orcid_valid.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.26 on 2026-03-11 19:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("submission", "0088_auto_20250506_1214"), + ] + + operations = [ + migrations.AddField( + model_name="frozenauthor", + name="is_frozen_orcid_valid", + field=models.BooleanField( + default=False, + help_text="Reflects if a validated orcid was associated with this account at the time of creation", + ), + ), + ] diff --git a/src/submission/models.py b/src/submission/models.py index 583d6bc87a..27b092b38e 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -2668,6 +2668,10 @@ class FrozenAuthor(AbstractLastModifiedModel): "ORCID to be displayed when no account is associated with this author." ), ) + is_frozen_orcid_valid = models.BooleanField( + default=False, + help_text="Reflects if a validated orcid was associated with this account at the time of creation", + ) display_email = models.BooleanField( default=False, help_text=_( @@ -2868,6 +2872,14 @@ def orcid_uri(self): else: return "" + @property + def is_orcid_valid(self): + if self.frozen_orcid: + return self.is_frozen_orcid_valid + elif self.author: + return self.author.is_orcid_token_valid() + return False + @property def corporate_name(self): return self.primary_affiliation(as_object=False) 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..2c11ac97d0 --- /dev/null +++ b/src/templates/admin/elements/accounts/orcid_field.html @@ -0,0 +1,41 @@ +{% load orcid %} +{% load static %} +{% if account.pk %} +
+
+ + ORCID iD + +
+ {% if account.orcid %} +
+ + ORCID logo {{ account.get_orcid_url }} + +
+ {% if account.is_orcid_token_valid %} + {% if account == request.user or request.user.is_staff %} +
+ +
+ {% endif %} + {% else %} +
ORCID iD could not be validated.
+ {% endif %} + {% endif %} + {% if not account.orcid or not account.is_orcid_token_valid %} + {% if account == request.user %} + + {% else %} + {% include "admin/elements/orcid_request.html" %} + {% endif %} + {% endif %} +
+{% endif %} diff --git a/src/templates/admin/elements/accounts/user_form.html b/src/templates/admin/elements/accounts/user_form.html index a0fdfdb389..27fa88ce3a 100644 --- a/src/templates/admin/elements/accounts/user_form.html +++ b/src/templates/admin/elements/accounts/user_form.html @@ -14,12 +14,19 @@

{% trans "Social Media and Accounts" %}

{% include "admin/elements/forms/field.html" with field=form.twitter %} {% include "admin/elements/forms/field.html" with field=form.facebook %} - {% include "admin/elements/forms/field.html" with field=form.orcid %} {% include "admin/elements/forms/field.html" with field=form.github %} {% include "admin/elements/forms/field.html" with field=form.linkedin %} {% include "admin/elements/forms/field.html" with field=form.website %} + {% if not settings.ENABLE_ORCID %} + {% include "admin/elements/forms/field.html" with field=form.orcid %} + {% else %} + {{ form.orcid }} +
+ {% include 'admin/elements/accounts/orcid_field.html' with account=form.instance %} +
+ {% endif %}
-
+

{% trans "Biography and Signature" %}

diff --git a/src/templates/admin/elements/current_authors_inner.html b/src/templates/admin/elements/current_authors_inner.html index ad6288ecf4..9a55c639a6 100644 --- a/src/templates/admin/elements/current_authors_inner.html +++ b/src/templates/admin/elements/current_authors_inner.html @@ -84,7 +84,22 @@

{% trans "Email" as email_key %} {% include "admin/elements/layout/key_value_above.html" with key=email_key value=author.real_email|default:"No email address" %} {% trans "ORCID" as orcid_key %} - {% include "admin/elements/layout/key_value_above.html" with key=orcid_key value=author.orcid|default:"No ORCID" %} +
+
+ {{ orcid_key }} +
+
+ {{ author.orcid|default:"No ORCID iD" }} + {% if settings.ENABLE_ORCID %} + {% if author.orcid and not author.is_orcid_valid %} +
ORCID iD could not be validated.
+ {% endif %} + {% if not author.is_orcid_valid and author.author %} + {% include "admin/elements/orcid_request.html" with account=author.author %} + {% endif %} + {% endif %} +
+
{% trans "Has user account" as account_key %} {% include "admin/elements/layout/key_value_above.html" with key=account_key value=author.author|yesno:"Yes,No" %}

diff --git a/src/templates/admin/elements/orcid_display.html b/src/templates/admin/elements/orcid_display.html index ac64c3d043..810e86cbc6 100644 --- a/src/templates/admin/elements/orcid_display.html +++ b/src/templates/admin/elements/orcid_display.html @@ -11,6 +11,9 @@ + {% if settings.ENABLE_ORCID and not author.is_orcid_valid %} +
ORCID iD could not be validated.
+ {% endif %} {% elif author.orcid %} {{ author.orcid }} {% else %} diff --git a/src/templates/admin/elements/orcid_request.html b/src/templates/admin/elements/orcid_request.html new file mode 100644 index 0000000000..3b56a20b01 --- /dev/null +++ b/src/templates/admin/elements/orcid_request.html @@ -0,0 +1,12 @@ +{% load next_url %} +{% if settings.ENABLE_ORCID %} + {% if account.date_orcid_requested %} +

Request sent: {{ account.date_orcid_requested|date:"d M Y" }}

+ {% endif %} +
+
+ {% csrf_token %} + +
+
+{% endif %} \ No newline at end of file diff --git a/src/templates/admin/repository/article.html b/src/templates/admin/repository/article.html index ce851b814b..3507efdfcc 100644 --- a/src/templates/admin/repository/article.html +++ b/src/templates/admin/repository/article.html @@ -220,44 +220,51 @@

Authors

Author
-
- {% csrf_token %} - - - - - - - - - - - - {% for author in preprint.preprintauthor_set.all %} - - - - - - + + {% empty %} + + + + {% endfor %} + +
{% trans 'Name' %}{% trans 'Email' %}{% trans 'Affiliation' %}{% trans 'Edit' %}{% trans 'Delete' %}
{{ author.account.full_name }}{{ author.account.email }}{% if author.affiliation %}{{ author.affiliation }}{% else %} - {{ author.account.institution }}{% endif %} - - Edit - - + + + + + + + + + + + + + {% for author in preprint.preprintauthor_set.all %} + + + + + + + - - {% empty %} - - - - {% endfor %} - -
{% trans 'Name' %}{% trans 'Email' %}{% trans 'ORCID iD' %}{% trans 'Affiliation' %}{% trans 'Edit' %}{% trans 'Delete' %}
{{ author.account.full_name }}{{ author.account.email }} + {% include "admin/elements/orcid_display.html" %} + {% if not author.is_orcid_valid %} + {% include "admin/elements/orcid_request.html" with account=author.account %} + {% endif %} + {% if author.affiliation %}{{ author.affiliation }}{% else %} + {{ author.account.institution }}{% endif %} + + Edit + + + + {% csrf_token %} -
{% trans 'No authors added.' %}
- + +
{% trans 'No authors added.' %}
diff --git a/src/templates/admin/repository/submit/authors.html b/src/templates/admin/repository/submit/authors.html index 6d256c22d9..8158c2394f 100644 --- a/src/templates/admin/repository/submit/authors.html +++ b/src/templates/admin/repository/submit/authors.html @@ -42,7 +42,7 @@

{% trans 'Add Self as Author' %}

{% trans 'Search for an Author' %}

-

{% trans 'You can search by email or ORCiD. eg. person@example.com or 0000-0003-2126-266X.' %}

+

{% trans 'You can search by email or ORCID iD. eg. person@example.com or 0000-0003-2126-266X.' %}

{% csrf_token %}
@@ -68,35 +68,42 @@

{% trans 'Authors' %}

{% trans 'You can reorder the authors by dragging and dropping rows in the table below.' %}

- - {% csrf_token %} - - - - - - - - - - - {% for author in preprint.preprintauthor_set.all %} - - - - - +
{% trans 'Name' %}{% trans 'Email' %}{% trans 'Affiliation' %}{% trans 'Delete' %}
{{ author.account.full_name }}{{ author.account.email }}{{ author.display_affiliation }} + + + + + + + + + + + + {% for author in preprint.preprintauthor_set.all %} + + + + + + + + + + {% empty %} + + - {% empty %} - - - - {% endfor %} - -
{% trans 'Name' %}{% trans 'Email' %}{% trans 'ORCID iD' %}{% trans 'Affiliation' %}{% trans 'Delete' %}
{{ author.account.full_name }}{{ author.account.email }} + {% include "admin/elements/orcid_display.html" %} + {% if not author.is_orcid_valid %} + {% include "admin/elements/orcid_request.html" with account=author.account %} + {% endif %} + {{ author.display_affiliation }} + + {% csrf_token %} -
{% trans 'No authors added.' %}
{% trans 'No authors added.' %}
- + {% endfor %} +

{% trans 'Once you have added all of your authors you can complete this stage.' %}

{% csrf_token %} diff --git a/src/templates/admin/repository/submit/review.html b/src/templates/admin/repository/submit/review.html index f4dbd2ce29..71f32ab8fd 100644 --- a/src/templates/admin/repository/submit/review.html +++ b/src/templates/admin/repository/submit/review.html @@ -55,12 +55,19 @@

Authors

Email Address First Name Last Name + ORCID iD {% for author in preprint.preprintauthor_set.all %} {{ author.account.email }} {{ author.account.first_name }} {{ author.account.last_name }} + + {% include "admin/elements/orcid_display.html" %} + {% if not author.is_orcid_valid %} + {% include "admin/elements/orcid_request.html" with account=author.account %} + {% endif %} + {% endfor %} diff --git a/src/templates/admin/submission/edit/author.html b/src/templates/admin/submission/edit/author.html index a2aec40198..333c602080 100644 --- a/src/templates/admin/submission/edit/author.html +++ b/src/templates/admin/submission/edit/author.html @@ -134,7 +134,22 @@

{% trans "Name, bio, and identifiers" %}

{% trans "Display email" as display_email %} {% include "elements/layout/key_value_above.html" with key=display_email value=author.display_email|yesno:"Yes,No" %} {% trans "ORCID" as orcid_key %} - {% include "admin/elements/layout/key_value_above.html" with key=orcid_key value=author.orcid|default:"No ORCID" %} +
+
+ {{ orcid_key }} +
+
+
{{ author.orcid|default:"No ORCID iD" }}
+ {% if settings.ENABLE_ORCID %} + {% if author.orcid and not author.is_orcid_valid %} +
ORCID iD could not be validated.
+ {% endif %} + {% if not author.is_orcid_valid and author.author %} + {% include "admin/elements/orcid_request.html" with account=author.author %} + {% endif %} + {% endif %} +
+
{% endif %} diff --git a/src/templates/common/identifiers/crossref_contributors.xml b/src/templates/common/identifiers/crossref_contributors.xml index c149a9bf44..99bef68ea3 100644 --- a/src/templates/common/identifiers/crossref_contributors.xml +++ b/src/templates/common/identifiers/crossref_contributors.xml @@ -25,7 +25,7 @@ {% endif %} {% if author.orcid_uri %} - {{ author.orcid_uri }} + {{ author.orcid_uri }} {% endif %} {% endwith %} diff --git a/src/utils/install/journal_defaults.json b/src/utils/install/journal_defaults.json index ff88ba275a..1ea30181e2 100644 --- a/src/utils/install/journal_defaults.json +++ b/src/utils/install/journal_defaults.json @@ -1669,6 +1669,25 @@ "journal-manager" ] }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent when user requests co-authors add ORCID iD.", + "is_translatable": true, + "name": "orcid_request", + "pretty_name": "ORCID iD Request", + "type": "rich-text" + }, + "value": { + "default": "

Dear {{ user.full_name }},

Please click here to verify your ORCID iD and connect it to your {{ publication_name }} account (registered email: {{ user.email }}).

Kind regards,
{{ publication_name }}

" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, { "group": { "name": "general" @@ -3607,6 +3626,25 @@ "journal-manager" ] }, + { + "value": { + "default": "ORCID iD Request" + }, + "setting": { + "type": "char", + "pretty_name": "ORCID iD Request", + "is_translatable": true, + "description": "Subject for when a submitter requests a co-author's ORCID iD", + "name": "subject_orcid_request" + }, + "group": { + "name": "email_subject" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, { "group": { "name": "email" diff --git a/src/utils/orcid.py b/src/utils/orcid.py index d2d193bd94..b8f9aa05cc 100755 --- a/src/utils/orcid.py +++ b/src/utils/orcid.py @@ -14,6 +14,7 @@ from django.http import QueryDict import requests from requests.exceptions import HTTPError +import datetime from utils import logic from utils.logger import get_logger @@ -48,12 +49,45 @@ def retrieve_tokens(authorization_code, site): r.raise_for_status() except HTTPError as e: logger.error("ORCID request failed: %s" % str(e)) - orcid_id = None + # after logging failure continue with an empty response + # to avoid additional errors + orcid_response = {} else: logger.info("OK response from ORCID") - orcid_id = json.loads(r.text).get("orcid") + orcid_response = json.loads(r.text) - return orcid_id + access_token = orcid_response.get("access_token", None) + orcid_id = orcid_response.get("orcid", None) + + if "expires_in" in orcid_response: + expires = int(orcid_response.get("expires_in")) + expiration_date = datetime.datetime.now() + datetime.timedelta(seconds=expires) + else: + expiration_date = None + + return access_token, expiration_date, orcid_id + + +def is_token_valid(orcid_id, token): + is_sandbox = "sandbox" in settings.ORCID_URL + api_client = OrcidAPI( + settings.ORCID_CLIENT_ID, settings.ORCID_CLIENT_SECRET, sandbox=is_sandbox + ) + r = api_client._get_public_info( + orcid_id, "record", token, None, "application/orcid+json" + ) + return r.status_code == 200 + + +def revoke_token(token): + url = settings.ORCID_TOKEN_URL.replace("token", "revoke") + data = { + "client_id": settings.ORCID_CLIENT_ID, + "client_secret": settings.ORCID_CLIENT_SECRET, + "token": token, + } + r = requests.post(url, data=data) + return r.status_code == 200 def build_redirect_uri(site): @@ -67,7 +101,10 @@ def build_redirect_uri(site): def get_orcid_record(orcid): try: logger.info("Retrieving ORCID profile for %s", orcid) - api_client = OrcidAPI(settings.ORCID_CLIENT_ID, settings.ORCID_CLIENT_SECRET) + is_sandbox = "sandbox" in settings.ORCID_URL + api_client = OrcidAPI( + settings.ORCID_CLIENT_ID, settings.ORCID_CLIENT_SECRET, sandbox=is_sandbox + ) search_token = api_client.get_search_token_from_orcid() return api_client.read_record_public( orcid,