Skip to content

Author interface for ROR, CRediT, and ORCiD in submission workflow #4697

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 47 commits into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
23c3331
feat #4519 Users can import ROR-controlled affiliations from ORCiD
joemull Apr 7, 2025
2918919
feat #4519 #3111 ORCiD, ROR, and CRediT interface for authors
joemull Apr 7, 2025
ece60a2
fix #1485 Remove old API URL to avoid confusion
joemull Apr 7, 2025
e98c1b4
feat #4519 Add missing Python imports
joemull Apr 8, 2025
6b2e274
feat #4519 Improve ORCiD affiliation parsing
joemull Apr 15, 2025
9593b3d
feat #4519 UI improvements after demo
joemull Apr 15, 2025
77613e8
feat #4519 Translations and cleanup
joemull Apr 15, 2025
9f00775
Split out submission tests; test fixes for #4519
joemull Apr 15, 2025
45fd701
feat #4519 Write tests for views and logic
joemull Apr 16, 2025
834019b
feat #4519 Improve delete author logic; more tests
joemull Apr 18, 2025
cb1e361
feat #4519 Make button for copying share link
joemull Apr 23, 2025
5f0df45
feat #4519 UX improvements requested in review
joemull Apr 23, 2025
55d8bd9
feat #4519 Fix up a few things spotted in review
joemull Apr 23, 2025
6cfeefd
feat #4519 Use shell of a button for credit roles
joemull Apr 24, 2025
804c024
chore #4519 Spell ORCID without lowercase I
joemull Apr 24, 2025
b2c48b7
feat #4519 Wrap credit role shells on mobile
joemull Apr 24, 2025
46ef986
feat #4519 Fix shell button margins
joemull Apr 24, 2025
fe25e66
feat #4519 Return user to submission directly after affil edits
joemull Apr 24, 2025
2b044b6
wip #4519 Working UI for frozen author list during submission
joemull Apr 29, 2025
a633ed3
chore: adds migration for in progress articles forzen authors.
ajrbyers May 7, 2025
f8afa74
remove accidental comment
mauromsl May 8, 2025
6d08a21
fix: set name_prefix properly
ajrbyers May 14, 2025
3fd3312
#4519: Adds a warning to Article.authors
mauromsl May 21, 2025
b10bd1d
#4519: Adds signal for backwards compatibility of Article.authors
mauromsl May 21, 2025
cb8085a
#4519: Replace stale Article.authors calls
mauromsl May 21, 2025
0bd1b15
#4519: Replace stale Article.authors calls
mauromsl May 21, 2025
151b9d1
#4519: Replace stale Article accounts calls
mauromsl May 21, 2025
9eb2fe6
#4519: Update frozen authors mgmt command
mauromsl May 21, 2025
06300da
#4519: Adds manager to avoid n+1 Qs for article authors
mauromsl May 21, 2025
4e40420
fix: remove snapshot call when completing submission
ajrbyers May 21, 2025
bbe831c
fix: remove snapshot_authors calls that are no longer required.
ajrbyers May 21, 2025
6a91d9b
fix #4755 Adjustments to article-account relationships
joemull May 22, 2025
dec36a7
feat #4755 Systemtatic changes to calls to Article.authors
joemull May 22, 2025
5d5501f
feat #4519 Improvements for navigation and clarity
joemull May 26, 2025
3fed94a
feat #4519 Redirect after post on edit author page
joemull May 26, 2025
400ecce
feat #4519 Remove setting user_automatically_author
joemull May 26, 2025
bc2806a
feat #4755 Adjust migration in case first author has fake email
joemull May 26, 2025
628ed95
feat #4755 Fix bad rebase on submission tests
joemull May 26, 2025
f13ede8
wip #4519 submission models tests
joemull May 26, 2025
37afa0c
fix(migration): adds missing colon in submission 0088.
ajrbyers May 28, 2025
b44d83b
fix: use safe_title where needed.
ajrbyers May 30, 2025
b2e0a29
feat #4755 Update author logic in new submission tests
joemull Jun 3, 2025
55678a7
feat #4755 Update author logic in all submission tests
joemull Jun 3, 2025
ce8ae50
feat #4755 Update author logic in remaining tests
joemull Jun 3, 2025
a67f554
feat #4755 Update deprecation note
joemull Jun 4, 2025
113253b
feat #4519 Fix navigation and display bugs
joemull Jun 4, 2025
d8ca2ca
fix: Ensure unavailable GET parameter is not serialized as text
mauromsl Jun 5, 2025
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
2 changes: 0 additions & 2 deletions docs/source/manager/submission/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ This section allows you to control generic submission settings that affect how s
- Any publication fees associated with submitting the paper.
- Editors for Notification
- This allows you to select which Editors are notified of new papers being submitted.
- User Automatically Author
- If enabled the submission system assumes the user submitting the paper is also an author, they can be removed if required.
- Competing Interests
- This setting is deprecated in favour of the Submission Configurator version.
- Submission Summary
Expand Down
2 changes: 1 addition & 1 deletion docs/source/workflow/author.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Individual journals can add more fields to this page and they will be displayed

