Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions src/core/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
11 changes: 11 additions & 0 deletions src/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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")
)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
78 changes: 78 additions & 0 deletions src/core/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, '<input type="text" name="orcid" maxlength="40" id="id_orcid">'
)

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, '<input type="hidden" name="orcid" value="0000-0000-0000-0000"/>'
)
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, '<input type="hidden" name="orcid" value="0000-0000-0000-0000"/>'
)
self.assertNotContains(response, "ORCiD could not be validated.")
self.assertNotContains(response, "remove_orcid")
18 changes: 13 additions & 5 deletions src/core/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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]}
Expand All @@ -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 = {
Expand All @@ -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,
Expand Down
72 changes: 57 additions & 15 deletions src/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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", []):
Expand Down Expand Up @@ -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", "")
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
Loading