Author Information
------------------
On the Author Information page we can add the authors of our paper. One some journals the submitting user is added as an author automatically, on others you will have the option to add yourself as an author using a button.
On the Author Information page we can add the authors of our paper. The submitting user is added as an author automatically, but they can remove themselves and add other authors if needed.

.. figure:: ../nstatic/no_authors.png

Expand Down
8 changes: 4 additions & 4 deletions src/api/tests/test_oai.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ def setUpTestData(cls):
date_published="1986-07-12T17:00:00.000+0200",
authors=[cls.author],
)
cls.author.add_credit('data-curation', cls.article)
cls.author.add_credit('writing-original-draft', cls.article)
cls.frozen_author = cls.author.frozen_author(cls.article)
cls.frozen_author.add_credit('data-curation')
cls.frozen_author.add_credit('writing-original-draft')
cls.issue = helpers.create_issue(
journal=cls.journal, vol=1, number=1,
articles=[cls.article],
Expand Down Expand Up @@ -141,8 +142,7 @@ def test_get_record_jats(self):
expected = GET_RECORD_DATA_JATS
# Add a non correspondence author
author_2 = helpers.create_author(self.journal, email="[email protected]")
self.article.authors.add(author_2)
self.article.snapshot_authors()
author_2.snapshot_as_author(self.article)

setting_handler.save_setting(
"general",
Expand Down
6 changes: 3 additions & 3 deletions src/copyediting/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@ def setUpTestData(cls):
)
cls.active_article.title = 'Active Article'
cls.active_article.save()
cls.active_article.authors.add(cls.author)
cls.author.snapshot_as_author(cls.active_article)
cls.archived_article = helpers.create_article(
journal=cls.journal_one,
)
cls.archived_article.stage = submission_models.STAGE_ARCHIVED
cls.archived_article.title = 'Archived Article'
cls.archived_article.save()
cls.archived_article.authors.add(cls.author)
cls.author.snapshot_as_author(cls.archived_article)

cls.active_copyediting_task = models.CopyeditAssignment.objects.create(
article=cls.active_article,
Expand Down Expand Up @@ -141,4 +141,4 @@ def test_active_article_review_task_200s(self):
self.assertTrue(
response.status_code,
200,
)
)
2 changes: 1 addition & 1 deletion src/core/example_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

# ORCID Settings
ENABLE_ORCID = True
ORCID_API_URL = 'http://pub.orcid.org/v1.2_rc7/'
ORCID_API_URL = '' # Not needed any more. Requests are delegated to python-orcid.
ORCID_URL = 'https://orcid.org/oauth/authorize'
ORCID_TOKEN_URL = 'https://pub.orcid.org/oauth/token'
ORCID_CLIENT_SECRET = ''
Expand Down
1 change: 1 addition & 0 deletions src/core/forms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
JournalSubmissionForm,
LoginForm,
NotificationForm,
OrcidAffiliationForm,
OrganizationNameForm,
PasswordResetForm,
PressJournalAttrForm,
Expand Down
91 changes: 83 additions & 8 deletions src/core/forms/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@

import uuid
import json
import os

from django import forms
from django.db.models import Q
from django.utils.datastructures import MultiValueDict
from django.forms.fields import Field
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
Expand All @@ -27,6 +29,7 @@
JanewayTranslationModelForm,
CaptchaForm,
HTMLDateInput,
YesNoRadio,
)
from utils.logger import get_logger
from submission import models as submission_models
Expand Down Expand Up @@ -966,6 +969,7 @@ class Meta:
widgets = {
'start': HTMLDateInput,
'end': HTMLDateInput,
'is_primary': YesNoRadio,
}

def __init__(self, *args, **kwargs):
Expand All @@ -975,14 +979,15 @@ def __init__(self, *args, **kwargs):

def clean(self):
cleaned_data = super().clean()
query = Q(account=self.account, organization=self.organization)
for key, value in cleaned_data.items():
query &= Q((key, value))
if self._meta.model.objects.filter(query).exists():
self.add_error(
None,
"An affiliation with matching details already exists."
)
if not self.instance.pk:
query = Q(account=self.account, organization=self.organization)
for key, value in cleaned_data.items():
query &= Q((key, value))
if self._meta.model.objects.filter(query).exists():
self.add_error(
None,
"An affiliation with matching details already exists."
)
return cleaned_data

def save(self, commit=True):
Expand All @@ -994,6 +999,76 @@ def save(self, commit=True):
return affiliation


class OrcidAffiliationForm(forms.ModelForm):
"""
A form for creating ControlledAffiliation objects
from ORCID data.
"""

class Meta:
model = models.ControlledAffiliation
fields = '__all__'

def __init__(
self,
orcid_affiliation,
tzinfo=timezone.get_current_timezone(),
data=None,
*args,
**kwargs,
):
if not data:
data = MultiValueDict()

# The `get` methods below are used together with `or`
# defensively because of the data population in the API.
# It can have keys pointing to None values like:
# {"year": {"value": 2019}, "month": None}

data['title'] = orcid_affiliation.get('role-title', '') or ''
data['department'] = orcid_affiliation.get('department-name', '') or ''

org = None
orcid_org = orcid_affiliation.get('organization', {}) or {}
disamb_org = orcid_org.get('disambiguated-organization', {}) or {}
disamb_id = disamb_org.get('disambiguated-organization-identifier', '') or ''
if disamb_id.startswith('https://ror.org/'):
ror_id = os.path.split(disamb_id)[-1]
try:
org = models.Organization.objects.get(ror_id=ror_id)
except models.Organization.DoesNotExist:
pass
if not org:
address = orcid_org.get('address', {}) or {}
org, _created = models.Organization.get_or_create_without_ror(
institution=orcid_org.get('name', '') or '',
country=address.get('country', '') or '',
account=data.get('account'),
frozen_author=data.get('frozen_author'),
preprint_author=data.get('preprint_author'),
)
data['organization'] = org

orcid_start = orcid_affiliation.get('start-date', {}) or {}
if orcid_start:
data['start'] = timezone.datetime(
int((orcid_start.get('year', {}) or {}).get('value', 1)),
int((orcid_start.get('month', {}) or {}).get('value', 1)),
int((orcid_start.get('day', {}) or {}).get('value', 1)),
tzinfo=tzinfo,
)
orcid_end = orcid_affiliation.get('end-date', {}) or {}
if orcid_end:
data['end'] = timezone.datetime(
int((orcid_end.get('year', {}) or {}).get('value', 1)),
int((orcid_end.get('month', {}) or {}).get('value', 1)),
int((orcid_end.get('day', {}) or {}).get('value', 1)),
Comment on lines +1063 to +1065
Copy link
Member

Choose a reason for hiding this comment

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

do we need to handle the case of explicit None like for the other cases? (e.g {'year': {'value': None}})

tzinfo=tzinfo,
)

super().__init__(data=data, *args, **kwargs)


class ConfirmDeleteForm(forms.Form):
"""
A generic form for use on confirm-delete pages
Expand Down
10 changes: 8 additions & 2 deletions src/core/include_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
re_path(r'^manager/user/(?P<user_id>\d+)/edit/$', core_views.user_edit, name='core_user_edit'),
re_path(r'^manager/user/(?P<user_id>\d+)/history/$', core_views.user_history, name='core_user_history'),

## Affiliations
# Affiliations
re_path(
r'^profile/organization/search/$',
core_views.OrganizationListView.as_view(),
Expand All @@ -155,6 +155,11 @@
core_views.affiliation_update,
name='core_affiliation_update'
),
re_path(
r'^profile/affiliation/update-from-orcid/(?P<how_many>primary|all)/$',
core_views.affiliation_update_from_orcid,
name='core_affiliation_update_from_orcid'
),
re_path(
r'^profile/affiliation/(?P<affiliation_id>\d+)/delete/$',
core_views.affiliation_delete,
Expand Down Expand Up @@ -322,7 +327,8 @@
"Failed to import urls for plugin %s: %s", plugin.name, error,
)
except Exception as error:
logger.error("Error loading plugin %s", block.name)
print("Error loading plugin %s", plugin.name)
logger.error("Error loading plugin %s", plugin.name)
logger.exception(error)

# load the notification plugins
Expand Down
21 changes: 17 additions & 4 deletions src/core/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from django.utils import timezone
from django.utils.translation import get_language, gettext_lazy as _

from core import models, files, plugin_installed_apps
from core import forms, models, files, plugin_installed_apps
from utils.function_cache import cache
from review import models as review_models
from utils import render_template, notify_helpers, setting_handler
Expand Down Expand Up @@ -293,9 +293,6 @@ def get_settings_to_edit(display_group, journal, user):
'object': setting_handler.get_setting('general', 'editors_for_notification', journal),
'choices': journal.editor_pks()
},
{'name': 'user_automatically_author',
'object': setting_handler.get_setting('general', 'user_automatically_author', journal),
},
{'name': 'submission_summary',
'object': setting_handler.get_setting('general', 'submission_summary', journal),
},
Expand Down Expand Up @@ -1032,3 +1029,19 @@ def filter_articles_to_editor_assigned(request, articles):
)
assignment_article_pks = [assignment.article.pk for assignment in assignments]
return articles.filter(pk__in=assignment_article_pks)


def create_organization_name(request):
form = forms.OrganizationNameForm(request.POST)
if form.is_valid():
organization_name = form.save()
organization = models.Organization.objects.create()
organization_name.custom_label_for = organization
organization_name.save()
messages.add_message(
request,
messages.SUCCESS,
_("Custom organization created: %(organization)s")
% {"organization": organization_name},
)
return organization_name
4 changes: 2 additions & 2 deletions src/core/migrations/0104_location_organization_affiliation.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(blank=True, max_length=300, verbose_name='Title, position, or role')),
('department', models.CharField(blank=True, max_length=300, verbose_name='Department, unit, or team')),
('is_primary', models.BooleanField(default=False, help_text='Each account can have one primary affiliation')),
('is_primary', models.BooleanField(default=False, help_text='Each author or user can have one primary affiliation')),
('start', models.DateField(blank=True, null=True, verbose_name='Start date')),
('end', models.DateField(blank=True, null=True, help_text='Leave empty for a current affiliation', verbose_name='End date')),
('account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
Expand All @@ -64,7 +64,7 @@ class Migration(migrations.Migration):
('preprint_author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='repository.preprintauthor')),
],
options={
'ordering': ['is_primary', '-pk'],
'ordering': ['-is_primary', '-pk'],
},
),
migrations.AddConstraint(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 4.2.20 on 2025-05-01 14:14

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('core', '0106_remove_account_country_affiliation_organization'),
]

operations = [
migrations.AlterModelOptions(
name='controlledaffiliation',
options={'ordering': ['-is_primary', '-end', '-start', '-pk']},
),
migrations.AlterModelOptions(
name='organization',
options={},
),
]
13 changes: 13 additions & 0 deletions src/core/model_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
__license__ = "AGPL v3"
__maintainer__ = "Birkbeck Centre for Technology and Publishing"
from contextlib import contextmanager
from hashlib import md5
from io import BytesIO
import re
import sys
Expand Down Expand Up @@ -912,3 +913,15 @@ def create(self, **kwargs):
def filter(self, *args, **kwargs):
kwargs = self._remap_old_affiliation_lookups(kwargs)
return super().filter(*args, **kwargs)


def generate_dummy_email(details):
"""
:param details: a dict whose keys and values will serve as the hash seed
:type details: dict
"""
seed = ''.join([str(key) + str(val) for key, val in details.items()])
hashed = md5(str(seed).encode("utf-8")).hexdigest()
# Avoid validation bug where two @@ symbols are used in the email
domain = settings.DUMMY_EMAIL_DOMAIN.replace('@', '')
return "{0}@{1}".format(hashed, domain)
Loading