From a4f4f4093203d6bfb9c6cb3ed4adc7ca944c66df Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 3 Dec 2024 15:17:37 +0000 Subject: [PATCH 01/76] WIP for Iowa State repo orgs. --- src/repository/admin.py | 13 ++ .../0046_repositoryorganisationunit.py | 28 +++ ...ositoryorganisationunit_parent_and_more.py | 24 +++ src/repository/models.py | 33 ++++ src/repository/urls.py | 5 + src/repository/views.py | 89 +++++++-- src/themes/OLH/templates/repository/home.html | 175 +++++++++++------- 7 files changed, 291 insertions(+), 76 deletions(-) create mode 100644 src/repository/migrations/0046_repositoryorganisationunit.py create mode 100644 src/repository/migrations/0047_repositoryorganisationunit_parent_and_more.py diff --git a/src/repository/admin.py b/src/repository/admin.py index 54810abbd7..52dd82ca41 100755 --- a/src/repository/admin.py +++ b/src/repository/admin.py @@ -335,6 +335,18 @@ class ReviewRecommendationAdmin(admin.ModelAdmin): search_fields = ("name",) +class RepositoryOrganisationUnitAdmin(admin.ModelAdmin): + list_display = ('name', 'code', 'repository', 'parent') + list_filter = ('repository__short_name',) + search_fields = ( + 'name', + 'code', + 'repository__name', + 'repository__short_name', + ) + raw_id_fields = ('repository', 'parent') + + admin_list = [ (models.Repository, RepositoryAdmin), (models.RepositoryRole, RepositoryRoleAdmin), @@ -352,6 +364,7 @@ class ReviewRecommendationAdmin(admin.ModelAdmin): (models.VersionQueue, VersionQueueAdmin), (models.Review, ReviewAdmin), (models.ReviewRecommendation, ReviewRecommendationAdmin), + (models.RepositoryOrganisationUnit, RepositoryOrganisationUnitAdmin), ] [admin.site.register(*t) for t in admin_list] diff --git a/src/repository/migrations/0046_repositoryorganisationunit.py b/src/repository/migrations/0046_repositoryorganisationunit.py new file mode 100644 index 0000000000..6d5f3c6dcf --- /dev/null +++ b/src/repository/migrations/0046_repositoryorganisationunit.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.15 on 2024-12-03 13:17 + +from django.db import migrations, models +import django.db.models.deletion +import django.db.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('repository', '0045_historicalrepository_display_public_metrics_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='RepositoryOrganisationUnit', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(verbose_name=django.db.models.fields.CharField)), + ('code', models.SlugField(help_text='A unique code within the repository for URL generation.')), + ('preprints', models.ManyToManyField(blank=True, help_text='Preprints associated with this organisational unit.', related_name='organisation_units', to='repository.preprint')), + ('repository', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='repository.repository')), + ], + options={ + 'unique_together': {('repository', 'code')}, + }, + ), + ] diff --git a/src/repository/migrations/0047_repositoryorganisationunit_parent_and_more.py b/src/repository/migrations/0047_repositoryorganisationunit_parent_and_more.py new file mode 100644 index 0000000000..551d52ac9e --- /dev/null +++ b/src/repository/migrations/0047_repositoryorganisationunit_parent_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.15 on 2024-12-03 14:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('repository', '0046_repositoryorganisationunit'), + ] + + operations = [ + migrations.AddField( + model_name='repositoryorganisationunit', + name='parent', + field=models.ForeignKey(blank=True, help_text='Parent organisational unit, or leave blank if this is a top-level unit.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='repository.repositoryorganisationunit'), + ), + migrations.AlterField( + model_name='repositoryorganisationunit', + name='name', + field=models.CharField(), + ), + ] diff --git a/src/repository/models.py b/src/repository/models.py index 5d70a1f272..8b8c6e5600 100755 --- a/src/repository/models.py +++ b/src/repository/models.py @@ -335,6 +335,39 @@ def best_large_image_url(self): return static(settings.HERO_IMAGE_FALLBACK) +class RepositoryOrganisationUnit(models.Model): + repository = models.ForeignKey( + Repository, + on_delete=models.CASCADE, + ) + name = models.CharField() + code = models.SlugField( + max_length=50, + help_text='A unique code within the repository for URL generation.', + ) + preprints = models.ManyToManyField( + 'repository.Preprint', + blank=True, + related_name='organisation_units', + help_text='Preprints associated with this organisational unit.', + ) + parent = models.ForeignKey( + 'self', + null=True, + blank=True, + on_delete=models.CASCADE, + related_name='children', + help_text='Parent organisational unit, or leave blank if this is ' + 'a top-level unit.', + ) + + def __str__(self): + return f'{self.repository.code}/{self.code} - {self.name}' + + class Meta: + unique_together = ('repository', 'code') + + class RepositoryRole(models.Model): repository = models.ForeignKey( Repository, diff --git a/src/repository/urls.py b/src/repository/urls.py index f8251dd254..30c5734160 100755 --- a/src/repository/urls.py +++ b/src/repository/urls.py @@ -275,4 +275,9 @@ views.send_user_email, name="send_user_email_preprint", ), + re_path( + r"^(?P[\w-]+)/$", + views.repository_home, + name="repository_home_by_rou", + ), ] diff --git a/src/repository/views.py b/src/repository/views.py index 82c7015219..9a3cb45bab 100644 --- a/src/repository/views.py +++ b/src/repository/views.py @@ -48,20 +48,45 @@ logger = logger.get_logger(__name__) -def repository_home(request): - """ - Displays the preprints home page with search box and 6 latest - preprints publications - :param request: HttpRequest object - :return: HttpResponse - """ - preprints = models.Preprint.objects.filter( - repository=request.repository, + +def repository_home( + request, + rou_code=None, +): + repository = request.repository + selected_rou = None + + if rou_code: + # Get the selected ROU + selected_rou = get_object_or_404( + models.RepositoryOrganisationUnit, + repository=repository, + code=rou_code, + ) + # Fetch children of the selected ROU + rous = selected_rou.children.all() + else: + # Fetch top-level ROUs + rous = models.RepositoryOrganisationUnit.objects.filter( + repository=repository, + parent__isnull=True, + ) + + preprints_query = models.Preprint.objects.filter( + repository=repository, date_published__lte=timezone.now(), stage=models.STAGE_PREPRINT_PUBLISHED, - ).order_by("-date_published")[:6] + ) + + if selected_rou: + preprints_query = preprints_query.filter( + organisation_units=selected_rou, + ) + + preprints = preprints_query.order_by("-date_published")[:6] + subjects = models.Subject.objects.filter( - repository=request.repository, + repository=repository, ).prefetch_related( "preprint_set", ) @@ -70,9 +95,16 @@ def repository_home(request): context = { "preprints": preprints, "subjects": subjects, + "rous": rous, + "selected_rou": selected_rou, } + return render( + request, + template, + context, + ) + - return render(request, template, context) def sitemap(request, subject_id=None): @@ -2539,3 +2571,36 @@ def manage_review_recommendation(request, recommendation_id=None): template, context, ) + + +def preprints_by_rou(request, rou_code): + # Get the ROU object + rou = get_object_or_404( + models.RepositoryOrganisationUnit, + repository=request.repository, + code=rou_code, + ) + + # Fetch preprints for the ROU + preprints = rou.preprints.all() + + # Pagination setup + page = request.GET.get('page', 1) # Default to page 1 + paginator = Paginator(preprints, 10) # Show 10 preprints per page + + try: + preprints_page = paginator.page(page) + except PageNotAnInteger: + preprints_page = paginator.page(1) + except EmptyPage: + preprints_page = paginator.page(paginator.num_pages) + + # Render the template + return render( + request, + 'repository/list.html', + { + 'preprints': preprints_page, + 'rou': rou, + }, + ) diff --git a/src/themes/OLH/templates/repository/home.html b/src/themes/OLH/templates/repository/home.html index 7c542e038e..c35ab753cc 100644 --- a/src/themes/OLH/templates/repository/home.html +++ b/src/themes/OLH/templates/repository/home.html @@ -1,83 +1,130 @@ {% extends "core/base.html" %} {% load i18n %} -{% block title %}{{ request.repository.name }}{% endblock %} +{% block title %} + {% if selected_rou %} + {{ selected_rou.name }} - {{ request.repository.name }} + {% else %} + {{ repository.name }} + {% endif %} +{% endblock %} {% block navbar %} {% include "repository/nav.html" %} {% endblock navbar %} {% block body %} +
+
+
+

+ {% if selected_rou %} + {{ selected_rou.name }} + {% else %} + {{ request.repository.name }} + {% endif %} +

+
+
+
+
+ {% csrf_token %} +
+ + +
+ +
+
+ {% if not selected_rou %} +

+ {% trans "Read about" %} + + {{ request.repository.name }} + + {% trans "or view list of" %} + + {{ request.repository.object_name_plural }} + . +

+ {% endif %} +
+ + {% if preprints %} +
+

+ {% if selected_rou %} + {% trans "Latest Preprints in" %} {{ selected_rou.name }} + {% else %} + {% trans "Latest Preprints" %} + {% endif %} +

+ {% include "repository/elements/preprint_home_listing.html" with preprints=preprints %} +
+ {% endif %} -
-
-
-

{{ request.repository.name }}

+ {% if rous %} +
+

+ {% if selected_rou %} + {% trans 'Select Subunit of' %} {{ selected_rou.name }} + {% else %} + {% trans 'Select Organisational Unit' %} + {% endif %} +

+ {% if rous|length <= 5 %} +
+
+ {% for rou in rous %} + {{ rou.name }} + {% endfor %}
-
-
-
- {% csrf_token %} -
- - - - -
- -
+
+ {% else %} +
+ {% for rou in rous %} + + {% if forloop.counter|divisibleby:2 %}
-

- {% trans "Read about" %} {{ request.repository.name }} - {% trans "or" %} - {% trans "view list of" %}{{ request.repository.object_name_plural }}. -

+
+ {% endif %} + {% endfor %}
+ {% endif %} +
+ {% endif %} - {% if preprints %} + {% if subjects %}
-

{% trans "Latest Preprints" %}

- {% include "repository/elements/preprint_home_listing.html" with preprints=preprints %} -
- {% endif %} - - {% if subjects %} -
-

{% trans 'Filter by Subject' %}

- {% if subjects|length <= 5 %} -
-
- -
-
- {% else %} -
+

{% trans 'Filter by Subject' %}

+ {% if subjects|length <= 5 %} +
+
{% for subject in subjects %} - - {% if forloop.counter|divisibleby:2 %} -
-
- {% endif %} + {{ subject.name }} {% endfor %} +
+
+ {% else %} +
+ {% for subject in subjects %} + - {% endif %} -
- {% endif %} -
-
- + {% if forloop.counter|divisibleby:2 %} +
+
+ {% endif %} + {% endfor %} +
+ {% endif %} + + {% endif %} + +
{% endblock %} From ebaaac5d6731037816bb5d219599f4d4e5ad2bc8 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 12 Mar 2025 17:01:56 +0000 Subject: [PATCH 02/76] WIP for repo ous --- ...toryorganisationunit_preprints_and_more.py | 23 ++ ...ove_preprint_organisation_unit_and_more.py | 22 ++ ...calrepository_rou_default_name_and_more.py | 34 +++ src/repository/models.py | 49 +++- src/repository/urls.py | 15 ++ src/repository/views.py | 117 +++++++-- src/themes/OLH/assets/scss/app.scss | 20 +- .../repository/elements/hierarchy_list.html | 27 ++ .../OLH/templates/repository/hierarchy.html | 243 ++++++++++++++++++ src/themes/OLH/templates/repository/home.html | 97 ++++--- src/themes/OLH/templates/repository/list.html | 243 +++++++++--------- .../OLH/templates/repository/preprint.html | 20 ++ 12 files changed, 729 insertions(+), 181 deletions(-) create mode 100644 src/repository/migrations/0048_remove_repositoryorganisationunit_preprints_and_more.py create mode 100644 src/repository/migrations/0049_remove_preprint_organisation_unit_and_more.py create mode 100644 src/repository/migrations/0050_historicalrepository_rou_default_name_and_more.py create mode 100644 src/themes/OLH/templates/repository/elements/hierarchy_list.html create mode 100644 src/themes/OLH/templates/repository/hierarchy.html diff --git a/src/repository/migrations/0048_remove_repositoryorganisationunit_preprints_and_more.py b/src/repository/migrations/0048_remove_repositoryorganisationunit_preprints_and_more.py new file mode 100644 index 0000000000..40898fb18b --- /dev/null +++ b/src/repository/migrations/0048_remove_repositoryorganisationunit_preprints_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2025-03-11 12:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('repository', '0047_repositoryorganisationunit_parent_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='repositoryorganisationunit', + name='preprints', + ), + migrations.AddField( + model_name='preprint', + name='organisation_unit', + field=models.OneToOneField(blank=True, help_text='Linked organization of this preprint.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='repository.repositoryorganisationunit'), + ), + ] diff --git a/src/repository/migrations/0049_remove_preprint_organisation_unit_and_more.py b/src/repository/migrations/0049_remove_preprint_organisation_unit_and_more.py new file mode 100644 index 0000000000..b8d36578af --- /dev/null +++ b/src/repository/migrations/0049_remove_preprint_organisation_unit_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.16 on 2025-03-11 15:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('repository', '0048_remove_repositoryorganisationunit_preprints_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='preprint', + name='organisation_unit', + ), + migrations.AddField( + model_name='preprint', + name='organisation_units', + field=models.ManyToManyField(blank=True, help_text='The organisational units this preprint belongs to.', related_name='preprints', to='repository.repositoryorganisationunit'), + ), + ] diff --git a/src/repository/migrations/0050_historicalrepository_rou_default_name_and_more.py b/src/repository/migrations/0050_historicalrepository_rou_default_name_and_more.py new file mode 100644 index 0000000000..f14a12e118 --- /dev/null +++ b/src/repository/migrations/0050_historicalrepository_rou_default_name_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.16 on 2025-03-12 10:53 + +import core.model_utils +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('repository', '0049_remove_preprint_organisation_unit_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='historicalrepository', + name='rou_default_name', + field=models.CharField(default='Organisational Units', help_text='Default name for the organisation structure within this repository.'), + ), + migrations.AddField( + model_name='historicalrepository', + name='rou_struct_page_text', + field=core.model_utils.JanewayBleachField(blank=True, default='

This page provides an overview of the organisational structure within {{ repository.name }}. You can navigate through the hierarchy to explore different units and their associated preprints.

', help_text='Text that displays on the organisational unit page.'), + ), + migrations.AddField( + model_name='repository', + name='rou_default_name', + field=models.CharField(default='Organisational Units', help_text='Default name for the organisation structure within this repository.'), + ), + migrations.AddField( + model_name='repository', + name='rou_struct_page_text', + field=core.model_utils.JanewayBleachField(blank=True, default='

This page provides an overview of the organisational structure within {{ repository.name }}. You can navigate through the hierarchy to explore different units and their associated preprints.

', help_text='Text that displays on the organisational unit page.'), + ), + ] diff --git a/src/repository/models.py b/src/repository/models.py index 8b8c6e5600..88ccc30256 100755 --- a/src/repository/models.py +++ b/src/repository/models.py @@ -18,6 +18,7 @@ from django.shortcuts import reverse from django.http.request import split_domain_port from django.templatetags.static import static +from django.template import Template, Context from simple_history.models import HistoricalRecords from core.file_system import JanewayFileSystemStorage @@ -268,6 +269,17 @@ class Repository(model_utils.AbstractSiteModel): display_public_metrics = models.BooleanField( default=False, help_text="Enable this setting to display metrics publicly." ) + rou_default_name = models.CharField( + default="Organisational Units", + help_text="Default name for the organisation structure within this repository.", + ) + rou_struct_page_text = model_utils.JanewayBleachField( + blank=True, + default="

This page provides an overview of the organisational structure " + "within {{ repository.name }}. You can navigate through the hierarchy " + "to explore different units and their associated preprints.

", + help_text="Text that displays on the organisational unit page.", + ) class Meta: verbose_name_plural = "repositories" @@ -334,6 +346,18 @@ def best_large_image_url(self): else: return static(settings.HERO_IMAGE_FALLBACK) + def render_setting(self, setting_text): + """ + Renders a repository setting string, replacing placeholders like + {{ repository.name }}. + """ + if not setting_text: + return "" + + template = Template(setting_text) + context = Context({"repository": self}) # Mimic request context + return template.render(context) + class RepositoryOrganisationUnit(models.Model): repository = models.ForeignKey( @@ -345,12 +369,6 @@ class RepositoryOrganisationUnit(models.Model): max_length=50, help_text='A unique code within the repository for URL generation.', ) - preprints = models.ManyToManyField( - 'repository.Preprint', - blank=True, - related_name='organisation_units', - help_text='Preprints associated with this organisational unit.', - ) parent = models.ForeignKey( 'self', null=True, @@ -367,6 +385,19 @@ def __str__(self): class Meta: unique_together = ('repository', 'code') + def get_descendants(self): + """Returns all descendant ROUs recursively.""" + descendants = list(self.children.all()) # Start with direct children + queue = list(descendants) + + while queue: + parent = queue.pop() + children = list(parent.children.all()) + descendants.extend(children) + queue.extend(children) + + return descendants + class RepositoryRole(models.Model): repository = models.ForeignKey( @@ -547,6 +578,12 @@ class Preprint(models.Model): on_delete=models.SET_NULL, help_text="Linked article of this preprint.", ) + organisation_units = models.ManyToManyField( + "repository.RepositoryOrganisationUnit", + blank=True, + related_name="preprints", + help_text="The organisational units this preprint belongs to.", + ) def __str__(self): return "{}".format( diff --git a/src/repository/urls.py b/src/repository/urls.py index 30c5734160..91f234a216 100755 --- a/src/repository/urls.py +++ b/src/repository/urls.py @@ -275,9 +275,24 @@ views.send_user_email, name="send_user_email_preprint", ), + re_path( + r"^hierarchy/(?P[\w-]+)/$", + views.rou_hierarchy_view, + name="rou_hierarchy", + ), + re_path( + r"^hierarchy/$", + views.rou_hierarchy_view, + name="rou_hierarchy", + ), re_path( r"^(?P[\w-]+)/$", views.repository_home, name="repository_home_by_rou", ), + re_path( + r"^(?P[\w-]+)/list$", + views.preprints_by_rou, + name="repository_preprints_by_rou", + ), ] diff --git a/src/repository/views.py b/src/repository/views.py index 9a3cb45bab..2ace312931 100644 --- a/src/repository/views.py +++ b/src/repository/views.py @@ -9,7 +9,7 @@ from django.shortcuts import render, redirect, get_object_or_404 from django.utils import timezone -from django.db.models import Q +from django.db.models import Q, Count from django.urls import reverse from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.contrib.auth.decorators import login_required @@ -55,6 +55,7 @@ def repository_home( ): repository = request.repository selected_rou = None + rous = [] if rou_code: # Get the selected ROU @@ -63,7 +64,9 @@ def repository_home( repository=repository, code=rou_code, ) - # Fetch children of the selected ROU + # Get all descendant ROUs + descendant_rous = selected_rou.get_descendants() + relevant_rous = [selected_rou] + descendant_rous rous = selected_rou.children.all() else: # Fetch top-level ROUs @@ -71,25 +74,28 @@ def repository_home( repository=repository, parent__isnull=True, ) + relevant_rous = [] + # Filter preprints, ensuring they belong to the repository and are published preprints_query = models.Preprint.objects.filter( repository=repository, date_published__lte=timezone.now(), stage=models.STAGE_PREPRINT_PUBLISHED, ) - if selected_rou: + if relevant_rous: + # Filter preprints that belong to the selected ROU or its sub-units preprints_query = preprints_query.filter( - organisation_units=selected_rou, - ) + organisation_units__in=relevant_rous, + ).distinct() + # Limit to latest 6 preprints preprints = preprints_query.order_by("-date_published")[:6] + # Fetch subjects related to the repository subjects = models.Subject.objects.filter( repository=repository, - ).prefetch_related( - "preprint_set", - ) + ).prefetch_related("preprint_set") template = "repository/home.html" context = { @@ -105,8 +111,6 @@ def repository_home( ) - - def sitemap(request, subject_id=None): """ :param request: HttpRequest object @@ -2574,19 +2578,29 @@ def manage_review_recommendation(request, recommendation_id=None): def preprints_by_rou(request, rou_code): - # Get the ROU object + # Get the selected ROU rou = get_object_or_404( models.RepositoryOrganisationUnit, repository=request.repository, code=rou_code, ) - # Fetch preprints for the ROU - preprints = rou.preprints.all() + # Get all descendant ROUs + descendant_rous = rou.get_descendants() + relevant_rous = [rou] + descendant_rous + + # Fetch preprints associated with the selected ROU and its sub-units + preprints = models.Preprint.objects.filter( + organisation_units__in=relevant_rous, + date_published__lte=timezone.now(), + stage=models.STAGE_PREPRINT_PUBLISHED, + ).distinct().order_by( + "-date_published", + ) # Pagination setup - page = request.GET.get('page', 1) # Default to page 1 paginator = Paginator(preprints, 10) # Show 10 preprints per page + page = request.GET.get("page", 1) # Default to page 1 try: preprints_page = paginator.page(page) @@ -2598,9 +2612,78 @@ def preprints_by_rou(request, rou_code): # Render the template return render( request, - 'repository/list.html', + "repository/list.html", { - 'preprints': preprints_page, - 'rou': rou, + "preprints": preprints_page, + "rou": rou, }, ) + + +def build_hierarchy(units): + """ Recursively builds a nested dictionary structure for hierarchy """ + hierarchy = [] + for unit in units: + children = unit.children.annotate(preprint_count=Count("preprints")) + latest_preprints = unit.preprints.order_by("-date_published")[:10] # Get latest 10 preprints + hierarchy.append( + { + "unit": unit, + "preprint_count": unit.preprints.count(), + "latest_preprints": latest_preprints, # Add latest preprints + "children": build_hierarchy(children), + } + ) + return hierarchy + +def rou_hierarchy_view(request, rou_code=None): + repository = request.repository + selected_rou = None + hierarchy = [] + + if rou_code: + # Get the selected ROU + selected_rou = get_object_or_404( + models.RepositoryOrganisationUnit.objects.annotate( + preprint_count=Count("preprints") + ), + repository=repository, + code=rou_code, + ) + # Fetch the latest 10 preprints for the selected ROU + selected_rou.latest_preprints = selected_rou.preprints.order_by( + "-date_published", + )[:10] + + # Build hierarchy from the **top level down** + top_level_units = models.RepositoryOrganisationUnit.objects.filter( + repository=repository, + parent__isnull=True, + ).annotate(preprint_count=Count("preprints")) + + hierarchy = build_hierarchy(top_level_units) + + else: + # No ROU selected – Show **all top-level ROUs and their full hierarchy** + top_level_units = models.RepositoryOrganisationUnit.objects.filter( + repository=repository, + parent__isnull=True, + ).annotate(preprint_count=Count("preprints")) + + hierarchy = build_hierarchy(top_level_units) + + return render( + request, + "repository/hierarchy.html", + { + "rou": selected_rou, + "repository": repository, + "hierarchy": hierarchy, + "recent_preprints": models.Preprint.objects.filter( + repository=request.repository, + date_published__lte=timezone.now(), + ).order_by('-date_published')[:10], + "page_text": repository.render_setting(repository.rou_struct_page_text), + }, + ) + diff --git a/src/themes/OLH/assets/scss/app.scss b/src/themes/OLH/assets/scss/app.scss index b5d5a40618..5c952f9eb8 100755 --- a/src/themes/OLH/assets/scss/app.scss +++ b/src/themes/OLH/assets/scss/app.scss @@ -1652,4 +1652,22 @@ input[type="submit"]:focus-visible { box-shadow: 0 0 0 var(--focus-ring-size) var(--focus-dark); } -/* end of WCAG 2.4.7 Focus Visible - Two-Color Focus Indicator (Technique C40) */ \ No newline at end of file +/* end of WCAG 2.4.7 Focus Visible - Two-Color Focus Indicator (Technique C40) */ + +.preprint-section { + margin-top: 0.5rem; + padding: 0.5rem; + background-color: #f8f9fa; /* Light grey background to separate preprints */ + border-left: 3px solid #007bff; /* Blue left border for emphasis */ +} + +.preprint-list { + list-style-type: disc; /* Ensure preprints have bullet points */ + padding-left: 1.5rem; /* Proper indentation */ + font-size: 0.9rem; /* Slightly smaller font for preprints */ +} + +.view-all-preprints { + margin-top: 0.5rem; + font-weight: bold; +} diff --git a/src/themes/OLH/templates/repository/elements/hierarchy_list.html b/src/themes/OLH/templates/repository/elements/hierarchy_list.html new file mode 100644 index 0000000000..3be539c165 --- /dev/null +++ b/src/themes/OLH/templates/repository/elements/hierarchy_list.html @@ -0,0 +1,27 @@ +{% load i18n %} + +{% for item in hierarchy %} +
  • +
    + + + + + {% if rou and item.unit == rou %} + {{ item.unit.name }} + {% else %} + {{ item.unit.name }} + {% endif %} + + + ({{ item.preprint_count }} preprint{% if item.preprint_count != 1 %}s{% endif %}) + +
    + + {% if item.children %} +
      + {% include "repository/elements/hierarchy_list.html" with hierarchy=item.children %} +
    + {% endif %} +
  • +{% endfor %} \ No newline at end of file diff --git a/src/themes/OLH/templates/repository/hierarchy.html b/src/themes/OLH/templates/repository/hierarchy.html new file mode 100644 index 0000000000..de030b6748 --- /dev/null +++ b/src/themes/OLH/templates/repository/hierarchy.html @@ -0,0 +1,243 @@ +{% extends "core/base.html" %} +{% load static %} +{% load i18n %} + +{% block title %} + {% if rou %} + {{ rou.name }} Hierarchy - {{ request.repository.name }} + {% else %} + {% trans "Organisational Units" %} - {{ request.repository.name }} + {% endif %} +{% endblock %} + +{% block head %} +{{ block.super }} + +{% endblock %} + +{% block body %} +
    +
    +
    +

    {{ request.repository.rou_default_name|safe }}

    + +
    + {{ page_text|safe }} +
    + +
    +
    +
    +

    {% trans "Organisational Structure" %}

    + + +
    +
    + +
    + {% if rou %} +
    +
    +

    + {{ rou.name }} + + ({{ rou.preprint_count }} preprint{% if rou.preprint_count != 1 %}s{% endif %}) + +

    +
    + + {% if rou.latest_preprints %} +

    {% trans "Latest Preprints" %}

    + {% if rou.preprint_count > 10 %} + + {% trans "View all preprints" %} + + {% endif %} + +
    + {% for preprint in rou.latest_preprints %} +
    + {{ preprint.title }} +
    + {{ preprint.display_authors_compact }} + + {{ preprint.date_published|date:"Y-m-d" }} +
    + {% if preprint.abstract %} +

    {{ preprint.abstract|truncatewords_html:30 }}

    + {% endif %} +
    + {% endfor %} +
    + + {% endif %} +
    + {% else %} +
    +

    {% trans "Repository Overview" %}

    +

    {% trans "Select an organizational unit from the hierarchy to view its details and preprints." %}

    + {% if recent_preprints %} +
    +
    +

    {% trans "Recent Preprints" %}

    + + {% trans "View all preprints" %}  + +
    + +
    + {% for preprint in recent_preprints %} +
    + {{ preprint.title }} +
    + {{ preprint.display_authors_compact }} + + {{ preprint.date_published|date:"Y-m-d" }} +
    +
    + {% endfor %} +
    +
    + {% endif %} +
    + {% endif %} +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/src/themes/OLH/templates/repository/home.html b/src/themes/OLH/templates/repository/home.html index c35ab753cc..6fee25f5ef 100644 --- a/src/themes/OLH/templates/repository/home.html +++ b/src/themes/OLH/templates/repository/home.html @@ -16,15 +16,35 @@ {% block body %}
    + {% if not selected_rou %}

    - {% if selected_rou %} - {{ selected_rou.name }} - {% else %} - {{ request.repository.name }} - {% endif %} + {{ request.repository.name }}

    + {% endif %} + + {% if selected_rou %} + + {% endif %} + + {% if not selected_rou %}
    @@ -50,56 +70,53 @@

    {% endif %}

    + {% endif %} {% if preprints %}

    - {% if selected_rou %} - {% trans "Latest Preprints in" %} {{ selected_rou.name }} - {% else %} - {% trans "Latest Preprints" %} - {% endif %} + {% trans "Latest" %} {{ request.repository.object_name_plural }}

    {% include "repository/elements/preprint_home_listing.html" with preprints=preprints %}
    {% endif %} {% if rous %} -
    -

    - {% if selected_rou %} - {% trans 'Select Subunit of' %} {{ selected_rou.name }} +
    +

    + {% if selected_rou %} + {% trans 'Select Sub-unit of' %} {{ selected_rou.name }} + {% else %} + {% trans 'Select Organisational Unit' %} + {% endif %} +

    + {% if rous|length <= 5 %} +
    +
    + {% for rou in rous %} + {{ rou.name }} + {% endfor %} +
    +
    {% else %} - {% trans 'Select Organisational Unit' %} +
    + {% for rou in rous %} + + {% if forloop.counter|divisibleby:2 %} +
    +
    + {% endif %} + {% endfor %} +
    {% endif %} -

    - {% if rous|length <= 5 %} -
    -
    - {% for rou in rous %} - {{ rou.name }} - {% endfor %} -
    - {% else %} -
    - {% for rou in rous %} - - {% if forloop.counter|divisibleby:2 %} -
    -
    - {% endif %} - {% endfor %} -
    - {% endif %} -
    {% endif %} - {% if subjects %} + {% if subjects and not selected_rou %}

    {% trans 'Filter by Subject' %}

    {% if subjects|length <= 5 %} diff --git a/src/themes/OLH/templates/repository/list.html b/src/themes/OLH/templates/repository/list.html index 6abf2a711d..e456f4d919 100644 --- a/src/themes/OLH/templates/repository/list.html +++ b/src/themes/OLH/templates/repository/list.html @@ -2,131 +2,140 @@ {% load static %} {% load i18n %} {% load truncate %} -{% load dates %} - - -{% block title %}{% if subject %}{{ subject.name }} {{ request.repository.object_name_plural }}{% else %}{% trans "All" %} {{ request.repository.object_name_plural }} -{% endif %}{% endblock %} +{% block title %} + {% if rou %} + {{ rou.name }} - {{ request.repository.name }} + {% elif subject %} + {{ subject.name }} {{ request.repository.object_name_plural }} + {% else %} + {% trans "All" %} {{ request.repository.object_name_plural }} + {% endif %} +{% endblock %} {% block body %} +
    +
    +
    +
    +

    + {% if rou %} + {{ rou.name }} {{ request.repository.object_name_plural }} + {% else %} + {{ request.repository.object_name_plural }} + {% endif %} +

    +

    + {% if search_term %} + Search for {{ search_term }} ({{ preprints.paginator.count }} results) + {% elif subject %} + Filtering by Subject: {{ subject }} + {% endif %} +

    +

    + There {% if preprints.paginator.count == 1 %}is{% else %}are{% endif %} + {{ preprints.paginator.count }} + {% if preprints.paginator.count == 1 %} + {{ request.repository.object_name }} + {% else %} + {{ request.repository.object_name_plural }} + {% endif %} + listed{% if rou and rou.children.exists %}, including those from sub-units of {{ rou.name }}{% endif %}. +

    -
    -
    -
    -
    -

    {{ request.repository.object_name_plural }}

    -

    - {% if search_term %} - Search for {{ search_term }} ({{ preprints.paginator.count }} results) - {% elif subject %} - Filtering by Subject: {{ subject }} - {% else %} - There {% if preprints.paginator.count > 1 %}are {{ preprints.paginator.count }} {{ request.repository.object_name_plural }} listed.{% elif preprints.paginator.count == 1 %}is 1 {{ request.repository.object_name }}{% else %}are 0 {{ request.repository.object_name }} listed.{% endif %} - {% endif %} -

    -
    -
    - {% for preprint in preprints %} -
    - - - - +
    + {% for preprint in preprints %} +
    + +
    +
    +

    {{ preprint.title|safe }}

    +

    {{ preprint.display_authors_compact }}

    +

    + + + {{ preprint.date_published|date:"Y-m-d" }}   + + {% include "common/repository/subject_display.html" %}  + + {% for rou in preprint.organisation_units.all %} + + {{ rou.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} + +

    +

    {{ preprint.abstract|striptags|truncatesmart:400 }}

    +
    +
    +
    + {% endfor %} +
    +
      + {% if preprints.has_previous %} +
    • «
    • + {% endif %} + {% for page in preprints.paginator.page_range %} +
    • + {{ page }} +
    • {% endfor %} -
    -
    -
    - +
    +
    +
    -
    - -
    - -
    - -{% endblock body %} + {% endif %} +
    + +
    +
    +{% endblock %} diff --git a/src/themes/OLH/templates/repository/preprint.html b/src/themes/OLH/templates/repository/preprint.html index 90bf51cc16..13ed4a0257 100644 --- a/src/themes/OLH/templates/repository/preprint.html +++ b/src/themes/OLH/templates/repository/preprint.html @@ -123,6 +123,26 @@

    {% trans "Downloads" %}

    {% endif %} + {% with preprint.organisation_units.all as rous %} + {% if rous %} +

    + {% if rous.count > 1 %} + {% trans "Units" %} + {% else %} + {% trans "Unit" %} + {% endif %} +

    + + {% endif %} + {% endwith %}

    {% trans "Metadata" %}

    • {% trans "Published" %}: {{ preprint.date_published|date_human }}
    • From a0b7d1d1f51116fe1dee3da825090945d2afddf9 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 5 Jun 2025 10:11:07 +0100 Subject: [PATCH 03/76] feat(repo): wip for multiple object types in repositories. --- requirements.txt | 1 + src/repository/admin.py | 29 +- src/repository/forms.py | 65 +++ ...submissiontype_preprint_submission_type.py | 55 +++ src/repository/models.py | 230 ++++++++- src/repository/urls.py | 22 +- src/repository/views.py | 210 ++++---- .../elements/repository/metadata_form.html | 3 + src/templates/admin/press/nav.html | 1 + src/templates/admin/repository/article.html | 12 +- src/templates/admin/repository/dashboard.html | 4 +- src/templates/admin/repository/manager.html | 8 +- .../repository/submission_type_form.html | 61 +++ .../repository/submission_type_list.html | 81 ++++ .../admin/repository/submit/review.html | 2 +- .../admin/repository/submit/start.html | 5 +- .../style/submission_type_pills.html | 23 + src/themes/OLH/assets/scss/app.scss | 12 + .../repository/elements/hierarchy_list.html | 2 +- .../elements/preprint_home_listing.html | 39 +- .../elements/submission_type_pill.html | 4 + .../OLH/templates/repository/hierarchy.html | 453 ++++++++++-------- src/themes/OLH/templates/repository/home.html | 27 +- src/themes/OLH/templates/repository/list.html | 95 ++-- .../OLH/templates/repository/preprint.html | 34 +- 25 files changed, 1067 insertions(+), 411 deletions(-) create mode 100644 src/repository/migrations/0051_repositorysubmissiontype_preprint_submission_type.py create mode 100644 src/templates/admin/repository/submission_type_form.html create mode 100644 src/templates/admin/repository/submission_type_list.html create mode 100644 src/templates/common/repository/style/submission_type_pills.html create mode 100644 src/themes/OLH/templates/repository/elements/submission_type_pill.html diff --git a/requirements.txt b/requirements.txt index 957f884dbb..15445ccd72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,6 +42,7 @@ markdown maxminddb==2.5.1 mock mozilla-django-oidc==4.0.1 +openpyxl==3.1.5 orcid==1.0.3 packaging==23.2 pdfkit==1.0.0 diff --git a/src/repository/admin.py b/src/repository/admin.py index 52dd82ca41..eaae4e9df2 100755 --- a/src/repository/admin.py +++ b/src/repository/admin.py @@ -19,7 +19,12 @@ class RepositoryAdmin(SimpleHistoryAdmin): "short_name", "name", ) - raw_id_fields = ("managers", "homepage_preprints", "active_licenses") + raw_id_fields = ( + "managers", + "homepage_preprints", + "active_licenses", + "submission_notification_recipients", + ) inlines = [ admin_utils.RepositoryRoleInline, @@ -101,6 +106,7 @@ class PreprintAdmin(admin.ModelAdmin): "article", "submission_file", "license", + "submission_type", ) search_fields = ( "pk", @@ -347,6 +353,26 @@ class RepositoryOrganisationUnitAdmin(admin.ModelAdmin): raw_id_fields = ('repository', 'parent') +class RepositorySubmissionTypeAdmin(admin.ModelAdmin): + list_display = ( + 'name', + 'slug', + 'repository', + 'pill_colour', + ) + list_filter = ( + 'repository', + ) + search_fields = ( + 'name', + 'slug', + 'repository__name', + ) + prepopulated_fields = { + 'slug': ('name',), + } + + admin_list = [ (models.Repository, RepositoryAdmin), (models.RepositoryRole, RepositoryRoleAdmin), @@ -365,6 +391,7 @@ class RepositoryOrganisationUnitAdmin(admin.ModelAdmin): (models.Review, ReviewAdmin), (models.ReviewRecommendation, ReviewRecommendationAdmin), (models.RepositoryOrganisationUnit, RepositoryOrganisationUnitAdmin), + (models.RepositorySubmissionType, RepositorySubmissionTypeAdmin), ] [admin.site.register(*t) for t in admin_list] diff --git a/src/repository/forms.py b/src/repository/forms.py index e38fcee06b..e0509f3612 100755 --- a/src/repository/forms.py +++ b/src/repository/forms.py @@ -33,6 +33,7 @@ class Meta: model = models.Preprint fields = ( "title", + "submission_type", "abstract", "license", "comments_editor", @@ -65,6 +66,10 @@ def __init__(self, *args, **kwargs): enabled=True, repository=self.request.repository, ) + self.fields['submission_type'].queryset = models.RepositorySubmissionType.objects.filter( + repository=self.request.repository, + ) + if self.admin: self.fields["license"].queryset = submission_models.Licence.objects.filter( journal__isnull=True, @@ -761,3 +766,63 @@ def save(self, commit=True): recommendation.save() return recommendation + + +class PreprintFilterForm(forms.Form): + subject = forms.ModelChoiceField( + queryset=models.Subject.objects.none(), + required=False, + label="Subject", + empty_label="— All Subjects —", + widget=forms.Select(attrs={"class": "full-width"}), + ) + + submission_type = forms.ModelChoiceField( + queryset=models.RepositorySubmissionType.objects.none(), + required=False, + label="Submission Type", + empty_label="— All Types —", + widget=forms.Select(attrs={"class": "full-width"}), + ) + + search_term = forms.CharField( + required=False, + label="Search", + widget=forms.TextInput(attrs={ + "placeholder": "Search preprints", + }), + ) + + def __init__(self, *args, repository=None, **kwargs): + super().__init__(*args, **kwargs) + if repository: + self.fields['subject'].queryset = models.Subject.objects.filter( + repository=repository, + enabled=True, + ) + self.fields['submission_type'].queryset = models.RepositorySubmissionType.objects.filter( + repository=repository, + ) + + +class RepositorySubmissionTypeForm(forms.ModelForm): + class Meta: + model = models.RepositorySubmissionType + fields = [ + "name", + "name_plural", + "slug", + "pill_colour", + ] + help_texts = { + "slug": ( + "A URL-safe identifier for this type (e.g. 'preprint'). " + "Used in HTML classes and API filters. Must be unique within this site." + ), + "pill_colour": ( + "Hex colour code used to style the label (e.g. #1e40af)." + ), + } + widgets = { + "pill_colour": forms.TextInput(attrs={"placeholder": "#1e40af"}), + } \ No newline at end of file diff --git a/src/repository/migrations/0051_repositorysubmissiontype_preprint_submission_type.py b/src/repository/migrations/0051_repositorysubmissiontype_preprint_submission_type.py new file mode 100644 index 0000000000..20d68a491c --- /dev/null +++ b/src/repository/migrations/0051_repositorysubmissiontype_preprint_submission_type.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.20 on 2025-06-05 09:08 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +def create_preprint_submission_type(apps, schema_editor): + RepositorySubmissionType = apps.get_model('repository', 'RepositorySubmissionType') + Preprint = apps.get_model('repository', 'Preprint') + Repository = apps.get_model('repository', 'Repository') + + for repo in Repository.objects.all(): + submission_type, _ = RepositorySubmissionType.objects.get_or_create( + repository=repo, + slug='preprint', + defaults={ + 'name': 'Preprint', + 'name_plural': 'Preprints', + }, + ) + Preprint.objects.filter(repository=repo).update(submission_type=submission_type) + + +class Migration(migrations.Migration): + + dependencies = [ + ('repository', '0050_historicalrepository_rou_default_name_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='RepositorySubmissionType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('name_plural', models.CharField(max_length=100)), + ('slug', models.SlugField(max_length=255, unique=True)), + ('pill_colour', models.CharField(default='#1e40af', help_text='Hex colour code for the pill border and text (e.g. #1e40af)', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a valid hex colour code (e.g. #1e40af or #fff).', regex='^#(?:[0-9a-fA-F]{3}){1,2}$')])), + ('repository', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='object_types', to='repository.repository')), + ], + options={ + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='preprint', + name='submission_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='repository.repositorysubmissiontype'), + ), + migrations.RunPython( + create_preprint_submission_type, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/src/repository/models.py b/src/repository/models.py index 88ccc30256..64b8ff92a8 100755 --- a/src/repository/models.py +++ b/src/repository/models.py @@ -6,8 +6,9 @@ import os import uuid import json -from dateutil import parser as dateparser +import csv +from django.apps import apps from django.db import models from django.db.models import Q from django.utils import timezone @@ -19,7 +20,12 @@ from django.http.request import split_domain_port from django.templatetags.static import static from django.template import Template, Context +from django.utils.html import format_html +from django.core.validators import RegexValidator +from django.core.exceptions import ValidationError + from simple_history.models import HistoricalRecords +from openpyxl import load_workbook from core.file_system import JanewayFileSystemStorage from core import model_utils, files, models as core_models @@ -490,6 +496,11 @@ class Preprint(models.Model): null=True, on_delete=models.SET_NULL, ) + submission_type = models.ForeignKey( + 'RepositorySubmissionType', + null=True, + on_delete=models.SET_NULL, + ) owner = models.ForeignKey( "core.Account", null=True, @@ -590,6 +601,15 @@ def __str__(self): self.title, ) + def clean(self): + super().clean() + if self.submission_type and self.submission_type.repository != self.repository: + raise ValidationError({ + 'submission_type': _( + "Submission type must belong to the same repository as the preprint." + ) + }) + def old_versions(self): return PreprintVersion.objects.filter( preprint=self, @@ -812,6 +832,14 @@ def local_url(self): return url + def get_linked_books(self): + try: + from plugins.books.models import Book + except (ImportError, LookupError): + return [] + + return Book.objects.filter(linked_repository_objects=self) + def create_article( self, journal, workflow_stage, journal_license, journal_section, force=False ): @@ -853,6 +881,33 @@ def create_article( return None +class RepositorySubmissionType(models.Model): + repository = models.ForeignKey( + Repository, + on_delete=models.CASCADE, + related_name='object_types', + ) + name = models.CharField(max_length=100) + name_plural = models.CharField(max_length=100) + slug = models.SlugField(max_length=255, unique=True) + pill_colour = models.CharField( + max_length=7, + default="#1e40af", + validators=[ + RegexValidator( + regex=r"^#(?:[0-9a-fA-F]{3}){1,2}$", + message="Enter a valid hex colour code (e.g. #1e40af or #fff).", + ), + ], + help_text="Hex colour code for the pill border and text (e.g. #1e40af)", + ) + + class Meta: + ordering = ('name',) + + def __str__(self): + return self.name + class KeywordPreprint(models.Model): keyword = models.ForeignKey( "submission.Keyword", @@ -1140,6 +1195,179 @@ class PreprintVersion(models.Model): class Meta: ordering = ("-version", "-date_time", "-id") + def render(self): + """ + Render the file associated with this version as HTML, if possible. + Supports: PDF, HTML, images, plain text. + """ + if not self.file or not self.file.file: + return "" + + file_path = self.file.file.path + if not os.path.exists(file_path): + return "" + + mime_type = self.file.mime_type or files.guess_mime(self.file.file.url)[0] + if not mime_type: + return "" + + if mime_type == "application/pdf": + return self.render_pdf() + elif mime_type == "text/html": + return self.render_html() + elif mime_type.startswith("image/"): + return self.render_image() + elif mime_type.endswith("csv"): + return self.render_csv() + elif mime_type.startswith("text/"): + return self.render_text() + elif mime_type in ( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-excel" + ): + return self.render_excel() + + return format_html( + '

      Preview not available for this file type: {}

      ', + mime_type, + ) + + def render_pdf(self): + pdf_view_url = reverse( + 'repository_pdf', + kwargs={'preprint_id': self.preprint.pk}, + ) + download_url = reverse( + 'repository_file_download', + kwargs={ + 'preprint_id': self.preprint.pk, + 'file_id': self.file.pk, + }, + ) + return format_html( + '', + pdf_view_url, + download_url, + ) + + def render_image(self): + download_url = reverse( + 'repository_file_download', + kwargs={ + 'preprint_id': self.preprint.pk, + 'file_id': self.file.pk, + }, + ) + return format_html( + 'Preprint image', + download_url, + ) + + def render_text(self): + try: + with open(self.file.file.path, 'r', encoding='utf-8') as f: + content = f.read() + return format_html("
      {}
      ", content) + except Exception: + return "

      Unable to render text content.

      " + + def render_html(self): + return self.html() + + def render_csv(self, max_rows=10, max_cols=5): + """ + Render the first `max_rows` and `max_cols` of a CSV file as an HTML table, + with truncation notices if applicable. + """ + try: + with open(self.file.file.path, newline='', encoding='utf-8') as f: + reader = csv.reader(f) + rows = list(reader) + + if not rows: + return "

      CSV file is empty.

      " + + header = rows[0] + total_cols = len(header) + total_rows = len(rows) - 1 + + display_cols = header[:max_cols] + data_rows = [row[:max_cols] for row in rows[1:max_rows + 1]] + + table = [''] + table.append("{}".format( + "".join(f"" for col in display_cols) + )) + table.append("") + for row in data_rows: + table.append("{}".format( + "".join(f"" for cell in row) + )) + table.append("
      {col}
      {cell}
      ") + + messages = [] + if total_rows > max_rows: + messages.append(f"Showing first {max_rows} of {total_rows} rows.") + if total_cols > max_cols: + messages.append(f"Showing first {max_cols} of {total_cols} columns.") + + if messages: + table.append(f"

      {' '.join(messages)}

      ") + + return format_html("".join(table)) + + except Exception as e: + return format_html("

      Error rendering CSV: {}

      ", str(e)) + + def render_excel(self, max_rows=10, max_cols=5): + """ + Render the first `max_rows` and `max_cols` of the first worksheet in an Excel file + as an HTML table, with truncation messages if applicable. + """ + try: + wb = load_workbook(filename=self.file.file.path, read_only=True) + sheet = wb.active + + rows = list(sheet.iter_rows(values_only=True)) + if not rows: + return "

      Excel file is empty.

      " + + header = rows[0] + total_cols = len(header) + total_rows = len(rows) - 1 + + display_cols = header[:max_cols] + data_rows = [row[:max_cols] for row in rows[1:max_rows + 1]] + + table = [''] + table.append("{}".format( + "".join(f"" for col in display_cols) + )) + table.append("") + for row in data_rows: + table.append("{}".format( + "".join( + f"" for cell in row) + )) + table.append("
      {col}
      {cell if cell is not None else ''}
      ") + + messages = [] + if total_rows > max_rows: + messages.append(f"Showing first {max_rows} of {total_rows} rows.") + if total_cols > max_cols: + messages.append(f"Showing first {max_cols} of {total_cols} columns.") + + if messages: + table.append(f"

      {' '.join(messages)}

      ") + + return format_html("".join(table)) + + except Exception as e: + return format_html("

      Error rendering Excel file: {}

      ", str(e)) + + + def html(self): if self.file.mime_type in files.HTML_MIMETYPES: return self.file.contents() diff --git a/src/repository/urls.py b/src/repository/urls.py index 91f234a216..1b7c629e30 100755 --- a/src/repository/urls.py +++ b/src/repository/urls.py @@ -48,7 +48,7 @@ ), re_path( r"^list/(?P\d+)/$", - views.repository_list, + views.redirect_old_subject, name="repository_list_subject", ), re_path(r"^editors/$", views.preprints_editors, name="preprints_editors"), @@ -114,6 +114,26 @@ views.delete_preprint_author, name="repository_manager_delete_author", ), + re_path( + r"^manager/submission-types/$", + views.submission_type_list, + name="submission_type_list", + ), + re_path( + r"^manager/submission-types/create/$", + views.edit_submission_type, + name="create_submission_type", + ), + re_path( + r"^manager/submission-types/(?P\d+)/edit/$", + views.edit_submission_type, + name="edit_submission_type", + ), + re_path( + r"^manager/submission-types/(?P\d+)/delete/$", + views.delete_submission_type, + name="delete_submission_type", + ), # Review re_path( r"^manager/reviewers/$", diff --git a/src/repository/views.py b/src/repository/views.py index 2ace312931..c53cbfc87f 100644 --- a/src/repository/views.py +++ b/src/repository/views.py @@ -19,6 +19,8 @@ from django.http import HttpResponse, Http404 from django.core.exceptions import PermissionDenied from django.utils.translation import gettext_lazy as _ +from django.utils.http import urlencode + from repository import forms, logic as repository_logic, models from core import ( @@ -332,107 +334,54 @@ def repository_subject_list(request): return render(request, template, context) -def repository_list(request, subject_id=None): +def redirect_old_subject(request, subject_id): """ - Displays a list of all published preprints. - :param request: HttpRequest - :return: HttpResponse + Redirects our old url pattern to the new query string filtering. """ - if subject_id: - subject = get_object_or_404( - models.Subject, - pk=subject_id, - repository=request.repository, - ) - preprints = subject.preprint_set.filter( - repository=request.repository, - date_published__lte=timezone.now(), - ).order_by("-date_published") - else: - subject = None - preprints = models.Preprint.objects.filter( - date_published__lte=timezone.now(), - repository=request.repository, - ).order_by("-date_published") - - paginator = Paginator(preprints, 15) - page = request.GET.get("page", 1) - - try: - preprints = paginator.page(page) - except PageNotAnInteger: - preprints = paginator.page(1) - except EmptyPage: - preprints = paginator.page(paginator.num_pages) - - template = "repository/list.html" - context = { - "preprints": preprints, - "subject": subject, - "subjects": models.Subject.objects.filter(enabled=True), - } - - return render(request, template, context) + return redirect( + f"{reverse('repository_list')}?subject={subject_id}", + permanent=True, + ) -def repository_search(request, search_term=None): - """ - Searches for and displays a list of Preprints. - """ - if request.POST and "search_term" in request.POST: - search_term = request.POST.get("search_term") - return redirect( - reverse( - "repository_search_with_term", - kwargs={"search_term": search_term}, - ) - ) +def repository_list(request): + form = forms.PreprintFilterForm(request.GET, repository=request.repository) - # Grab all of the preprints that are published. We can then filter them - # if a search term is given or return them if none. preprints = models.Preprint.objects.filter( date_published__lte=timezone.now(), repository=request.repository, - ) + ).select_related('submission_type') \ + .prefetch_related('organisation_units') - if search_term: - search_term = search_term.strip() - split_search_term = [term.strip() for term in search_term.split(" ") if term] - - # Initial filter on Title, Abstract and Keywords. - preprint_search = preprints.filter( - ( - Q(title__icontains=search_term) - | Q(abstract__icontains=search_term) - | Q(keywords__word__in=split_search_term) - ) - ) + if form.is_valid(): + subject = form.cleaned_data.get('subject') + submission_type = form.cleaned_data.get('submission_type') + search_term = form.cleaned_data.get('search_term', '').strip() - from_author = models.PreprintAuthor.objects.filter( - ( - Q(account__first_name__in=split_search_term) - | Q(account__middle_name__in=split_search_term) - | Q(account__last_name__in=split_search_term) - | Q( - account__affiliation__organization__labels__value__icontains=search_term - ) - ) - & (Q(preprint__repository=request.repository)) - ) + if subject: + preprints = preprints.filter(subject=subject) - preprints_from_author = [ - pa.preprint - for pa in models.PreprintAuthor.objects.filter( - pk__in=from_author, - preprint__date_published__lte=timezone.now(), - ) - ] + if submission_type: + preprints = preprints.filter(submission_type=submission_type) + + if search_term: + split_terms = [term for term in search_term.split() if term] + + keyword_filter = Q(keywords__word__in=split_terms) + title_abstract_filter = Q(title__icontains=search_term) | Q( + abstract__icontains=search_term) - preprints = list(set(list(preprint_search) + preprints_from_author)) - preprints.sort(key=operator.attrgetter("date_published"), reverse=True) + author_filter = Q(preprintauthor__account__first_name__in=split_terms) | \ + Q(preprintauthor__account__middle_name__in=split_terms) | \ + Q(preprintauthor__account__last_name__in=split_terms) | \ + Q(preprintauthor__account__institution__icontains=search_term) - paginator = Paginator(preprints, 15) - page = request.GET.get("page", 1) + preprints = preprints.filter( + title_abstract_filter | keyword_filter | author_filter + ).distinct() + + paginator = Paginator(preprints.order_by('-date_published'), 15) + page = request.GET.get('page', 1) try: preprints = paginator.page(page) @@ -441,13 +390,27 @@ def repository_search(request, search_term=None): except EmptyPage: preprints = paginator.page(paginator.num_pages) - template = "repository/list.html" - context = { - "search_term": search_term, - "preprints": preprints, - } + return render(request, 'repository/list.html', { + 'preprints': preprints, + 'form': form, + 'subject': form.cleaned_data.get('subject') if form.is_valid() else None, + 'submission_type': form.cleaned_data.get('submission_type') if form.is_valid() else None, + }) - return render(request, template, context) + +def repository_search(request, search_term=None): + """ + Redirects legacy search URL with optional search_term to the + main repository list view. + """ + if request.method == "POST": + search_term = request.POST.get('search_term', '').strip() + + query = {} + if search_term: + query['search_term'] = search_term + + return redirect(f"{reverse('repository_list')}?{urlencode(query)}") def repository_preprint(request, preprint_id): @@ -2687,3 +2650,62 @@ def rou_hierarchy_view(request, rou_code=None): }, ) + +@is_repository_manager +def submission_type_list(request): + types = (models.RepositorySubmissionType.objects.filter( + repository=request.repository, + ).annotate( + preprint_count=Count('preprint') + )) + return render( + request, + 'repository/submission_type_list.html', + { + 'submission_types': types, + }, + ) + + +@is_repository_manager +@require_POST +def delete_submission_type(request, pk): + obj = get_object_or_404( + models.RepositorySubmissionType, + pk=pk, + repository=request.repository, + ) + obj.delete() + return redirect('submission_type_list') + + +@is_repository_manager +def edit_submission_type(request, pk=None): + if pk: + submission_type = get_object_or_404(models.RepositorySubmissionType, pk=pk) + else: + submission_type = None + + if request.method == "POST": + form = forms.RepositorySubmissionTypeForm( + request.POST, + instance=submission_type, + ) + if form.is_valid(): + obj = form.save(commit=False) + obj.repository = request.repository # assumes middleware provides this + obj.save() + return redirect("submission_type_list") + else: + form = forms.RepositorySubmissionTypeForm(instance=submission_type) + + return render( + request, + "repository/submission_type_form.html", + { + "form": form, + "editing": submission_type is not None, + "submission_type": submission_type, + }, + ) + diff --git a/src/templates/admin/elements/repository/metadata_form.html b/src/templates/admin/elements/repository/metadata_form.html index 0b1bb91a3c..f92cd73deb 100644 --- a/src/templates/admin/elements/repository/metadata_form.html +++ b/src/templates/admin/elements/repository/metadata_form.html @@ -6,6 +6,9 @@
      {{ form.title|foundation }}
      +
      + {{ form.submission_type|foundation }} +
      {{ form.abstract|foundation }}
      diff --git a/src/templates/admin/press/nav.html b/src/templates/admin/press/nav.html index 7df00dc0e0..ff848295a5 100644 --- a/src/templates/admin/press/nav.html +++ b/src/templates/admin/press/nav.html @@ -36,6 +36,7 @@
    •  Author Dashboard
    • {% if request.user.is_staff or repository_manager %}
    •  Repository Manager
    • +
    •  Submission Types
    • {% endif %} {% if request.user.is_staff %}
    •  Subjects
    • diff --git a/src/templates/admin/repository/article.html b/src/templates/admin/repository/article.html index ce851b814b..28ff184d51 100644 --- a/src/templates/admin/repository/article.html +++ b/src/templates/admin/repository/article.html @@ -3,13 +3,13 @@ {% load static %} {% block title %}{{ preprint.title|striptags }} - Manager{% endblock %} -{% block title-section %}{{ request.repository.object_name }} Manager{% endblock %} -{% block title-sub %}{{ request.repository.object_name }} #{{ preprint.pk }} - {{ preprint.title|safe }}{% endblock %} +{% block title-section %}{{ request.repository.object_name|capfirst }} Manager{% endblock %} +{% block title-sub %}{{ request.repository.object_name|capfirst }} #{{ preprint.pk }} - {{ preprint.title|safe }}{% endblock %} {% load files %} {% block breadcrumbs %}
    • Press Manager
    • -
    • {{ request.repository.object_name }} Manager
    • +
    • {{ request.repository.object_name|capfirst }} Manager
    • {{ preprint.title|safe }}
    • {% endblock %} @@ -32,12 +32,14 @@

      Metadata

      {{ preprint.title }} - Owner + Owner Licence + Type - {{ preprint.owner.full_name }} + {{ preprint.owner.full_name }} {{ preprint.license.short_name }} + {{ preprint.submission_type.name|default:"No submission type set" }} Preprint DOI diff --git a/src/templates/admin/repository/dashboard.html b/src/templates/admin/repository/dashboard.html index f99ca1fd31..2525a5dc3a 100644 --- a/src/templates/admin/repository/dashboard.html +++ b/src/templates/admin/repository/dashboard.html @@ -20,6 +20,7 @@

      Submitted Preprints

      ID Title + Type Date Submitted Decision Date Published @@ -33,7 +34,8 @@

      Submitted Preprints

      {{ preprint.pk }} {{ preprint.title|safe }} - {{ preprint.date_submitted }} + {{ preprint.submission_type.name }} + {{ preprint.submission_type.name|default:"No submission type" }} {% if preprint.date_accepted %}Accepted{% elif preprint.date_declined %}Rejected{% else %}Under Consideration{% endif %} {% if preprint.date_published %}{{ preprint.date_published }}{% else %}N/a{% endif %} {{ preprint.views.count }} diff --git a/src/templates/admin/repository/manager.html b/src/templates/admin/repository/manager.html index 5865048573..a8c6e1a199 100644 --- a/src/templates/admin/repository/manager.html +++ b/src/templates/admin/repository/manager.html @@ -1,11 +1,11 @@ {% extends "admin/core/base.html" %} -{% block title-section %}Preprint Manager{% endblock %} +{% block title-section %}{{ request.repository.object_name|capfirst }} Manager{% endblock %} {% block title-sub %}Management interface for {{ request.repository.name }}{% endblock %} {% block breadcrumbs %}
    • Press Manager
    • -
    • Preprint Manager
    • +
    • {{ request.repository.object_name|capfirst }} Manager
    • {% endblock %} {% load cache %} @@ -23,6 +23,7 @@

      Unpublished Preprints

      ID Title + Type First Author Date Submitted @@ -34,6 +35,7 @@

      Unpublished Preprints

      {{ preprint.title|safe }} + {{ preprint.submission_type.name|default:"No submission type" }} {{ preprint.author_full_name }} {{ preprint.date_submitted }} @@ -98,6 +100,7 @@

      Published Preprints

      ID Title + Type First Author Date Published @@ -109,6 +112,7 @@

      Published Preprints

      {{ preprint.title|safe }} + {{ preprint.submission_type.name|default:"No submission type" }} {{ preprint.author_full_name }} {{ preprint.date_published }} diff --git a/src/templates/admin/repository/submission_type_form.html b/src/templates/admin/repository/submission_type_form.html new file mode 100644 index 0000000000..7bed24f1da --- /dev/null +++ b/src/templates/admin/repository/submission_type_form.html @@ -0,0 +1,61 @@ +{% extends "admin/core/base.html" %} +{% load static foundation %} + +{% block title %} + {% if editing %}Edit{% else %}New{% endif %} Submission Type +{% endblock %} + +{% block title-section %} + {% if editing %}Edit{% else %}New{% endif %} Submission Type +{% endblock %} + +{% block breadcrumbs %} +
    • Press Manager
    • +
    • + + {{ request.repository.object_name|capfirst }} Manager + +
    • +
    • + + Submission Types + +
    • +
    • {% if editing %}Edit{% else %}New{% endif %}
    • +{% endblock %} + +{% block body %} +
      +
      +
      +
      + {% include "admin/elements/forms/errors.html" with form=form %} +
      + {% csrf_token %} + {{ form.non_field_errors }} + {{ form|foundation }} +
      +
      + + Cancel +
      +
      +
      +
      +
      +

      The slug field should consist of only letters, numbers, underscores or hyphens.

      +

      + When selecting a pill colour, you should ensure that it has at least + 4.5:1 contrast ratio with your site's background to meet + + WCAG 2.2 AA 1.4.3 Contrast requirements + . +

      +
      +
      +
      +{% endblock %} diff --git a/src/templates/admin/repository/submission_type_list.html b/src/templates/admin/repository/submission_type_list.html new file mode 100644 index 0000000000..0eefed5aab --- /dev/null +++ b/src/templates/admin/repository/submission_type_list.html @@ -0,0 +1,81 @@ +{% extends "admin/core/base.html" %} +{% load static %} + +{% block title-section %}{{ request.repository.object_name|capfirst }} + Types{% endblock %} + +{% block breadcrumbs %} +
    • Press Manager
    • +
    • + {{ request.repository.object_name|capfirst }} + Manager
    • +
    • {{ preprint.title|safe }}
    • +{% endblock %} + +{% load cache %} + +{% block body %} +
      +
      +

      + Submission types are used to categorise preprints (e.g. Preprint, + Postprint). + Each type belongs to a single repository and is used to organise submissions on + the site. +

      +

      + You can edit the name, slug, or pill colour of each type. Deleting a type will + remove it permanently; + be careful when doing so — especially if it is in use. +

      + +

      + When selecting a pill colour, you should ensure that it has at least + 4.5:1 contrast ratio with your site's background to meet + + WCAG 2.2 AA 1.4.3 Contrast requirements + . +

      + + + + + + + + + + + + {% for type in submission_types %} + + + + + + + + {% endfor %} + +
      NameSlugSubmissionsPill ColourActions
      {{ type.name }}{{ type.slug }}{{ type.preprint_count }} + + {{ type.pill_colour }} + + + Edit +
      + {% csrf_token %} + +
      +
      +
      +
      +{% endblock %} + +{% block js %} + +{% endblock js %} diff --git a/src/templates/admin/repository/submit/review.html b/src/templates/admin/repository/submit/review.html index f4dbd2ce29..a61e04330f 100644 --- a/src/templates/admin/repository/submit/review.html +++ b/src/templates/admin/repository/submit/review.html @@ -130,7 +130,7 @@

      Complete Submission

      {% csrf_token %}
      diff --git a/src/templates/admin/repository/submit/start.html b/src/templates/admin/repository/submit/start.html index 10c537acc0..c750d12f1e 100644 --- a/src/templates/admin/repository/submit/start.html +++ b/src/templates/admin/repository/submit/start.html @@ -5,7 +5,7 @@ {% load field %} {% block title-section %} - Submit {{ request.repository.object_name }} + Create a new {{ request.repository.object_name }} {% endblock %} {% block breadcrumbs %} @@ -38,6 +38,9 @@

      {% trans "Metadata" %}

      {{ form.title|foundation }}
      +
      + {{ form.submission_type|foundation }} +
      {{ form.abstract|foundation }} diff --git a/src/templates/common/repository/style/submission_type_pills.html b/src/templates/common/repository/style/submission_type_pills.html new file mode 100644 index 0000000000..3440e34a15 --- /dev/null +++ b/src/templates/common/repository/style/submission_type_pills.html @@ -0,0 +1,23 @@ +{% regroup preprints by submission_type as grouped_preprints %} + +{% if preprints %} + +{% elif preprint %} + +{% endif %} \ No newline at end of file diff --git a/src/themes/OLH/assets/scss/app.scss b/src/themes/OLH/assets/scss/app.scss index 5c952f9eb8..4b7304bb8e 100755 --- a/src/themes/OLH/assets/scss/app.scss +++ b/src/themes/OLH/assets/scss/app.scss @@ -1671,3 +1671,15 @@ input[type="submit"]:focus-visible { margin-top: 0.5rem; font-weight: bold; } + +.submission-type-pill { + display: inline-block; + background-color: transparent; + font-size: 0.75rem; + font-weight: 600; + padding: 0.2rem 0.55rem; + border-radius: 2px; + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} diff --git a/src/themes/OLH/templates/repository/elements/hierarchy_list.html b/src/themes/OLH/templates/repository/elements/hierarchy_list.html index 3be539c165..5a68e41262 100644 --- a/src/themes/OLH/templates/repository/elements/hierarchy_list.html +++ b/src/themes/OLH/templates/repository/elements/hierarchy_list.html @@ -14,7 +14,7 @@ {% endif %} - ({{ item.preprint_count }} preprint{% if item.preprint_count != 1 %}s{% endif %}) + ({{ item.preprint_count }} submission{% if item.preprint_count != 1 %}s{% endif %})
      diff --git a/src/themes/OLH/templates/repository/elements/preprint_home_listing.html b/src/themes/OLH/templates/repository/elements/preprint_home_listing.html index 1bcbbda579..1e1b415883 100644 --- a/src/themes/OLH/templates/repository/elements/preprint_home_listing.html +++ b/src/themes/OLH/templates/repository/elements/preprint_home_listing.html @@ -1,22 +1,21 @@ -{% load dates %} -
      {% for preprint in preprints %} -
      -
      -

      {{ preprint.title|safe }}

      -

      {{ preprint.display_authors_compact }}

      -

      - -

      -
      -
      - {% if forloop.counter|divisibleby:3 %} -
      -
      - {% endif %} - {% endfor %} -
      \ No newline at end of file +
      +
      + {% include "repository/elements/submission_type_pill.html" %} +

      {{ preprint.title|safe }} +

      +

      {{ preprint.display_authors_compact }}

      +

       {{ preprint.date_published|date:"Y-m-d" }} +

      +
      +
      + {% if forloop.counter|divisibleby:3 %} + +
      + {% endif %} + {% endfor %} +
      \ No newline at end of file diff --git a/src/themes/OLH/templates/repository/elements/submission_type_pill.html b/src/themes/OLH/templates/repository/elements/submission_type_pill.html new file mode 100644 index 0000000000..55229c6b04 --- /dev/null +++ b/src/themes/OLH/templates/repository/elements/submission_type_pill.html @@ -0,0 +1,4 @@ + + + {{ preprint.submission_type.name }} + \ No newline at end of file diff --git a/src/themes/OLH/templates/repository/hierarchy.html b/src/themes/OLH/templates/repository/hierarchy.html index de030b6748..b61309c1f5 100644 --- a/src/themes/OLH/templates/repository/hierarchy.html +++ b/src/themes/OLH/templates/repository/hierarchy.html @@ -3,241 +3,274 @@ {% load i18n %} {% block title %} - {% if rou %} - {{ rou.name }} Hierarchy - {{ request.repository.name }} - {% else %} - {% trans "Organisational Units" %} - {{ request.repository.name }} - {% endif %} + {% if rou %} + {{ rou.name }} Hierarchy - {{ request.repository.name }} + {% else %} + {{ request.repository.rou_default_name|safe }}s - {{ request.repository.name }} + {% endif %} {% endblock %} {% block head %} -{{ block.super }} - + } + + {% endblock %} +{% block css %} + {% include "common/repository/style/submission_type_pills.html" with preprints=recent_preprints %} +{% endblock css %} + {% block body %} -
      -
      -
      -

      {{ request.repository.rou_default_name|safe }}

      +
      +
      +
      +

      {{ request.repository.rou_default_name|safe }}s

      -
      +
      {{ page_text|safe }} -
      - -
      -
      -
      -

      {% trans "Organisational Structure" %}

      +
      - +
      +
      +
      + +
      -
      -
      - {% if rou %} -
      -
      -

      - {{ rou.name }} - - ({{ rou.preprint_count }} preprint{% if rou.preprint_count != 1 %}s{% endif %}) +
      + {% if rou %} +
      +
      +

      + {{ rou.name }} + + ({{ rou.preprint_count }} {% if rou.preprint_count == 1 %} + {{ request.site_type.object_name }}{% else %} + {{ request.site_type.object_name_plural }}{% endif %}) -

      -
      +

      +
      - {% if rou.latest_preprints %} -

      {% trans "Latest Preprints" %}

      - {% if rou.preprint_count > 10 %} - - {% trans "View all preprints" %} - - {% endif %} + {% if rou.latest_preprints %} +

      {% trans "Latest Submissions" %}

      + {% if rou.preprint_count > 10 %} + + {% trans "View all preprints" %} + + {% endif %} -
      - {% for preprint in rou.latest_preprints %} -
      - {{ preprint.title }} -
      - {{ preprint.display_authors_compact }} - - {{ preprint.date_published|date:"Y-m-d" }} +
      + {% for preprint in rou.latest_preprints %} +
      + {{ preprint.title }} +
      + {{ preprint.display_authors_compact }} + + {{ preprint.date_published|date:"Y-m-d" }} +
      + {% if preprint.abstract %} +

      + {{ preprint.abstract|truncatewords_html:30 }}

      + {% endif %} +
      + {% endfor %}
      - {% if preprint.abstract %} -

      {{ preprint.abstract|truncatewords_html:30 }}

      - {% endif %} -
      - {% endfor %} -
      - {% endif %} -
      - {% else %} -
      -

      {% trans "Repository Overview" %}

      -

      {% trans "Select an organizational unit from the hierarchy to view its details and preprints." %}

      - {% if recent_preprints %} -
      -
      -

      {% trans "Recent Preprints" %}

      - - {% trans "View all preprints" %}  - + {% endif %}
      - -
      - {% for preprint in recent_preprints %} -
      - {{ preprint.title }} -
      - {{ preprint.display_authors_compact }} - - {{ preprint.date_published|date:"Y-m-d" }} + {% else %} +
      +
      +

      {% trans "Recent Submissions" %}

      + + {% trans "View all submissions" %}  + +
      + +
      + {% for preprint in recent_preprints %} +
      +
      + +
      + {% include "repository/elements/submission_type_pill.html" %} +
      +
      +
      + {{ preprint.display_authors_compact }} + + {{ preprint.date_published|date:"Y-m-d" }} +
      +
      + {% endfor %} +
      -
      - {% endfor %} + {% endif %}
      -
      {% endif %}
      - {% endif %}
      -
      -
      +
      {% endblock %} \ No newline at end of file diff --git a/src/themes/OLH/templates/repository/home.html b/src/themes/OLH/templates/repository/home.html index 6fee25f5ef..cd31c73407 100644 --- a/src/themes/OLH/templates/repository/home.html +++ b/src/themes/OLH/templates/repository/home.html @@ -5,10 +5,14 @@ {% if selected_rou %} {{ selected_rou.name }} - {{ request.repository.name }} {% else %} - {{ repository.name }} + {{ request.repository.name }} {% endif %} {% endblock %} +{% block css %} + {% include "common/repository/style/submission_type_pills.html" %} +{% endblock css %} + {% block navbar %} {% include "repository/nav.html" %} {% endblock navbar %} @@ -32,12 +36,12 @@

      @@ -47,8 +51,7 @@

      {% if not selected_rou %}
      -
      - {% csrf_token %} + @@ -75,7 +76,7 @@

      {% if preprints %}

      - {% trans "Latest" %} {{ request.repository.object_name_plural }} + {% trans "Latest" %} {{ request.repository.object_name_plural|capfirst }}

      {% include "repository/elements/preprint_home_listing.html" with preprints=preprints %}
      @@ -85,9 +86,11 @@

      {% if selected_rou %} - {% trans 'Select Sub-unit of' %} {{ selected_rou.name }} + {% blocktrans with rou_name=request.repository.rou_default_name selected_name=selected_rou.name %} + Select Sub-{{ rou_name }} of {{ selected_name }} + {% endblocktrans %} {% else %} - {% trans 'Select Organisational Unit' %} + {% trans 'Select' %} {{ request.repository.rou_default_name }} {% endif %}

      {% if rous|length <= 5 %} diff --git a/src/themes/OLH/templates/repository/list.html b/src/themes/OLH/templates/repository/list.html index e456f4d919..819dcff42a 100644 --- a/src/themes/OLH/templates/repository/list.html +++ b/src/themes/OLH/templates/repository/list.html @@ -3,6 +3,10 @@ {% load i18n %} {% load truncate %} +{% block css %} + {% include "common/repository/style/submission_type_pills.html" %} +{% endblock css %} + {% block title %} {% if rou %} {{ rou.name }} - {{ request.repository.name }} @@ -22,7 +26,7 @@

      {% if rou %} {{ rou.name }} {{ request.repository.object_name_plural }} {% else %} - {{ request.repository.object_name_plural }} + {{ request.repository.object_name_plural|capfirst }} {% endif %}

      @@ -53,11 +57,12 @@

      {{ preprint.title|safe

      {{ preprint.display_authors_compact }}

      + {% include "repository/elements/submission_type_pill.html" %}   {{ preprint.date_published|date:"Y-m-d" }}   {% include "common/repository/subject_display.html" %}  - + {% if preprint.organisation_units.all %}{% endif %} {% for rou in preprint.organisation_units.all %} {{ rou.name }}{% if not forloop.last %}, {% endif %} @@ -87,55 +92,51 @@

      {{ preprint.title|safe

      + {{ form.subject.label_tag }} + {{ form.subject }} + + {{ form.submission_type.label_tag }} + {{ form.submission_type }} + + + +
      + +
      +
      {% trans "You can search by" %}:
      +
        +
      • {% trans "Title" %}
      • +
      • {% trans "Keywords" %}
      • +
      • {% trans "Author Name" %}
      • +
      • {% trans "Author Affiliation" %}
      • +
      +
      + + {% if subject and subject.editors.exists %} +
      +
      {{ subject.name }} {% trans "Editors" %}
      +
        + {% for editor in subject.editors.all %} +
      • {{ editor.full_name }}
      • + {% endfor %} +
      +
      + {% endif %} + + + {% endblock %} diff --git a/src/themes/OLH/templates/repository/preprint.html b/src/themes/OLH/templates/repository/preprint.html index 13ed4a0257..732edf834f 100644 --- a/src/themes/OLH/templates/repository/preprint.html +++ b/src/themes/OLH/templates/repository/preprint.html @@ -7,27 +7,32 @@ {% block title %}{{ preprint.title }}{% endblock %} +{% block css %} + {% include "common/repository/style/submission_type_pills.html" with preprint=preprint %} +{% endblock css %} + {% block body %}


      @@ -117,9 +122,9 @@

      {% trans 'Comments' %}

      {% if preprint.current_version.file %}

      {% trans "Downloads" %}

      - - {{ request.repository.object_name }} - {% include "elements/icons/link_download.html" %} + + Download {{ preprint.submission_type.name }}

      {% endif %} @@ -127,9 +132,9 @@

      {% trans "Downloads" %}

      {% if rous %}

      {% if rous.count > 1 %} - {% trans "Units" %} + {{ request.repository.rou_default_name }}s {% else %} - {% trans "Unit" %} + {{ request.repository.rou_default_name }} {% endif %}

        @@ -196,10 +201,11 @@

        {% trans "Metrics" %}

      • {% trans "Downloads" %}: {{ preprint.downloads.count }}
      {% endif %} - - - {% trans "All Preprints" %} - + + {% hook 'preprint_sidebar' %} + + + {% trans "All submissions" %}
      From 063023bfabd432179a2ba4c6fc59dfe15c921545 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 10 Jun 2025 10:39:24 +0100 Subject: [PATCH 04/76] wip(repo): A WIP for multip object types in janeway's repo. --- src/repository/forms.py | 520 +++++++++--------- src/repository/logic.py | 36 ++ ...submissiontype_preprint_submission_type.py | 7 + src/repository/models.py | 28 +- src/repository/urls.py | 1 + src/repository/views.py | 63 ++- src/templates/admin/repository/fields.html | 2 +- .../admin/repository/submit/info.html | 116 ++++ .../admin/repository/submit/start.html | 220 ++++---- 9 files changed, 615 insertions(+), 378 deletions(-) create mode 100644 src/templates/admin/repository/submit/info.html diff --git a/src/repository/forms.py b/src/repository/forms.py index e0509f3612..dfcb9e95ba 100755 --- a/src/repository/forms.py +++ b/src/repository/forms.py @@ -12,112 +12,159 @@ from press import models as press_models from review.logic import render_choices from core import models as core_models, workflow -from core.logic import resize_and_crop from utils import forms as utils_forms from identifiers.models import URL_DOI_RE from core.widgets import TableMultiSelectUser -class PreprintInfo(utils_forms.KeywordModelForm): +class PreSubmissionStartForm(forms.Form): + submission_type = forms.ModelChoiceField( + queryset=models.RepositorySubmissionType.objects.none(), + required=True, + label="Select the appropriate submission type. This will affect the metadata " + "fields shown and the processing steps your submission follows.", + widget=forms.RadioSelect, + ) + submission_agreement = forms.BooleanField( - widget=forms.CheckboxInput(), required=True, + label="I agree to the terms of submission", ) + + organisation_unit = forms.ModelChoiceField( + queryset=models.RepositoryOrganisationUnit.objects.none(), + required=False, + label="Organisational Unit", + help_text="Select the relevant unit, department, or group for this submission.", + widget=forms.RadioSelect, + ) + + def __init__(self, *args, **kwargs): + repository = kwargs.pop("repository") + super().__init__(*args, **kwargs) + + self.fields[ + "submission_type"].queryset = models.RepositorySubmissionType.objects.filter( + repository=repository, + ) + + self.ou_depth_map = {} + + def walk(unit, level=0): + self.ou_depth_map[str(unit.id)] = level + yield (unit.id, unit.name) + for child in unit.children.all().order_by("name"): + yield from walk(child, level + 1) + + top_units = models.RepositoryOrganisationUnit.objects.filter( + repository=repository, + parent__isnull=True, + ).order_by("name").prefetch_related("children") + + choices = [] + for unit in top_units: + choices.extend(walk(unit)) + + self.fields["organisation_unit"].choices = choices + self.fields[ + "organisation_unit"].queryset = models.RepositoryOrganisationUnit.objects.filter( + repository=repository, + ) + + + + +class PreprintInfo(utils_forms.KeywordModelForm): subject = forms.ModelMultipleChoiceField( required=True, queryset=models.Subject.objects.none(), - widget=forms.SelectMultiple(attrs={"multiple": ""}), + widget=forms.SelectMultiple(attrs={'multiple': ''}), ) class Meta: model = models.Preprint fields = ( - "title", - "submission_type", - "abstract", - "license", - "comments_editor", - "subject", - "doi", + 'title', + 'abstract', + 'license', + 'comments_editor', + 'subject', + 'doi', ) widgets = { - "title": forms.TextInput(attrs={"placeholder": _("Title")}), - "abstract": forms.Textarea( - attrs={"placeholder": _("Enter your article's abstract here")} + 'title': forms.TextInput(attrs={'placeholder': _('Title')}), + 'abstract': forms.Textarea( + attrs={ + 'placeholder': _('Enter your article\'s abstract here') + } ), } def __init__(self, *args, **kwargs): - self.request = kwargs.pop("request") - self.admin = kwargs.pop("admin", False) - elements = self.request.repository.additional_submission_fields() + self.request = kwargs.pop('request') + self.admin = kwargs.pop('admin', False) + self.submission_type_slug = kwargs.pop('submission_type_slug', None) super(PreprintInfo, self).__init__(*args, **kwargs) - if self.admin: - self.fields.pop("submission_agreement") - self.fields.pop("comments_editor") + if not self.submission_type_slug and self.instance and self.instance.submission_type: + self.submission_type_slug = self.instance.submission_type.slug - # If using this form and there is an instance then this has - # previously been checked as it is required. - if self.instance.id and "submission_agreement" in self._meta.fields: - self.fields["submission_agreement"].initial = True + elements = self.request.repository.type_additional_submission_fields( + submission_type_slug=self.submission_type_slug, + ) + if self.admin: + self.fields.pop('comments_editor') - self.fields["subject"].queryset = models.Subject.objects.filter( + self.fields['subject'].queryset = models.Subject.objects.filter( enabled=True, repository=self.request.repository, ) - self.fields['submission_type'].queryset = models.RepositorySubmissionType.objects.filter( - repository=self.request.repository, - ) if self.admin: - self.fields["license"].queryset = submission_models.Licence.objects.filter( + self.fields['license'].queryset = submission_models.Licence.objects.filter( journal__isnull=True, ) else: - self.fields[ - "license" - ].queryset = self.request.repository.active_licenses.all() - self.fields["license"].required = True + self.fields['license'].queryset = self.request.repository.active_licenses.all() + self.fields['license'].required = True if elements: for element in elements: - if element.input_type == "text": + if element.input_type == 'text': self.fields[element.name] = forms.CharField( - widget=forms.TextInput(), required=element.required + widget=forms.TextInput(), + required=element.required, ) - elif element.input_type == "textarea": + elif element.input_type == 'textarea': self.fields[element.name] = forms.CharField( widget=forms.Textarea, required=element.required, ) - elif element.input_type == "date": + elif element.input_type == 'date': self.fields[element.name] = forms.CharField( widget=forms.DateInput( - attrs={ - "class": "datepicker", - } + attrs={'class': 'datepicker'} ), required=element.required, ) - elif element.input_type == "select": + elif element.input_type == 'select': choices = render_choices(element.choices) self.fields[element.name] = forms.ChoiceField( widget=forms.Select(), choices=choices, required=element.required, ) - elif element.input_type == "email": + elif element.input_type == 'email': self.fields[element.name] = forms.EmailField( widget=forms.TextInput(), required=element.required, ) - elif element.input_type == "checkbox": + elif element.input_type == 'checkbox': self.fields[element.name] = forms.BooleanField( - widget=forms.CheckboxInput(attrs={"is_checkbox": True}), + widget=forms.CheckboxInput(attrs={'is_checkbox': True}), required=element.required, ) - elif element.input_type == "number": + elif element.input_type == 'number': self.fields[element.name] = forms.IntegerField( required=element.required, ) @@ -127,17 +174,13 @@ def __init__(self, *args, **kwargs): required=element.required, ) - if element.input_type == "date": - self.fields[ - element.name - ].help_text = "Use ISO 8601 Date Format YYYY-MM-DD. {}".format( - element.help_text - ) + if element.input_type == 'date': + self.fields[element.name].help_text = f'Use ISO 8601 Date Format YYYY-MM-DD. {element.help_text}' else: self.fields[element.name].help_text = element.help_text self.fields[element.name].label = element.name - preprint = kwargs["instance"] + preprint = kwargs['instance'] if preprint: try: check_for_answer = models.RepositoryFieldAnswer.objects.get( @@ -151,7 +194,6 @@ def __init__(self, *args, **kwargs): def save(self, commit=True): preprint = super(PreprintInfo, self).save() - # We only set the preprint owner once on creation. if not preprint.owner: preprint.owner = self.request.user @@ -163,7 +205,6 @@ def save(self, commit=True): ) for field in additional_fields: answer = self.request.POST.get(field.name, None) - if answer: try: field_answer = models.RepositoryFieldAnswer.objects.get( @@ -185,27 +226,25 @@ def save(self, commit=True): return preprint def clean_doi(self): - doi_string = self.cleaned_data.get("doi") + doi_string = self.cleaned_data.get('doi') if doi_string and not URL_DOI_RE.match(doi_string): self.add_error( - "doi", - "DOIs should be in the following format: https://doi.org/10.XXX/XXXXX", + 'doi', + 'DOIs should be in the following format: https://doi.org/10.XXX/XXXXX' ) return doi_string + class PreprintSupplementaryFileForm(forms.ModelForm): class Meta: model = models.PreprintSupplementaryFile - fields = ( - "label", - "url", - ) + fields = ('label', 'url',) def __init__(self, *args, **kwargs): - self.preprint = kwargs.pop("preprint") + self.preprint = kwargs.pop('preprint') super(PreprintSupplementaryFileForm, self).__init__(*args, **kwargs) def save(self, commit=True): @@ -226,62 +265,60 @@ class AuthorForm(forms.Form): affiliation = forms.CharField(max_length=200, required=False) def __init__(self, *args, **kwargs): - self.instance = kwargs.pop("instance") - self.request = kwargs.pop("request") - self.preprint = kwargs.pop("preprint") + self.instance = kwargs.pop('instance') + self.request = kwargs.pop('request') + self.preprint = kwargs.pop('preprint') super(AuthorForm, self).__init__(*args, **kwargs) if self.instance: - self.fields["email_address"].initial = self.instance.account.email - self.fields["first_name"].initial = self.instance.account.first_name - self.fields["middle_name"].initial = self.instance.account.middle_name - self.fields["last_name"].initial = self.instance.account.last_name - self.fields["affiliation"].initial = ( - self.instance.affiliation or self.instance.account.institution - ) + self.fields['email_address'].initial = self.instance.account.email + self.fields['first_name'].initial = self.instance.account.first_name + self.fields['middle_name'].initial = self.instance.account.middle_name + self.fields['last_name'].initial = self.instance.account.last_name + self.fields['affiliation'].initial = self.instance.affiliation or self.instance.account.institution def save(self): cleaned_data = self.cleaned_data if self.instance: account = self.instance.account - account.email = cleaned_data.get("email_address") - account.first_name = cleaned_data.get("first_name") - account.middle_name = cleaned_data.get("middle_name") - account.last_name = cleaned_data.get("last_name") - self.instance.affiliation = cleaned_data.get("affiliation") + account.email = cleaned_data.get('email_address') + account.first_name = cleaned_data.get('first_name') + account.middle_name = cleaned_data.get('middle_name') + account.last_name = cleaned_data.get('last_name') + self.instance.affiliation = cleaned_data.get('affiliation') account.save() self.instance.save() return self.instance else: account, ac = core_models.Account.objects.get_or_create( - email=cleaned_data.get("email_address"), + email=cleaned_data.get('email_address'), defaults={ - "first_name": cleaned_data.get("first_name"), - "middle_name": cleaned_data.get("middle_name"), - "last_name": cleaned_data.get("last_name"), - }, + 'first_name': cleaned_data.get('first_name'), + 'middle_name': cleaned_data.get('middle_name'), + 'last_name': cleaned_data.get('last_name'), + } ) preprint_author, pc = models.PreprintAuthor.objects.get_or_create( account=account, preprint=self.preprint, defaults={ - "affiliation": cleaned_data.get("affiliation"), - "order": self.preprint.next_author_order(), - }, + 'affiliation': cleaned_data.get('affiliation'), + 'order': self.preprint.next_author_order() + } ) if not ac: messages.add_message( self.request, messages.WARNING, - "A user with this email address was found. They have been added.", + 'A user with this email address was found. They have been added.' ) else: messages.add_message( self.request, messages.SUCCESS, - "User added as Author.", + 'User added as Author.', ) return preprint_author @@ -290,11 +327,11 @@ def save(self): class CommentForm(forms.ModelForm): class Meta: model = models.Comment - fields = ("body",) + fields = ('body',) def __init__(self, *args, **kwargs): - self.preprint = kwargs.pop("preprint", None) - self.author = kwargs.pop("author", None) + self.preprint = kwargs.pop('preprint', None) + self.author = kwargs.pop('author', None) super(CommentForm, self).__init__(*args, **kwargs) def save(self, commit=True): @@ -312,25 +349,25 @@ class SettingsForm(forms.ModelForm): class Meta: model = press_models.Press fields = ( - "preprints_about", - "preprint_start", - "preprint_submission", - "preprint_publication", - "preprint_decline", - "preprint_pdf_only", + 'preprints_about', + 'preprint_start', + 'preprint_submission', + 'preprint_publication', + 'preprint_decline', + 'preprint_pdf_only', ) widgets = { - "preprints_about": TinyMCE, - "preprint_start": TinyMCE, - "preprint_submission": TinyMCE, - "preprint_publication": TinyMCE, - "preprint_decline": TinyMCE, + 'preprints_about': TinyMCE, + 'preprint_start': TinyMCE, + 'preprint_submission': TinyMCE, + 'preprint_publication': TinyMCE, + 'preprint_decline': TinyMCE, } def __init__(self, *args, **kwargs): super(SettingsForm, self).__init__(*args, **kwargs) - if "instance" in kwargs: - press = kwargs["instance"] + if 'instance' in kwargs: + press = kwargs['instance'] settings = press_models.PressSetting.objects.filter(press=press) for setting in settings: @@ -352,7 +389,7 @@ def save(self, commit=True): for setting in settings: if setting.is_boolean: - setting.value = "On" if self.cleaned_data[setting.name] else "" + setting.value = 'On' if self.cleaned_data[setting.name] else '' else: setting.value = self.cleaned_data[setting.name] setting.save() @@ -361,12 +398,12 @@ def save(self, commit=True): class SubjectForm(forms.ModelForm): class Meta: model = models.Subject - exclude = ("repository", "slug") + exclude = ('repository', 'slug') def __init__(self, *args, **kwargs): - self.repository = kwargs.pop("repository") + self.repository = kwargs.pop('repository') super(SubjectForm, self).__init__(*args, **kwargs) - self.fields["parent"].queryset = models.Subject.objects.filter( + self.fields['parent'].queryset = models.Subject.objects.filter( repository=self.repository, ) @@ -384,20 +421,20 @@ def save(self, commit=True): class ActiveLicenseForm(forms.ModelForm): class Meta: model = models.Repository - fields = ("active_licenses",) + fields = ('active_licenses',) widgets = { - "active_licenses": forms.CheckboxSelectMultiple, + 'active_licenses': forms.CheckboxSelectMultiple, } labels = { - "active_licenses": "Select the licenses that authors can pick from during submission", + 'active_licenses': 'Select the licenses that authors can pick from during submission', } def __init__(self, *args, **kwargs): super(ActiveLicenseForm, self).__init__(*args, **kwargs) - self.fields[ - "active_licenses" - ].queryset = submission_models.Licence.objects.filter(journal=None) - self.fields["active_licenses"].label_from_instance = self.label_from_instance + self.fields['active_licenses'].queryset = submission_models.Licence.objects.filter( + journal=None + ) + self.fields['active_licenses'].label_from_instance = self.label_from_instance @staticmethod def label_from_instance(obj): @@ -407,10 +444,10 @@ def label_from_instance(obj): class FileForm(forms.ModelForm): class Meta: model = models.PreprintFile - fields = ("file",) + fields = ('file',) def __init__(self, *args, **kwargs): - self.preprint = kwargs.pop("preprint") + self.preprint = kwargs.pop('preprint') super(FileForm, self).__init__(*args, **kwargs) def save(self, commit=True): @@ -429,14 +466,14 @@ def save(self, commit=True): class VersionForm(forms.ModelForm): class Meta: model = models.VersionQueue - fields = ("title", "abstract", "published_doi") + fields = ('title', 'abstract', 'published_doi') def __init__(self, *args, **kwargs): - self.preprint = kwargs.pop("preprint") + self.preprint = kwargs.pop('preprint') super(VersionForm, self).__init__(*args, **kwargs) - self.fields["title"].initial = self.preprint.title - self.fields["abstract"].initial = self.preprint.abstract - self.fields["published_doi"].initial = self.preprint.doi + self.fields['title'].initial = self.preprint.title + self.fields['abstract'].initial = self.preprint.abstract + self.fields['published_doi'].initial = self.preprint.doi def save(self, commit=True): version = super(VersionForm, self).save(commit=False) @@ -448,12 +485,12 @@ def save(self, commit=True): return version def clean_published_doi(self): - doi_string = self.cleaned_data.get("published_doi") + doi_string = self.cleaned_data.get('published_doi') if doi_string and not URL_DOI_RE.match(doi_string): self.add_error( - "published_doi", - "DOIs should be in the following format: https://doi.org/10.XXX/XXXXX", + 'published_doi', + 'DOIs should be in the following format: https://doi.org/10.XXX/XXXXX' ) return doi_string @@ -462,43 +499,29 @@ def clean_published_doi(self): class RepositoryBase(forms.ModelForm): class Meta: model = models.Repository - fields = "__all__" + fields = '__all__' def __init__(self, *args, **kwargs): - self.press = kwargs.pop("press") + self.press = kwargs.pop('press') super(RepositoryBase, self).__init__(*args, **kwargs) - def save(self, commit=True): - instance = super().save(commit=True) - try: - if "hero_background" in self.cleaned_data: - resize_and_crop( - instance.hero_background.path, - field_name="Hero background", - ) - except ValueError: - pass - return instance - class RepositoryInitial(RepositoryBase): class Meta: model = models.Repository fields = ( - "name", - "short_name", - "domain", - "object_name", - "object_name_plural", - "theme", - "display_public_metrics", - "publisher", - "enable_comments", - "enable_invited_comments", + 'name', + 'short_name', + 'domain', + 'object_name', + 'object_name_plural', + 'theme', + 'display_public_metrics', + 'publisher', ) help_texts = { - "domain": "Using a custom domain requires configuring DNS. " - "The repository will always be available under the /code path", + 'domain': 'Using a custom domain requires configuring DNS. ' + 'The repository will always be available under the /code path', } def save(self, commit=True): @@ -512,28 +535,29 @@ def save(self, commit=True): class RepositorySite(RepositoryBase): + class Meta: model = models.Repository fields = ( - "about", - "logo", - "hero_background", - "favicon", - "footer", - "login_text", - "limit_access_to_submission", - "submission_access_request_text", - "submission_access_contact", - "review_submission_text", - "custom_js_code", - "review_helper", + 'about', + 'logo', + 'hero_background', + 'favicon', + 'footer', + 'login_text', + 'limit_access_to_submission', + 'submission_access_request_text', + 'submission_access_contact', + 'review_submission_text', + 'custom_js_code', + 'review_helper', ) widgets = { - "about": TinyMCE, - "footer": TinyMCE, - "login_text": TinyMCE, - "submission_access_request_text": TinyMCE, - "review_helper": TinyMCE, + 'about': TinyMCE, + 'footer': TinyMCE, + 'login_text': TinyMCE, + 'submission_access_request_text': TinyMCE, + 'review_helper': TinyMCE, } @@ -541,25 +565,25 @@ class RepositorySubmission(RepositoryBase): class Meta: model = models.Repository fields = ( - "start", - "file_upload_help", - "submission_agreement", - "limit_upload_to_pdf", - "require_pdf_help", - "additional_version_help", - "managers", + 'start', + 'file_upload_help', + 'submission_agreement', + 'limit_upload_to_pdf', + 'require_pdf_help', + 'additional_version_help', + 'managers', ) widgets = { - "start": TinyMCE, - "submission_agreement": TinyMCE, - "file_upload_help": TinyMCE, - "additional_version_help": TinyMCE, - "managers": FilteredSelectMultiple( + 'start': TinyMCE, + 'submission_agreement': TinyMCE, + 'file_upload_help': TinyMCE, + 'additional_version_help': TinyMCE, + 'managers': FilteredSelectMultiple( "Accounts", False, - attrs={"rows": "2"}, - ), + attrs={'rows': '2'}, + ) } @@ -567,62 +591,62 @@ class RepositoryEmails(RepositoryBase): class Meta: model = models.Repository fields = ( - "submission", - "publication", - "decline", - "accept_version", - "decline_version", - "new_comment", - "review_invitation", - "manager_review_status_change", - "reviewer_review_status_change", - "submission_notification_recipients", + 'submission', + 'publication', + 'decline', + 'accept_version', + 'decline_version', + 'new_comment', + 'review_invitation', + 'manager_review_status_change', + 'reviewer_review_status_change', + 'submission_notification_recipients', ) widgets = { - "submission": TinyMCE, - "publication": TinyMCE, - "decline": TinyMCE, - "accept_version": TinyMCE, - "decline_version": TinyMCE, - "new_comment": TinyMCE, - "review_invitation": TinyMCE, - "manager_review_status_change": TinyMCE, - "reviewer_review_status_change": TinyMCE, - "submission_notification_recipients": TableMultiSelectUser(), + 'submission': TinyMCE, + 'publication': TinyMCE, + 'decline': TinyMCE, + 'accept_version': TinyMCE, + 'decline_version': TinyMCE, + 'new_comment': TinyMCE, + 'review_invitation': TinyMCE, + 'manager_review_status_change': TinyMCE, + 'reviewer_review_status_change': TinyMCE, + 'submission_notification_recipients': TableMultiSelectUser() } def __init__(self, *args, **kwargs): super(RepositoryEmails, self).__init__(*args, **kwargs) - repo_managers = kwargs["instance"].managers.all() - self.fields["submission_notification_recipients"].queryset = repo_managers - self.fields["submission_notification_recipients"].choices = [ - (m.id, {"name": m.full_name(), "email": m.email}) for m in repo_managers - ] - + repo_managers = kwargs['instance'].managers.all() + self.fields['submission_notification_recipients'].queryset = repo_managers + self.fields['submission_notification_recipients'].choices = [(m.id, {"name": m.full_name(), "email": m.email}) for m in repo_managers] class RepositoryLiveForm(RepositoryBase): class Meta: model = models.Repository - fields = ("live",) + fields = ( + 'live', + ) class RepositoryFieldForm(forms.ModelForm): class Meta: model = models.RepositoryField fields = ( - "name", - "input_type", - "choices", - "required", - "order", - "help_text", - "display", - "dc_metadata_type", + 'name', + 'submission_type', + 'input_type', + 'choices', + 'required', + 'order', + 'help_text', + 'display', + 'dc_metadata_type', ) def __init__(self, *args, **kwargs): - self.repository = kwargs.pop("repository") + self.repository = kwargs.pop('repository') super(RepositoryFieldForm, self).__init__(*args, **kwargs) def save(self, commit=True): @@ -638,51 +662,51 @@ def save(self, commit=True): class PreprinttoArticleForm(forms.Form): license = forms.ModelChoiceField(queryset=submission_models.Licence.objects.none()) section = forms.ModelChoiceField(queryset=submission_models.Section.objects.none()) - stage = forms.ChoiceField(choices=()) + stage = forms.ChoiceField( + choices=() + ) force = forms.BooleanField( required=False, - help_text="If you want to force the creation of a new article object even if one exists, check this box. " - "The old article will be orphaned and no longer linked to this object.", + help_text='If you want to force the creation of a new article object even if one exists, check this box. ' + 'The old article will be orphaned and no longer linked to this object.', ) def __init__(self, *args, **kwargs): - self.journal = kwargs.pop("journal", None) + self.journal = kwargs.pop('journal', None) super(PreprinttoArticleForm, self).__init__(*args, **kwargs) if self.journal: - self.fields["license"].queryset = submission_models.Licence.objects.filter( + self.fields['license'].queryset = submission_models.Licence.objects.filter( journal=self.journal, ) - self.fields["section"].queryset = submission_models.Section.objects.filter( + self.fields['section'].queryset = submission_models.Section.objects.filter( journal=self.journal, ) - self.fields["stage"].choices = workflow.workflow_journal_choices( - self.journal - ) + self.fields['stage'].choices = workflow.workflow_journal_choices(self.journal) class ReviewForm(forms.ModelForm): class Meta: model = models.Review fields = ( - "reviewer", - "date_due", + 'reviewer', + 'date_due', ) widgets = { - "date_due": utils_forms.HTMLDateInput(), + 'date_due': utils_forms.HTMLDateInput(), } def __init__(self, *args, **kwargs): - self.preprint = kwargs.pop("preprint") - self.manager = kwargs.pop("manager") + self.preprint = kwargs.pop('preprint') + self.manager = kwargs.pop('manager') super(ReviewForm, self).__init__(*args, **kwargs) - self.fields["reviewer"].queryset = self.preprint.repository.reviewer_accounts() + self.fields['reviewer'].queryset = self.preprint.repository.reviewer_accounts() def save(self, commit=True): review = super(ReviewForm, self).save(commit=False) review.preprint = self.preprint review.manager = self.manager - review.status = "new" + review.status = 'new' if commit: review.save() @@ -693,9 +717,9 @@ def save(self, commit=True): class ReviewDueDateForm(forms.ModelForm): class Meta: model = models.Review - fields = ("date_due",) + fields = ('date_due',) widgets = { - "date_due": utils_forms.HTMLDateInput(), + 'date_due': utils_forms.HTMLDateInput(), } @@ -708,22 +732,20 @@ class ReviewCommentForm(forms.Form): queryset=models.ReviewRecommendation.objects.none(), ) anonymous = forms.BooleanField( - help_text="Check if you want your comments to be displayed anonymously.", + help_text='Check if you want your comments to be displayed anonymously.', label="Comment Anonymously", required=False, ) def __init__(self, *args, **kwargs): - self.review = kwargs.pop("review") + self.review = kwargs.pop('review') super(ReviewCommentForm, self).__init__(*args, **kwargs) if self.review.comment: - self.fields["body"].initial = self.review.comment.body - self.fields["anonymous"].initial = self.review.anonymous - self.fields["recommendation"].initial = self.review.recommendation.pk + self.fields['body'].initial = self.review.comment.body + self.fields['anonymous'].initial = self.review.anonymous + self.fields['recommendation'].initial = self.review.recommendation.pk - self.fields[ - "recommendation" - ].queryset = models.ReviewRecommendation.objects.filter( + self.fields['recommendation'].queryset = models.ReviewRecommendation.objects.filter( repository=self.review.preprint.repository, active=True, ) @@ -737,12 +759,12 @@ def save(self): comment.author = self.review.reviewer comment.preprint = self.review.preprint - comment.body = self.cleaned_data.get("body") + comment.body = self.cleaned_data.get('body') comment.save() - self.review.anonymous = self.cleaned_data.get("anonymous", False) + self.review.anonymous = self.cleaned_data.get('anonymous', False) self.review.comment = comment - self.review.recommendation = self.cleaned_data.get("recommendation") + self.review.recommendation = self.cleaned_data.get('recommendation') self.review.save() @@ -750,12 +772,12 @@ class RecommendationForm(forms.ModelForm): class Meta: model = models.ReviewRecommendation fields = ( - "name", - "active", + 'name', + 'active', ) def __init__(self, *args, **kwargs): - self.repository = kwargs.pop("repository") + self.repository = kwargs.pop('repository') super(RecommendationForm, self).__init__(*args, **kwargs) def save(self, commit=True): diff --git a/src/repository/logic.py b/src/repository/logic.py index cd0d4b22d4..6c4b31f892 100755 --- a/src/repository/logic.py +++ b/src/repository/logic.py @@ -526,3 +526,39 @@ def get_review_notification(request, preprint, review): template_is_setting=True, ) return email_content + + +def get_submission_type_or_redirect(request): + """ + Attempts to retrieve submission_type and organisation_unit from request.GET. + If missing or invalid, returns an HttpResponseRedirect to the start page. + Otherwise, attaches them to request for later use and returns submission_type. + """ + submission_type_slug = request.GET.get('submission_type') + if not submission_type_slug: + return redirect(reverse('repository_start')) + + submission_type = models.RepositorySubmissionType.objects.filter( + repository=request.repository, + slug=submission_type_slug, + ).first() + + if not submission_type: + messages.warning(request, "No submission type found.") + return redirect(reverse('repository_start')) + + # Handle OU if present + ou_code = request.GET.get('ou') + request.organisation_unit = None + + if ou_code: + request.organisation_unit = models.RepositoryOrganisationUnit.objects.filter( + repository=request.repository, + code=ou_code, + ).first() + + if not request.organisation_unit: + messages.warning(request, "Invalid organisational unit.") + return redirect(reverse('repository_start')) + + return submission_type \ No newline at end of file diff --git a/src/repository/migrations/0051_repositorysubmissiontype_preprint_submission_type.py b/src/repository/migrations/0051_repositorysubmissiontype_preprint_submission_type.py index 20d68a491c..d93990ae39 100644 --- a/src/repository/migrations/0051_repositorysubmissiontype_preprint_submission_type.py +++ b/src/repository/migrations/0051_repositorysubmissiontype_preprint_submission_type.py @@ -48,6 +48,13 @@ class Migration(migrations.Migration): name='submission_type', field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='repository.repositorysubmissiontype'), ), + migrations.AddField( + model_name='repositoryfield', + name='submission_type', + field=models.ForeignKey(blank=True, null=True, + on_delete=django.db.models.deletion.CASCADE, + to='repository.repositorysubmissiontype'), + ), migrations.RunPython( create_preprint_submission_type, reverse_code=migrations.RunPython.noop, diff --git a/src/repository/models.py b/src/repository/models.py index 64b8ff92a8..003f663fce 100755 --- a/src/repository/models.py +++ b/src/repository/models.py @@ -316,8 +316,19 @@ def top_level_subjects(self): ).prefetch_related("children") def additional_submission_fields(self): + return self.all_additional_submission_fields + + def all_additional_submission_fields(self): + return RepositoryField.objects.filter( + repository=self, + ) + + def type_additional_submission_fields(self, submission_type_slug=None): return RepositoryField.objects.filter( repository=self, + ).filter( + Q(submission_type__isnull=True) | + Q(submission_type__slug=submission_type_slug) ) def site_url(self, path="", query=""): @@ -432,6 +443,14 @@ class RepositoryField(models.Model): Repository, on_delete=models.CASCADE, ) + submission_type = models.ForeignKey( + 'RepositorySubmissionType', + blank=True, + null=True, + on_delete=models.CASCADE, + help_text='Optional, allows you to tie this field to a specific submission type. ' + 'Leave blank to tie this to all submission types.', + ) name = models.CharField(max_length=255) input_type = models.CharField( max_length=255, @@ -601,15 +620,6 @@ def __str__(self): self.title, ) - def clean(self): - super().clean() - if self.submission_type and self.submission_type.repository != self.repository: - raise ValidationError({ - 'submission_type': _( - "Submission type must belong to the same repository as the preprint." - ) - }) - def old_versions(self): return PreprintVersion.objects.filter( preprint=self, diff --git a/src/repository/urls.py b/src/repository/urls.py index 1b7c629e30..84cf094865 100755 --- a/src/repository/urls.py +++ b/src/repository/urls.py @@ -53,6 +53,7 @@ ), re_path(r"^editors/$", views.preprints_editors, name="preprints_editors"), re_path(r"^submit/start/$", views.repository_submit, name="repository_submit"), + re_path(r"^submit/info/$", views.repository_info, name="repository_info"), re_path( r"^submit/(?P\d+)/$", views.repository_submit, diff --git a/src/repository/views.py b/src/repository/views.py index c53cbfc87f..7689e99136 100644 --- a/src/repository/views.py +++ b/src/repository/views.py @@ -3,7 +3,6 @@ __license__ = "AGPL v3" __maintainer__ = "Birkbeck Centre for Technology and Publishing" -import operator from datetime import datetime from dateutil import tz @@ -16,7 +15,7 @@ from django.contrib.admin.views.decorators import staff_member_required from django.contrib import messages from django.views.decorators.http import require_POST -from django.http import HttpResponse, Http404 +from django.http import HttpResponse, Http404, HttpResponseRedirect from django.core.exceptions import PermissionDenied from django.utils.translation import gettext_lazy as _ from django.utils.http import urlencode @@ -555,13 +554,49 @@ def preprints_editors(request): @submission_authorised -def repository_submit(request, preprint_id=None): +def repository_submit(request): + form = forms.PreSubmissionStartForm( + repository=request.repository, + ) + if request.POST: + form = forms.PreSubmissionStartForm( + request.POST, + repository=request.repository, + ) + if form.is_valid(): + submission_type = form.cleaned_data["submission_type"] + organisation_unit = form.cleaned_data.get("organisation_unit") + + url = reverse('repository_info') + params = f"?submission_type={submission_type.slug}" + + if organisation_unit: + params += f"&ou={organisation_unit.code}" + + return redirect(f"{url}{params}") + + template = 'admin/repository/submit/start.html' + context = { + 'form': form, + } + return render(request, template, context) + + +@submission_authorised +def repository_info(request, preprint_id=None): """ Handles initial steps of generating a preprints submission. :param request: HttpRequest :param preprint_id: int Pk for a preprint object :return: HttpResponse or HttpRedirect """ + result = repository_logic.get_submission_type_or_redirect(request) + if isinstance(result, HttpResponseRedirect): + return result + + submission_type = result + organisation_unit = getattr(request, 'organisation_unit', None) + if preprint_id: preprint = get_object_or_404( models.Preprint, @@ -575,6 +610,7 @@ def repository_submit(request, preprint_id=None): form = forms.PreprintInfo( instance=preprint, request=request, + submission_type_slug=submission_type.slug, ) if request.POST: @@ -582,10 +618,17 @@ def repository_submit(request, preprint_id=None): request.POST, instance=preprint, request=request, + submission_type_slug=submission_type.slug, ) if form.is_valid(): - preprint = form.save() + preprint = form.save(commit=False) + preprint.submission_type = submission_type + if organisation_unit: + preprint.organisation_units.add(organisation_unit) + + preprint.save() + return redirect( reverse( "repository_authors", @@ -593,11 +636,13 @@ def repository_submit(request, preprint_id=None): ), ) - template = "admin/repository/submit/start.html" + template = "admin/repository/submit/info.html" context = { "form": form, "preprint": preprint, - "additional_fields": request.repository.additional_submission_fields(), + "additional_fields": request.repository.type_additional_submission_fields( + submission_type_slug=submission_type.slug, + ), } return render(request, template, context) @@ -1075,7 +1120,9 @@ def repository_edit_metadata(request, preprint_id): context = { "preprint": preprint, "metadata_form": metadata_form, - "additional_fields": request.repository.additional_submission_fields(), + "additional_fields": request.repository.type_additional_submission_fields( + submission_type_slug=preprint.submission_type.slug if preprint.submission_type else None, + ), } return render(request, template, context) @@ -1654,7 +1701,7 @@ def repository_fields(request, field_id=None): context = { "field": field, "form": form, - "fields": request.repository.additional_submission_fields(), + "fields": request.repository.all_additional_submission_fields(), } return render(request, template, context) diff --git a/src/templates/admin/repository/fields.html b/src/templates/admin/repository/fields.html index 18421dc98a..abb54121ec 100644 --- a/src/templates/admin/repository/fields.html +++ b/src/templates/admin/repository/fields.html @@ -27,7 +27,7 @@

      Current Fields

      {% for field in fields %}
    •   - {{ field.name }} + {{ field.name }} [Linked to {{ field.submission_type.name|default:"All" }}]
      diff --git a/src/templates/admin/repository/submit/info.html b/src/templates/admin/repository/submit/info.html new file mode 100644 index 0000000000..f8bbc42d9f --- /dev/null +++ b/src/templates/admin/repository/submit/info.html @@ -0,0 +1,116 @@ +{% extends "admin/core/base.html" %} +{% load static %} +{% load i18n %} +{% load foundation %} +{% load field %} + +{% block title-section %} + Create a new {{ request.repository.object_name }} +{% endblock %} + +{% block breadcrumbs %} +{% include "admin/elements/breadcrumbs/repository_submission.html" with start=True %} +{% endblock %} + + +{% block body %} +
      +
      +
      + {% csrf_token %} +
      +
      + {% include "elements/forms/errors.html" with form=form %} +
      +

      {% trans "Metadata" %}

      +
      +
      + {{ form.title|foundation }} +
      +
      + {{ form.submission_type|foundation }} +
      +
      + {{ form.abstract|foundation }} + +
      +
      + {{ form.license|foundation }} +
      +
      + {{ form.doi|foundation }} +
      + +
      +
      +
      + +
        + {% include "admin/elements/repository/tree.html" with subjects=request.repository.top_level_subjects %} +
      +

      + Press the Caret (>) to view child subjects. +

      +
      +
      +
      + +
      +
      +
      + + +

      {% trans "Hit Enter to add a new keyword." %}

      +
      +
      +
      + {% if additional_fields %} + {% for additional_field in additional_fields %} + {% get_form_field form additional_field.name as field %} +
      + {{ field|foundation }} +
      + {% endfor %} + {% endif %} + +
      + {{ form.comments_editor|foundation }} +
      + +
      +
      +
      + +
      +
      +
      +
      +
      +
      +
      +
      +

      Information

      +
      +
      + {{ request.repository.start|safe }} +
      +
      +
      +
      +{% endblock %} + +{% block js %} + + + + + + +{% endblock %} diff --git a/src/templates/admin/repository/submit/start.html b/src/templates/admin/repository/submit/start.html index c750d12f1e..75d09af936 100644 --- a/src/templates/admin/repository/submit/start.html +++ b/src/templates/admin/repository/submit/start.html @@ -2,126 +2,124 @@ {% load static %} {% load i18n %} {% load foundation %} -{% load field %} +{% load field dict %} {% block title-section %} - Create a new {{ request.repository.object_name }} + Create a new {{ request.repository.object_name }} {% endblock %} {% block breadcrumbs %} -{% include "admin/elements/breadcrumbs/repository_submission.html" with start=True %} + {% include "admin/elements/breadcrumbs/repository_submission.html" with start=True %} {% endblock %} +{% block css %} + +{% endblock %} {% block body %} -
      -
      -
      - {% csrf_token %} -
      -
      -
      -

      {% trans "Submission Agreement" %}

      -
      - {% include "elements/forms/errors.html" with form=form %} -
      -
      -
      - {{ request.repository.submission_agreement|safe }} - {{ form.submission_agreement }} -
      -
      -
      -
      -

      {% trans "Metadata" %}

      -
      -
      - {{ form.title|foundation }} -
      -
      - {{ form.submission_type|foundation }} -
      -
      - {{ form.abstract|foundation }} - -
      -
      - {{ form.license|foundation }} -
      -
      - {{ form.doi|foundation }} -
      - -
      -
      -
      - -
        - {% include "admin/elements/repository/tree.html" with subjects=request.repository.top_level_subjects %} -
      -

      - Press the Caret (>) to view child subjects. -

      -
      -
      -
      - -
      -
      -
      - - -

      {% trans "Hit Enter to add a new keyword." %}

      -
      -
      -
      - {% if additional_fields %} - {% for additional_field in additional_fields %} - {% get_form_field form additional_field.name as field %} -
      - {{ field|foundation }} -
      - {% endfor %} - {% endif %} - -
      - {{ form.comments_editor|foundation }} -
      - -
      -
      -
      - -
      -
      -
      -
      -
      -
      -
      -
      -

      Information

      -
      -
      - {{ request.repository.start|safe }} -
      -
      +
      +
      +
      + {% csrf_token %} +
      +
      +

      {% trans "Submission Agreement" %}

      +
      + + {% include "elements/forms/errors.html" with form=form %} +
      + {{ request.repository.submission_agreement|safe }} + {{ form.submission_agreement }} + +
      + +
      +

      {% trans "Submission Type" %}

      +
      + +

      {{ form.submission_type.label }}

      + +
      + {% for radio in form.submission_type %} + + {% endfor %} +
      + +
      +

      {{ request.repository.rou_default_name }}

      +
      + +

      {{ form.organisation_unit.help_text }}

      +
      +{% for radio in form.organisation_unit %} + {% with radio.data.value|stringformat:"s" as val %} + {% with form.ou_depth_map|get:val as depth %} + + {% endwith %} + {% endwith %} +{% endfor %} + +
      + + +
      +
      -{% endblock %} - -{% block js %} - - - - - - +
      {% endblock %} From 2c8b308851a683bea701deafa5cfa2ad3316c0dc Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 12 Jun 2025 14:10:51 +0100 Subject: [PATCH 05/76] fix: only show enabled subjects --- src/repository/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/repository/views.py b/src/repository/views.py index 7689e99136..f1e6b529a7 100644 --- a/src/repository/views.py +++ b/src/repository/views.py @@ -96,6 +96,7 @@ def repository_home( # Fetch subjects related to the repository subjects = models.Subject.objects.filter( repository=repository, + enabled=True, ).prefetch_related("preprint_set") template = "repository/home.html" From a5215bc8115f9e513357b015688c74c9eb2e12c8 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Fri, 20 Oct 2023 11:47:49 +0100 Subject: [PATCH 06/76] Changes to core for the isolinear plugin. --- src/core/templatetags/hooks.py | 11 ++++++++--- src/templates/admin/review/unassigned.html | 1 - src/templates/admin/review/unassigned_article.html | 4 +++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/core/templatetags/hooks.py b/src/core/templatetags/hooks.py index 7dffd10a1c..c2d92d7e97 100755 --- a/src/core/templatetags/hooks.py +++ b/src/core/templatetags/hooks.py @@ -17,9 +17,14 @@ def hook(context, hook_name, *args, **kwargs): for hook in settings.PLUGIN_HOOKS.get(hook_name, []): hook_module = import_module(hook.get("module")) function = getattr(hook_module, hook.get("function")) - html = html + function(context, *args, **kwargs) + hook_output = function(context, *args, **kwargs) - return mark_safe(html) + if hook_output: + html = html + hook_output + if html: + return mark_safe(html) except Exception as e: logger.error("Error rendering hook {0}: {1}".format(hook_name, e)) - return "" + if settings.DEBUG: + return f"[DEBUG] Error rendering hook output: {e}" + return "" diff --git a/src/templates/admin/review/unassigned.html b/src/templates/admin/review/unassigned.html index c0f62af28b..4eeb3fbe2d 100644 --- a/src/templates/admin/review/unassigned.html +++ b/src/templates/admin/review/unassigned.html @@ -1,6 +1,5 @@ {% extends "admin/core/base.html" %} - {% block title %}Unassigned Articles{% endblock %} {% block breadcrumbs %} diff --git a/src/templates/admin/review/unassigned_article.html b/src/templates/admin/review/unassigned_article.html index 0ff9802808..87e4251f4e 100644 --- a/src/templates/admin/review/unassigned_article.html +++ b/src/templates/admin/review/unassigned_article.html @@ -1,5 +1,6 @@ {% extends "admin/core/base.html" %} -{% load static roles i18n securitytags %} +{% load static roles i18n securitytags hooks %} + {% block title %}Unassigned {{ article.title }}{% endblock %} {% block title-section %}Unassigned{% endblock %} @@ -284,6 +285,7 @@

      Actions

      class="fa fa-check-circle action-icon"> Accept Article
    •  Decline Article
    • + {% hook 'unassigned_additional_actions' %}

    {% else %}
    From 0efd6eedb9adb2a9c5966668e4756b8470627ce6 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 7 Nov 2023 16:23:03 +0000 Subject: [PATCH 07/76] Updated hooks to return a default value rather than none. --- src/core/templatetags/hooks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/templatetags/hooks.py b/src/core/templatetags/hooks.py index c2d92d7e97..010f4e7036 100755 --- a/src/core/templatetags/hooks.py +++ b/src/core/templatetags/hooks.py @@ -11,7 +11,7 @@ @register.simple_tag(takes_context=True) -def hook(context, hook_name, *args, **kwargs): +def hook(context, hook_name, default_value='', *args, **kwargs): try: html = "" for hook in settings.PLUGIN_HOOKS.get(hook_name, []): @@ -27,4 +27,4 @@ def hook(context, hook_name, *args, **kwargs): logger.error("Error rendering hook {0}: {1}".format(hook_name, e)) if settings.DEBUG: return f"[DEBUG] Error rendering hook output: {e}" - return "" + return mark_safe(default_value) From f6f20b57ea30b78489124f36b691f89707d09a51 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 7 Nov 2023 16:24:41 +0000 Subject: [PATCH 08/76] Add option for identifiers to link to preprint version and reviews. --- src/identifiers/admin.py | 23 ++++++++++--- .../migrations/0010_auto_20231103_1426.py | 32 +++++++++++++++++++ src/identifiers/models.py | 26 +++++++++++++-- 3 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 src/identifiers/migrations/0010_auto_20231103_1426.py diff --git a/src/identifiers/admin.py b/src/identifiers/admin.py index 7a76a1286b..ade11bd753 100755 --- a/src/identifiers/admin.py +++ b/src/identifiers/admin.py @@ -45,8 +45,17 @@ class IdentifierAdmin(admin_utils.ArticleFKModelAdmin): search_fields = ("pk", "id_type", "identifier", "article__title") raw_id_fields = ("article",) + def _article(self, obj): + if obj.article: + return obj.article + elif obj.review: + return obj.review.article + else: + return '' + def _article_url(self, obj): - return obj.article.url if obj else "" + if obj and obj.article: + return obj.article.url def _registration_status(self, obj): if obj and obj.crossrefstatus: @@ -68,7 +77,9 @@ class CrossrefStatusAdmin(admin.ModelAdmin): readonly_fields = ("deposits", "message") def _journal(self, obj): - return obj.identifier.article.journal if obj else "" + if obj and obj.identifier.article: + return obj.identifier.article.journal + return "" class CrossrefDepositAdmin(admin.ModelAdmin): @@ -97,8 +108,12 @@ class CrossrefDepositAdmin(admin.ModelAdmin): list_select_related = True def _journal(self, obj): - if obj and obj.crossrefstatus_set.first(): - return obj.crossrefstatus_set.first().identifier.article.journal + fist_status = obj.crossrefstatus_set.first() + + if obj and fist_status and fist_status.identifier.article: + return fist_status.identifier.article.journal + elif obj and fist_status and fist_status.identifier.review: + return fist_status.identifier.review.article.journal else: return "" diff --git a/src/identifiers/migrations/0010_auto_20231103_1426.py b/src/identifiers/migrations/0010_auto_20231103_1426.py new file mode 100644 index 0000000000..cc0acfadb4 --- /dev/null +++ b/src/identifiers/migrations/0010_auto_20231103_1426.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.20 on 2023-11-03 14:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('repository', '0041_auto_20231102_1737'), + ('submission', '0073_bleach_title_20230523_1804'), + ('review', '0021_auto_20230530_1442'), + ('identifiers', '0009_deduplicate_identifiers_20220527'), + ] + + operations = [ + migrations.AddField( + model_name='identifier', + name='preprint_version', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='repository.preprintversion'), + ), + migrations.AddField( + model_name='identifier', + name='review', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='review.reviewassignment'), + ), + migrations.AlterField( + model_name='identifier', + name='article', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='submission.article'), + ), + ] diff --git a/src/identifiers/models.py b/src/identifiers/models.py index 320c9ea1d4..2445309a37 100755 --- a/src/identifiers/models.py +++ b/src/identifiers/models.py @@ -259,7 +259,26 @@ class Identifier(models.Model): id_type = models.CharField(max_length=300, choices=identifier_choices) identifier = models.CharField(max_length=300) enabled = models.BooleanField(default=True) - article = models.ForeignKey("submission.Article", on_delete=models.CASCADE) + article = models.ForeignKey( + "submission.Article", + on_delete=models.CASCADE, + null=True, + blank=True, + ) + preprint_version = models.ForeignKey( + "repository.PreprintVersion", + on_delete=models.CASCADE, + null=True, + blank=True, + ) + review = models.ForeignKey( + "review.ReviewAssignment", + on_delete=models.CASCADE, + null=True, + blank=True, + ) + + # TODO: Add validation to ensure only one FK is filled. def __str__(self): return "[{0}]: {1}".format(self.id_type.upper(), self.identifier) @@ -281,9 +300,12 @@ def get_doi_url(self): def is_doi(self): if self.id_type == "doi": return True - return False + @property + def _object(self): + return self.article or self.preprint_version or self.review or None + class BrokenDOI(models.Model): article = models.ForeignKey( From 019089414385d2c52368488878e5a0939e34f977 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 7 Nov 2023 16:33:32 +0000 Subject: [PATCH 09/76] Updates to identifiers. --- src/identifiers/logic.py | 114 ++------------------------- src/identifiers/preprints.py | 114 +++++++++++++++++++++++++++ src/identifiers/reviews.py | 145 +++++++++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+), 106 deletions(-) create mode 100644 src/identifiers/preprints.py create mode 100644 src/identifiers/reviews.py diff --git a/src/identifiers/logic.py b/src/identifiers/logic.py index 646ec67b6c..30f9966c15 100755 --- a/src/identifiers/logic.py +++ b/src/identifiers/logic.py @@ -38,11 +38,16 @@ def register_crossref_doi(identifier): return register_batch_of_crossref_dois([identifier.article]) -def register_batch_of_crossref_dois(articles, **kwargs): +def check_deposits_from_same_journal(articles): journals = set([article.journal for article in articles]) if len(journals) > 1: - status = "Articles must all be from the same journal" - error = True + return "Articles must all be from the same journal", True, journals + return "All articles from same journal", False, journals + + +def register_batch_of_crossref_dois(articles, **kwargs): + status, error, journals = check_deposits_from_same_journal(articles) + if error: logger.debug(status) return status, error else: @@ -657,109 +662,6 @@ def preview_registration_information(article): return "" -def get_preprint_tempate_context(request, identifier): - raise DeprecationWarning("Not used.") - article = identifier.article - - template_context = { - "batch_id": uuid4(), - "timestamp": int( - round( - ( - datetime.datetime.now() - datetime.datetime(1970, 1, 1) - ).total_seconds() - ) - ), - "depositor_name": request.press.name, - "depositor_email": request.press.main_contact, - "registrant": request.press.name, - "journal_title": request.press.name, - "journal_issn": "", - "journal_month": identifier.article.date_published.month, - "journal_day": identifier.article.date_published.day, - "journal_year": identifier.article.date_published.year, - "journal_volume": 0, - "journal_issue": 0, - "article_title": "{0}{1}{2}".format( - identifier.article.title, - " " if identifier.article.subtitle is not None else "", - identifier.article.subtitle - if identifier.article.subtitle is not None - else "", - ), - "authors": identifier.article.author_accounts.all(), - "article_month": identifier.article.date_published.month, - "article_day": identifier.article.date_published.day, - "article_year": identifier.article.date_published.year, - "doi": identifier.identifier, - "article_url": reverse("preprints_article", kwargs={"article_id": article.pk}), - } - - return template_context - - -def register_preprint_doi(request, crossref_enabled, identifier): - """ - Registers a preprint doi with crossref, has its own function as preprints dont have things like issues. - :param identifier: Identifier object - :return: Nothing - """ - raise DeprecationWarning("Not used.") - if not crossref_enabled: - messages.add_message( - request, - messages.WARNING, - "Crossref DOIs are not enabled for this preprint service.", - ) - else: - # Set the URL for depositing based on whether we are in test mode - if request.press.get_setting_value("Crossref Test Mode") == "On": - url = CROSSREF_TEST_URL - else: - url = CROSSREF_LIVE_URL - - template_context = get_preprint_tempate_context(request, identifier) - template = "common/identifiers/crossref.xml" - rendered = render_to_string(template, template_context) - - pdfs = identifier.article.pdfs - if len(pdfs) > 0: - template_context["pdf_url"] = identifier.article.pdf_url - - response = requests.post( - url, - data=rendered.encode("utf-8"), - auth=( - request.press.get_setting_value("Crossref Login"), - request.press.get_setting_value("Crossref Password"), - ), - headers={"Content-Type": "application/vnd.crossref.deposit+xml"}, - ) - - if response.status_code != 200: - util_models.LogEntry.add_entry( - "Error", - "Error depositing: {0}. {1}".format( - response.status_code, response.text - ), - "Debug", - target=identifier.article, - ) - logger.error("Error depositing: {}".format(response.status_code)) - logger.error(response.text) - else: - token = response.json()["message"]["batch-id"] - status = response.json()["message"]["status"] - util_models.LogEntry.add_entry( - "Submission", - "Deposited {0}. Status: {1}".format(token, status), - "Info", - target=identifier.article, - ) - logger.info( - "Status of {} in {}: {}".format(token, identifier.identifier, status) - ) - def generate_issue_doi_from_logic(issue): doi_prefix = setting_handler.get_setting( diff --git a/src/identifiers/preprints.py b/src/identifiers/preprints.py new file mode 100644 index 0000000000..89f02f43e9 --- /dev/null +++ b/src/identifiers/preprints.py @@ -0,0 +1,114 @@ +import requests +from uuid import uuid4 +from datetime import datetime +from crossref.restful import Depositor + +from django.template.loader import render_to_string + +from identifiers import models, logic +from utils.logger import get_logger +from utils import models as util_models + +logger = get_logger(__name__) + + +def check_repository_crossref_settings(repository): + settings = [ + repository.crossref_username, + repository.crossref_password, + repository.crossref_depositor_name, + repository.crossref_depositor_email, + repository.crossref_registrant, + repository.crossref_prefix, + ] + if any(settings) is None: + return False, 'Some crossref settings are missing.' + return True, '' + + +def get_dois_for_preprint_versions(preprint_versions): + identifiers = [] + for preprint_version in preprint_versions: + identifier, c = models.Identifier.objects.get_or_create( + id_type='doi', + preprint_version=preprint_version, + defaults={ + 'identifier': preprint_version.get_doi_pattern(), + } + ) + identifiers.append(identifier) + return identifiers + + +def send_preprint_version_crossref_deposit(repository, versions, identifiers): + identifiers = set((i for i in identifiers)) + template = 'common/identifiers/crossref_preprint_batch.xml' + template_context = { + 'versions': versions, + 'batch_id': uuid4(), + 'repository': repository, + 'now': datetime.now(), + } + document = render_to_string( + template, + template_context, + ) + from pprint import pprint + pprint(document) + filename = uuid4() + crossref_deposit = models.CrossrefDeposit.objects.create( + document=document, + file_name=filename, + ) + for identifier in identifiers: + crossref_status, c = models.CrossrefStatus.objects.get_or_create( + identifier=identifier, + ) + crossref_status.deposits.add(crossref_deposit) + depositor = Depositor( + prefix=repository.crossref_prefix, + api_user=repository.crossref_username, + api_key=repository.crossref_password, + use_test_server=repository.crossref_test_mode, + ) + try: + response = depositor.register_doi( + submission_id=filename, + request_xml=crossref_deposit.document, + ) + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: + status = 'Error depositing. Could not connect to Crossref ({0}). Error: {1}'.format( + depositor.get_endpoint(verb='deposit'), + e, + ) + crossref_deposit.result_text = status + crossref_deposit.save() + logger.error(status) + return status, e + if response.status_code == 200: + status = f"Deposit sent ({repository.short_name})" + util_models.LogEntry.bulk_add_simple_entry( + 'Submission', + status, + 'Info', + targets=versions, + ) + logger.info(status) + for identifier in identifiers: + crossref_status = models.CrossrefStatus.objects.get( + identifier=identifier, + ) + crossref_status.update() + + +def deposit_doi_for_preprint_version(repository, preprint_versions): + if repository.crossref_enable: + status, error = check_repository_crossref_settings(repository) + + if not error: + identifiers = get_dois_for_preprint_versions(preprint_versions) + return send_preprint_version_crossref_deposit( + repository, + preprint_versions, + identifiers + ) diff --git a/src/identifiers/reviews.py b/src/identifiers/reviews.py new file mode 100644 index 0000000000..ef4ca0b7ad --- /dev/null +++ b/src/identifiers/reviews.py @@ -0,0 +1,145 @@ +import requests +from uuid import uuid4 +from datetime import datetime +from crossref.restful import Depositor + +from django.template.loader import render_to_string + +from identifiers import models, logic +from utils.logger import get_logger +from utils import models as util_models + +logger = get_logger(__name__) + + +def deposit_doi_for_reviews(journal, reviews): + status, error, journals = logic.check_deposits_from_same_journal( + [review.article for review in reviews] + ) + if error: + logger.debug(status) + return status, error + use_crossref, mode, missing_settings = logic.check_crossref_settings( + journal + ) + # Filter out non-accepted articles + reviews = filter_non_accepted_articles(reviews) + identifiers = get_dois_for_reviews(reviews) + if use_crossref and not missing_settings: + return send_review_crossref_deposit( + mode, + reviews, + identifiers, + journal, + ) + + +def get_dois_for_reviews(reviews): + identifiers = [] + for review in reviews: + identifier, c = models.Identifier.objects.get_or_create( + id_type='doi', + review=review, + defaults={ + 'identifier': review.get_doi_pattern(), + } + ) + identifiers.append(identifier) + return identifiers + + +def filter_non_accepted_articles(reviews): + pks_to_exclude = list() + for review in reviews: + if not review.article.is_accepted(): + pks_to_exclude.append(review.pk) + return reviews.exclude(pk__in=pks_to_exclude) + + +def send_review_crossref_deposit(mode, reviews, identifiers, journal): + # Form a set from the iterable passed in + identifiers = set((i for i in identifiers)) + template = 'common/identifiers/crossref_review_batch.xml' + template_context = { + 'reviews': reviews, + 'batch_id': uuid4(), + 'now': datetime.now(), + 'timestamp_suffix': journal.get_setting( + 'crossref', + 'crossref_date_suffix', + ), + 'depositor_name': journal.get_setting( + 'Identifiers', + 'crossref_name', + ), + 'depositor_email': journal.get_setting( + 'Identifiers', + 'crossref_email', + ), + 'registrant': journal.get_setting( + 'Identifiers', + 'crossref_registrant', + ), + } + document = render_to_string( + template, + template_context, + ) + filename = uuid4() + crossref_deposit = models.CrossrefDeposit.objects.create( + document=document, + file_name=filename, + ) + for identifier in identifiers: + crossref_status, c = models.CrossrefStatus.objects.get_or_create( + identifier=identifier, + ) + crossref_status.deposits.add(crossref_deposit) + + doi_prefix = journal.get_setting( + 'Identifiers', + 'crossref_prefix', + ) + username = journal.get_setting( + 'Identifiers', + 'crossref_username', + ) + password = journal.get_setting( + 'Identifiers', + 'crossref_password', + ) + depositor = Depositor( + prefix=doi_prefix, + api_user=username, + api_key=password, + use_test_server=mode, + ) + try: + response = depositor.register_doi( + submission_id=filename, + request_xml=crossref_deposit.document, + ) + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: + status = 'Error depositing. Could not connect to Crossref ({0}). Error: {1}'.format( + depositor.get_endpoint(verb='deposit'), + e, + ) + crossref_deposit.result_text = status + crossref_deposit.save() + logger.error(status) + return status, e + + if response.status_code == 200: + status = f"Deposit sent ({journal.code})" + util_models.LogEntry.bulk_add_simple_entry( + 'Submission', + status, + 'Info', + targets=reviews, + ) + logger.info(status) + for identifier in identifiers: + crossref_status = models.CrossrefStatus.objects.get( + identifier=identifier, + ) + crossref_status.update() From 9dc41d3796f450ea9866f2e081db567706916485 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 7 Nov 2023 16:33:40 +0000 Subject: [PATCH 10/76] Updates to journal. --- src/journal/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/journal/models.py b/src/journal/models.py index 731dd6d7f5..7199ee8fe4 100644 --- a/src/journal/models.py +++ b/src/journal/models.py @@ -1052,7 +1052,10 @@ def issue_title_parts(self, article=None): if journal.display_issue_number and self.issue and self.issue != "0": issue = "{%% trans 'Issue' %%} %s" % self.issue if journal.display_issue_year and self.date: - year = "{}".format(self.date.year) + try: + year = "{}".format(self.date.year) + except AttributeError: + year = '' if journal.display_issue_title: issue_title = self.issue_title if journal.display_article_number and article and article.article_number: From 5af87adae1a0ba80f3b2a2ac303eec84b1e37ac4 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 7 Nov 2023 16:33:54 +0000 Subject: [PATCH 11/76] Updates to repository. --- src/repository/decorators.py | 31 +++++++++++++++++ src/repository/forms.py | 1 + src/repository/models.py | 66 ++++++++++++++++++++++++++++++++++++ src/repository/views.py | 17 ++++++++-- 4 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 src/repository/decorators.py diff --git a/src/repository/decorators.py b/src/repository/decorators.py new file mode 100644 index 0000000000..74527e5aa5 --- /dev/null +++ b/src/repository/decorators.py @@ -0,0 +1,31 @@ +from functools import wraps + +from django.shortcuts import redirect, reverse +from django.contrib import messages + + +def headless_mode_check(func): + """ + If request.repository.headless_mode is enabled this decorator + redirects to the repo dashboard. + :param func: the function to callback from the decorator + :return: either the function call or raises an HttpRedirect + """ + + @wraps(func) + def headless_mode_check_wrapper(request, *args, **kwargs): + if request.repository and request.repository.headless_mode: + messages.add_message( + request, + messages.INFO, + 'Redirected to dashboard. This repository runs in headless' + ' mode.', + ) + return redirect( + reverse( + 'repository_dashboard' + ) + ) + return func(request, *args, **kwargs) + + return headless_mode_check_wrapper diff --git a/src/repository/forms.py b/src/repository/forms.py index dfcb9e95ba..f1c71eed65 100755 --- a/src/repository/forms.py +++ b/src/repository/forms.py @@ -539,6 +539,7 @@ class RepositorySite(RepositoryBase): class Meta: model = models.Repository fields = ( + 'headless_mode', 'about', 'logo', 'hero_background', diff --git a/src/repository/models.py b/src/repository/models.py index 003f663fce..f7687feb7b 100755 --- a/src/repository/models.py +++ b/src/repository/models.py @@ -34,6 +34,7 @@ from utils.function_cache import cache from submission import models as submission_models from events import logic as event_logic +from identifiers import models as identifier_models STAGE_PREPRINT_UNSUBMITTED = "preprint_unsubmitted" @@ -286,6 +287,49 @@ class Repository(model_utils.AbstractSiteModel): "to explore different units and their associated preprints.

    ", help_text="Text that displays on the organisational unit page.", ) + headless_mode = models.BooleanField( + default=False, + help_text='Enable this feature to make this repository run in headless' + ' mode, with no front end.', + ) + crossref_enable = models.BooleanField( + default=False, + help_text='Enable to use crossref. All other fields must be complete.', + ) + crossref_username = models.CharField( + max_length=255, + blank=True, + null=True, + ) + crossref_password = models.CharField( + max_length=255, + blank=True, + null=True, + ) + crossref_depositor_name = models.CharField( + max_length=255, + blank=True, + null=True, + ) + crossref_depositor_email = models.EmailField( + max_length=255, + blank=True, + null=True, + ) + crossref_registrant = models.CharField( + max_length=255, + blank=True, + null=True, + ) + crossref_prefix = models.CharField( + max_length=255, + blank=True, + null=True, + ) + crossref_test_mode = models.BooleanField( + default=False, + help_text='Enable to use Crossref test.', + ) class Meta: verbose_name_plural = "repositories" @@ -1394,6 +1438,28 @@ def safe_title(self): def __str__(self): return f"{self.preprint} (version {self.version})" + def get_doi_pattern(self): + return f"{self.preprint.repository.crossref_prefix}/{self.preprint.repository.short_name}.{self.preprint.pk}.v{self.pk}" + + def get_doi(self, _object=False): + try: + try: + doi = identifier_models.Identifier.objects.get( + id_type='doi', + preprint_version=self + ) + except identifier_models.Identifier.MultipleObjectsReturned: + doi = identifier_models.Identifier.objects.filter( + id_type='doi', + preprint_version=self, + ).first() + if not _object: + return doi.identifier + else: + return doi + except identifier_models.Identifier.DoesNotExist: + return None + class Comment(models.Model): author = models.ForeignKey( diff --git a/src/repository/views.py b/src/repository/views.py index f1e6b529a7..c36783f2df 100644 --- a/src/repository/views.py +++ b/src/repository/views.py @@ -21,15 +21,21 @@ from django.utils.http import urlencode -from repository import forms, logic as repository_logic, models from core import ( email as core_email, files, + logic as core_logic, models as core_models, forms as core_forms, views as core_views, ) from journal import models as journal_models +from repository import ( + forms, + logic as repository_logic, + models, + decorators, +) from utils import ( logger, logic as utils_logic, @@ -49,7 +55,7 @@ logger = logger.get_logger(__name__) - +@decorators.headless_mode_check def repository_home( request, rou_code=None, @@ -305,6 +311,7 @@ def repository_author_article(request, preprint_id): return render(request, template, context) +@decorators.headless_mode_check def repository_about(request): """ Displays the about page with text about preprints @@ -315,6 +322,7 @@ def repository_about(request): return render(request, template, {}) +@decorators.headless_mode_check def repository_subject_list(request): """ Displays a list of enabled subjects for selection. @@ -398,6 +406,7 @@ def repository_list(request): }) +@decorators.headless_mode_check def repository_search(request, search_term=None): """ Redirects legacy search URL with optional search_term to the @@ -413,6 +422,8 @@ def repository_search(request, search_term=None): return redirect(f"{reverse('repository_list')}?{urlencode(query)}") + +@decorators.headless_mode_check def repository_preprint(request, preprint_id): """ Fetches a single article and displays its metadata @@ -534,7 +545,7 @@ def repository_pdf(request, preprint_id): return render(request, template, context) -# TODO: Re-implement +@decorators.headless_mode_check def preprints_editors(request): """ Displays lists of preprint editors by their subject group. From e12ca15d951d420c7f907d86db41c7c65127e99d Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 7 Nov 2023 16:34:01 +0000 Subject: [PATCH 12/76] Updates to review. --- src/review/forms.py | 8 +++++++- src/review/models.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/review/forms.py b/src/review/forms.py index 9c838406d1..e01ad7487d 100755 --- a/src/review/forms.py +++ b/src/review/forms.py @@ -430,14 +430,20 @@ class ReviewReminderForm(forms.Form): class ReviewVisibilityForm(forms.ModelForm): class Meta: model = models.ReviewAssignment - fields = ("for_author_consumption", "display_review_file") + fields = ( + "for_author_consumption", + "display_review_file", + "display_public", + ) labels = { "for_author_consumption": _("Author can access this review"), "display_review_file": _("Author can access review file"), + "display_public": _("Display Review Publicly") } widgets = { "for_author_consumption": HTMLSwitchInput(), "display_review_file": HTMLSwitchInput(), + "display_public": HTMLSwitchInput(), } def __init__(self, *args, **kwargs): diff --git a/src/review/models.py b/src/review/models.py index 56a0ca3110..c6eeb5f63d 100755 --- a/src/review/models.py +++ b/src/review/models.py @@ -18,6 +18,7 @@ VisibilityOptions as VO, ) from utils import shared +from identifiers import models as identifier_models, logic as id_logic assignment_choices = ( @@ -399,6 +400,48 @@ def visibility_statement(self): return _("available for the author to access") return _("not available for the author to access") + def decision_to_crossref(self): + """ + Maps a decision to Crossref deposit recommendations. + """ + if self.decision == RD.DECISION_ACCEPT.value: + return 'accept' + elif self.decision == RD.DECISION_MINOR.value: + return 'minor-revision' + elif self.decision == RD.DECISION_MAJOR.value: + return 'major-revision' + elif self.decision == RD.DECISION_REJECT.value: + return 'reject' + + def get_doi_pattern(self): + if self.article.is_accepted(): + article_pattern = self.article.get_doi() + return f"{article_pattern}.r{self.pk}" + return None + + def get_doi(self, _object=False): + try: + try: + doi = identifier_models.Identifier.objects.get( + id_type='doi', + review=self + ) + except identifier_models.Identifier.MultipleObjectsReturned: + doi = identifier_models.Identifier.objects.filter( + id_type='doi', + review=self, + ).first() + if not _object: + return doi.identifier + else: + return doi + except identifier_models.Identifier.DoesNotExist: + return None + + def register_doi(self): + if self.article.is_accepted(): + id_logic.register_review_doi(self.get_doi_pattern()) + def __str__(self): if self.reviewer: reviewer_name = self.reviewer.full_name() From cc53a5cd809c41e6a67ab31619e26b8dcd7a67d7 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 7 Nov 2023 16:34:22 +0000 Subject: [PATCH 13/76] Sundry updates. --- src/security/decorators.py | 4 ++ src/submission/models.py | 12 ++++ src/utils/install/journal_defaults.json | 19 +++++++ .../register_crossref_preprint_dois.py | 34 +++++++++++ .../commands/update_journal_workflows.py | 56 +++++++++++++++++++ 5 files changed, 125 insertions(+) create mode 100644 src/utils/management/commands/register_crossref_preprint_dois.py create mode 100644 src/utils/management/commands/update_journal_workflows.py diff --git a/src/security/decorators.py b/src/security/decorators.py index a45b181948..cf2adde817 100755 --- a/src/security/decorators.py +++ b/src/security/decorators.py @@ -746,6 +746,10 @@ def wrapper(request, *args, **kwargs): article_object = models.Article.get_article( request.journal, identifier_type, identifier ) + if article_object and article_object.journal.get_setting( + "general", "uses_isolinear_plugin", + ): + return func(request, *args, **kwargs) if article_object is None or not article_object.is_accepted(): deny_access(request) diff --git a/src/submission/models.py b/src/submission/models.py index 583d6bc87a..c446fbdf2e 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -2091,6 +2091,10 @@ def accept_article(self, stage=None): id = id_logic.generate_crossref_doi_with_pattern(self) if self.journal.register_doi_at_acceptance: id.register() + id_logic.deposit_doi_for_reviews( + self.journal, + self.completed_reviews_with_permission, + ) def decline_article(self): self.date_declined = timezone.now() @@ -2590,6 +2594,14 @@ def best_large_image_url(self): else: return static(settings.HERO_IMAGE_FALLBACK) + def abstract_display(self): + if self.is_published: + return self.abstract + return ( + "

    This is an accepted article with a DOI pre-assigned" + " that is not yet published.

    " + ) + (self.abstract or "") + class FrozenAuthorQueryset(model_utils.AffiliationCompatibleQueryset): AFFILIATION_RELATED_NAME = "frozen_author" diff --git a/src/utils/install/journal_defaults.json b/src/utils/install/journal_defaults.json index ff88ba275a..d84c52236f 100644 --- a/src/utils/install/journal_defaults.json +++ b/src/utils/install/journal_defaults.json @@ -5065,6 +5065,25 @@ "journal-manager" ] }, + { + "group": { + "name": "general" + }, + "setting": { + "description": "Enable this to show that this journal uses the isolinear plugin.", + "is_translatable": true, + "name": "uses_isolinear_plugin", + "pretty_name": "Journal Uses Isolinear Plugin", + "type": "boolean" + }, + "value": { + "default": "" + }, + "editable_by": [ + "journal-manager", + "editor" + ] + }, { "group": { "name": "general" diff --git a/src/utils/management/commands/register_crossref_preprint_dois.py b/src/utils/management/commands/register_crossref_preprint_dois.py new file mode 100644 index 0000000000..129d09011c --- /dev/null +++ b/src/utils/management/commands/register_crossref_preprint_dois.py @@ -0,0 +1,34 @@ +from django.core.management.base import BaseCommand + +from identifiers import preprints +from repository import models + + +class Command(BaseCommand): + """Registers a repository object version with Crossref.""" + + help = "Registers a repository object version with Crossref." + + def add_arguments(self, parser): + """Adds arguments to Django's management command-line parser. + + :param parser: the parser to which the required arguments will be added + :return: None + """ + parser.add_argument('version_id') + + def handle(self, *args, **options): + """Calls the Crossref registration options + + :param args: None + :param options: Dictionary containing 'article_id' + :return: None + """ + version = models.PreprintVersion.objects.get( + pk=options.get('version_id') + ) + if version: + preprints.deposit_doi_for_preprint_version( + version.preprint.repository, + [version], + ) diff --git a/src/utils/management/commands/update_journal_workflows.py b/src/utils/management/commands/update_journal_workflows.py new file mode 100644 index 0000000000..463f2c7a59 --- /dev/null +++ b/src/utils/management/commands/update_journal_workflows.py @@ -0,0 +1,56 @@ +from django.core.management.base import BaseCommand + +from journal import models as jm +from core import models as cm + + +class Command(BaseCommand): + help = 'Loops through all journals adding the Typesetting plugin' \ + ' to workflows' + + def handle(self, *args, **options): + try: + from plugins.typesetting import plugin_settings + except ImportError: + exit("The typesetting plugin could not be imported.") + + journals = jm.Journal.objects.all() + + for journal in journals: + workflow = cm.Workflow.objects.get( + journal=journal + ) + elements_to_remove = ['production', 'proofing'] + + cm.WorkflowElement.objects.filter( + journal=journal, + element_name__in=elements_to_remove, + ).delete() + + ts_element, c = cm.WorkflowElement.objects.get_or_create( + journal=journal, + element_name=plugin_settings.PLUGIN_NAME, + defaults={ + 'handshake_url': plugin_settings.HANDSHAKE_URL, + 'jump_url': plugin_settings.JUMP_URL, + 'stage': plugin_settings.STAGE, + } + ) + workflow.elements.add(ts_element) + + stage_order = [ + 'review', + 'copyediting', + 'Typesetting Plugin', + 'prepublication', + ] + + journal_elements = cm.WorkflowElement.objects.filter( + journal=journal, + ) + print(journal_elements) + + for element in journal_elements: + order = stage_order.index(element.element_name) + element.order = order + element.save() From 7ee6f8f26c2d1975b5e0bf2b13f7b0f87c747bcf Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 7 Nov 2023 16:34:30 +0000 Subject: [PATCH 14/76] Template updates. --- .../admin/elements/article_jump.html | 3 +- src/templates/admin/review/in_review.html | 68 ++++++++++-------- .../common/identifiers/crossref_preprint.xml | 29 ++++++++ .../identifiers/crossref_preprint_batch.xml | 19 +++++ .../common/identifiers/crossref_review.xml | 37 ++++++++++ .../identifiers/crossref_review_batch.xml | 19 +++++ src/templates/common/pdf.html | 72 +++++++++++++++++++ .../elements/journal/box_article.html | 1 + .../templates/elements/public_reviews.html | 9 ++- src/themes/OLH/templates/journal/article.html | 52 +++++++++----- .../templates/elements/public_reviews.html | 6 +- .../templates/repository/preprint.html | 7 +- .../commands/register_crossref_review_dois.py | 34 +++++++++ 13 files changed, 301 insertions(+), 55 deletions(-) create mode 100644 src/templates/common/identifiers/crossref_preprint.xml create mode 100644 src/templates/common/identifiers/crossref_preprint_batch.xml create mode 100644 src/templates/common/identifiers/crossref_review.xml create mode 100644 src/templates/common/identifiers/crossref_review_batch.xml create mode 100644 src/templates/common/pdf.html create mode 100644 src/utils/management/commands/register_crossref_review_dois.py diff --git a/src/templates/admin/elements/article_jump.html b/src/templates/admin/elements/article_jump.html index 4ba88097ca..cd314e8c24 100644 --- a/src/templates/admin/elements/article_jump.html +++ b/src/templates/admin/elements/article_jump.html @@ -1,4 +1,4 @@ -{% load securitytags %} +{% load securitytags hooks %} {% load next_url %} {% is_editor as editor %} @@ -37,6 +37,7 @@
  • Admin
  • {% endif %} + {% hook 'logs_documents' %}
    diff --git a/src/templates/admin/review/in_review.html b/src/templates/admin/review/in_review.html index e5fb18e5d6..5ef7ca2b5b 100644 --- a/src/templates/admin/review/in_review.html +++ b/src/templates/admin/review/in_review.html @@ -1,6 +1,5 @@ {% extends "admin/core/base.html" %} -{% load static itertools roles securitytags %} -{% load hooks %} +{% load static itertools roles securitytags hooks %} {% block title %}Review {{ article.title }}{% endblock %} {% block title-section %}Peer Review{% endblock %} @@ -273,36 +272,45 @@

    Actions

    {% user_has_role request 'editor' as editor %}
    diff --git a/src/templates/common/identifiers/crossref_preprint.xml b/src/templates/common/identifiers/crossref_preprint.xml new file mode 100644 index 0000000000..cbbb64f1a3 --- /dev/null +++ b/src/templates/common/identifiers/crossref_preprint.xml @@ -0,0 +1,29 @@ + + {{ version.preprint.repository.name }} + + + {% for author in version.preprint.preprintauthor_set.all %} + + {{ author.account.first_name }} + {{ author.account.last_name }} + {% if author.account.orcid %}https://orcid.org/{{ author.account.orcid }}{% endif %} + + {% endfor %} + + + {{ version.preprint.title }} + + + {{ version.date_time.month }} + {{ version.date_time.day }} + {{ version.date_time.year }} + + + + {{ version.preprint.license.url }} + + + {{ version.get_doi }} + {{ version.preprint.url }}?version={{ version.version }} + + \ No newline at end of file diff --git a/src/templates/common/identifiers/crossref_preprint_batch.xml b/src/templates/common/identifiers/crossref_preprint_batch.xml new file mode 100644 index 0000000000..d7af8fc200 --- /dev/null +++ b/src/templates/common/identifiers/crossref_preprint_batch.xml @@ -0,0 +1,19 @@ + + + + {{ batch_id }} + {{ now|date:"YmdHis" }} + + {{ repository.crossref_depositor_name }} + {{ repository.crossref_depositor_email }} + + {{ repository.crossref_registrant }} + + + {% for version in versions %} + {% include "common/identifiers/crossref_preprint.xml" %} + {% endfor %} + + \ No newline at end of file diff --git a/src/templates/common/identifiers/crossref_review.xml b/src/templates/common/identifiers/crossref_review.xml new file mode 100644 index 0000000000..362f74dc85 --- /dev/null +++ b/src/templates/common/identifiers/crossref_review.xml @@ -0,0 +1,37 @@ + + + + {{ review.reviewer.first_name }} + {{ review.reviewer.last_name }} + + + + Review: {{ review.article.title }} + + + {{ review.date_complete.month }} + {{ review.date_complete.day }} + {{ review.date_complete.year }} + + There were no competing interests + + {{ review.pk }} + + + Referee report of {{ article.title }} + + + {{ review.article.get_doi }} + + + + + {{ review.get_doi }} + + {{ review.article.url }} + + + \ No newline at end of file diff --git a/src/templates/common/identifiers/crossref_review_batch.xml b/src/templates/common/identifiers/crossref_review_batch.xml new file mode 100644 index 0000000000..7cff0a613e --- /dev/null +++ b/src/templates/common/identifiers/crossref_review_batch.xml @@ -0,0 +1,19 @@ + + + + {{ batch_id }} + {{ now|date:"YmdHis" }}{{ timestamp_suffix }} + + {{ depositor_name }} + {{ depositor_email }} + + {{ registrant }} + + + {% for review in reviews %} + {% include "common/identifiers/crossref_review.xml" %} + {% endfor %} + + diff --git a/src/templates/common/pdf.html b/src/templates/common/pdf.html new file mode 100644 index 0000000000..3ea697d976 --- /dev/null +++ b/src/templates/common/pdf.html @@ -0,0 +1,72 @@ +{% load static %} + + + + + + PDF Proofing + + + + + + +
    +
    +
    + + + + diff --git a/src/themes/OLH/templates/elements/journal/box_article.html b/src/themes/OLH/templates/elements/journal/box_article.html index 85ac1f7ab5..dd72a71a5e 100644 --- a/src/themes/OLH/templates/elements/journal/box_article.html +++ b/src/themes/OLH/templates/elements/journal/box_article.html @@ -49,6 +49,7 @@

    {{ article.title|safe }}

    {% endif %} + {% include "elements/journal/authors_block.html" %}

    diff --git a/src/themes/OLH/templates/elements/public_reviews.html b/src/themes/OLH/templates/elements/public_reviews.html index 8234492921..799b8ca6e9 100644 --- a/src/themes/OLH/templates/elements/public_reviews.html +++ b/src/themes/OLH/templates/elements/public_reviews.html @@ -7,14 +7,19 @@

    Open peer review from {{ review.reviewer.full_name }}

    +

    + Reviewer: {{ review.reviewer.full_name }}
    + DOI: {{ review.get_doi }} +

    {% for answer in review.review_form_answers %} {% if answer.author_can_see %} -

    - {{ answer.element.name }} +

    + {{ answer.element.name }}
    {{ answer.answer|safe|linebreaksbr }}

    + {% endif %} {% endfor %} {% blocktrans %} diff --git a/src/themes/OLH/templates/journal/article.html b/src/themes/OLH/templates/journal/article.html index bbdd15e95b..f50b8d00a9 100644 --- a/src/themes/OLH/templates/journal/article.html +++ b/src/themes/OLH/templates/journal/article.html @@ -158,8 +158,12 @@

    {{ article.title|safe }}

    {% endif %} {% if article.abstract and article.abstract != ''%} -

    {% trans "Abstract" %}

    -

    {{ article.abstract|safe }}

    +

    {% trans "Abstract" %}

    + {% if journal_settings.general.uses_isolinear_plugin and not article.is_published %} + {% hook 'preprint_abstract_block' article.abstract %} + {% else %} + {{ article.abstract_display|safe }} + {% endif %} {% endif %} {% if journal_settings.general.keyword_list_page %} {% include "elements/journal/article_keywords.html" with keywords=article.keywords linked="True" %} @@ -221,6 +225,7 @@

    {% trans 'Files' %}:

    {% endif %} {% include "elements/funder_info_for_readers.html" %} +
    {% if article.is_published or proofing %} {% if not request.journal.disable_metrics_display %} @@ -292,12 +297,14 @@

    {% trans 'Files' %}:

    {{ article_content|safe }} + {% hook 'article_content_block' %}
    {% hook 'article_footer_block' %}
    {% endif %} + {% hook 'article_content' %}
    @@ -351,25 +358,30 @@

    {% trans "Files" %}

    {% else %}

    {% trans 'Downloads are not available for this article.' %}

    {% endif %} + {% elif article.preprint %} +

    {% trans "Download" %}

    + {% hook 'preprint_version_downloads' %} {% else %}

    {% trans 'Downloads are not available until this article is published ' %}

    {% endif %} + {% if article.published or proofing %}
    {% include "elements/journal/article_issue_list.html" %}
    - {% with article.get_doi_url as doi_url %} - {% if doi_url %} -
    -

    {% trans "Identifiers" %}

    -
      -
    • - {% include "elements/doi_display.html" with doi=doi_url title=article.title %} -
    • -
    -
    - {% endif %} - {% endwith %} + {% endif %} + {% with article.get_doi_url as doi_url %} + {% if doi_url %} +
    +

    {% trans "Identifiers" %}

    +
      +
    • + {% include "elements/doi_display.html" with doi=doi_url title=article.title %} +
    • +
    +
    + {% endif %} + {% endwith %} {% if article.has_publication_details %}
    @@ -455,7 +467,7 @@

    {% trans field.field.name %}

    {% endfor %} - {% if article.preprint and article.preprint.is_published %} + {% if article.preprint and article.preprint.is_published and not journal_settings.general.uses_isolinear_plugin %}

    {{ article.preprint.repository.object_name }}

    This article is linked to a {{ article.preprint.repository.object_name }} in {{ article.preprint.repository.name }}.

    @@ -463,6 +475,7 @@

    {{ article.preprint.repository.object_name }}

    {% endif %} + {% if galleys %}

    {% trans "File Checksums" %} (MD5)

    {% if galleys %} @@ -477,6 +490,7 @@

    {% trans "File Checksums" %} (MD5)

    {% trans 'File Checksums are not available for this article.' %}

    {% endif %}
    + {% endif %} {% if journal_settings.general.use_credit and author.credits.exists %} diff --git a/src/themes/material/templates/elements/public_reviews.html b/src/themes/material/templates/elements/public_reviews.html index d53823da49..5ecf4b87d9 100644 --- a/src/themes/material/templates/elements/public_reviews.html +++ b/src/themes/material/templates/elements/public_reviews.html @@ -2,7 +2,11 @@ {% for review in article.public_reviews %} {% if article.date_published or article.date_accepted or proofing %} @@ -358,7 +360,7 @@

    {% trans "Files" %}

    {% else %}

    {% trans 'Downloads are not available for this article.' %}

    {% endif %} - {% elif article.preprint %} + {% elif journal_settings.general.uses_isolinear_plugin and article.preprint %}

    {% trans "Download" %}

    {% hook 'preprint_version_downloads' %} {% else %} From 46d02d4196a1f67aac64b092aa86794073025f98 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 8 Nov 2023 14:34:49 +0000 Subject: [PATCH 26/76] Crossref deposit now runs via an event, remove this. --- src/submission/models.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/submission/models.py b/src/submission/models.py index c446fbdf2e..83203cf91e 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -2091,10 +2091,6 @@ def accept_article(self, stage=None): id = id_logic.generate_crossref_doi_with_pattern(self) if self.journal.register_doi_at_acceptance: id.register() - id_logic.deposit_doi_for_reviews( - self.journal, - self.completed_reviews_with_permission, - ) def decline_article(self): self.date_declined = timezone.now() From 6c8ddb32f898825153347ecf133caa9e7dcbf922 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Fri, 10 Nov 2023 12:33:15 +0000 Subject: [PATCH 27/76] Adds various options including the option to set a license for open peer reviews. --- src/submission/forms.py | 5 +++++ ...nconfiguration_open_peer_review_license.py | 19 +++++++++++++++++++ src/submission/models.py | 8 ++++++++ .../templates/elements/public_reviews.html | 8 +++++++- 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/submission/migrations/0074_submissionconfiguration_open_peer_review_license.py diff --git a/src/submission/forms.py b/src/submission/forms.py index 0406bb6ea5..60eb71b6a9 100755 --- a/src/submission/forms.py +++ b/src/submission/forms.py @@ -413,6 +413,11 @@ def __init__(self, *args, **kwargs): self.fields["default_license"].queryset = models.Licence.objects.filter( journal=self.instance.journal, ) + self.fields[ + 'open_peer_review_license' + ].queryset = models.Licence.objects.filter( + journal=self.instance.journal, + ) def clean(self): cleaned_data = super().clean() diff --git a/src/submission/migrations/0074_submissionconfiguration_open_peer_review_license.py b/src/submission/migrations/0074_submissionconfiguration_open_peer_review_license.py new file mode 100644 index 0000000000..0423e5a5ae --- /dev/null +++ b/src/submission/migrations/0074_submissionconfiguration_open_peer_review_license.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.20 on 2023-11-10 11:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('submission', '0073_bleach_title_20230523_1804'), + ] + + operations = [ + migrations.AddField( + model_name='submissionconfiguration', + name='open_peer_review_license', + field=models.ForeignKey(blank=True, help_text='The license that is applied to open peer reviews.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='open_peer_review_license', to='submission.licence'), + ), + ] diff --git a/src/submission/models.py b/src/submission/models.py index 83203cf91e..dfe2135f03 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -3312,6 +3312,14 @@ class SubmissionConfiguration(models.Model): help_text=_("The default license applied when no option is presented"), on_delete=models.SET_NULL, ) + open_peer_review_license = models.ForeignKey( + Licence, + null=True, + blank=True, + help_text=_('The license that is applied to open peer reviews.'), + on_delete=models.SET_NULL, + related_name='open_peer_review_license', + ) default_language = models.CharField( max_length=200, null=True, diff --git a/src/themes/OLH/templates/elements/public_reviews.html b/src/themes/OLH/templates/elements/public_reviews.html index 799b8ca6e9..22e24fddac 100644 --- a/src/themes/OLH/templates/elements/public_reviews.html +++ b/src/themes/OLH/templates/elements/public_reviews.html @@ -9,7 +9,9 @@

    Open peer review from {

    Reviewer: {{ review.reviewer.full_name }}
    - DOI: {{ review.get_doi }} + {% if review.reviewer.orcid %}ORCID: orcid logo https://orcid.org/{{ review.reviewer.orcid }}
    {% endif %} + DOI: {{ review.get_doi }}
    + Date Completed: {{ review.date_complete|date:"Y-m-d" }}

    {% for answer in review.review_form_answers %} {% if answer.author_can_see %} @@ -22,6 +24,10 @@

    Open peer review from { {% endif %} {% endfor %} + {% if request.journal.submissionconfiguration.open_peer_review_license %} +

    License: {{ request.journal.submissionconfiguration.open_peer_review_license.name }}

    +

    {{ request.journal.submissionconfiguration.open_peer_review_license.text }}

    + {% endif %} {% blocktrans %}

    Note:
    This review refers to round {{ review.review_round.round_number }} of peer review and may pertain to an earlier version of the document.

    From a1552dc962b384c86ac75361532d918881462de7 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 16 Nov 2023 14:06:25 +0000 Subject: [PATCH 28/76] Alter article page default ordering. --- src/journal/views.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/journal/views.py b/src/journal/views.py index e9491ad805..be33d64d5e 100755 --- a/src/journal/views.py +++ b/src/journal/views.py @@ -3024,6 +3024,20 @@ def get_facets(self): } return self.filter_facets_if_journal(facets) + + def get_facet_queryset(self): + queryset = super().get_facet_queryset() + return queryset.filter( + date_published__lte=timezone.now(), + stage=submission_models.STAGE_PUBLISHED, + ) + + def get_order_by(self): + order_by = self.request.GET.get('order_by', '-date_published') + order_by_choices = self.get_order_by_choices() + return order_by if order_by in dict(order_by_choices) else '' + + def get_order_by_choices(self): return [ ("-date_published", _("Newest")), From e184ab801001f6a25a558ed722d0915292dc96a2 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Mon, 18 Dec 2023 09:49:42 +0000 Subject: [PATCH 29/76] Added a new stage log list method. --- src/submission/models.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/submission/models.py b/src/submission/models.py index dfe2135f03..7598331d76 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -1677,6 +1677,12 @@ def is_accepted(self): def in_review_stages(self): return self.stage in REVIEW_STAGES + @property + def stage_log_list(self): + return [ + stage.stage_to for stage in self.articlestagelog_set.all() + ] + def peer_reviews_for_author_consumption(self): return self.reviewassignment_set.filter( for_author_consumption=True, From b15ea2f02d74374ffdeb9d4e095aac22fe7b0a73 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 24 Jan 2024 15:26:46 +0000 Subject: [PATCH 30/76] Adding unstaged changes from infinity/uclpress. --- src/identifiers/admin.py | 2 +- src/journal/management/commands/galley_healthcheck.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/identifiers/admin.py b/src/identifiers/admin.py index ade11bd753..d904c27340 100755 --- a/src/identifiers/admin.py +++ b/src/identifiers/admin.py @@ -43,7 +43,7 @@ class IdentifierAdmin(admin_utils.ArticleFKModelAdmin): list_filter = ("article__journal", "id_type") list_display_links = ("identifier",) search_fields = ("pk", "id_type", "identifier", "article__title") - raw_id_fields = ("article",) + raw_id_fields = ("article", "preprint_version", "review") def _article(self, obj): if obj.article: diff --git a/src/journal/management/commands/galley_healthcheck.py b/src/journal/management/commands/galley_healthcheck.py index c30c6b0154..b5c557d12a 100644 --- a/src/journal/management/commands/galley_healthcheck.py +++ b/src/journal/management/commands/galley_healthcheck.py @@ -47,7 +47,7 @@ def handle(self, *args, **options): elif render_galley: images_url = retrieve_image_urls_from_galley(render_galley) for url in images_url: - response = requests.get(journal.site_url(path=url)) + response = requests.get(f"{article.url}{url}") if not response.ok or not len(response.content): print("[{}][MISSING IMAGE][{}]".format(article.pk, url)) From 1c87f15f9ac17e612541e180c3584c1de57e6141 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 24 Jan 2024 15:53:57 +0000 Subject: [PATCH 31/76] Add missing comma. --- src/repository/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/repository/admin.py b/src/repository/admin.py index eaae4e9df2..addd4c8bb8 100755 --- a/src/repository/admin.py +++ b/src/repository/admin.py @@ -87,6 +87,7 @@ class PreprintAdmin(admin.ModelAdmin): "date_submitted", "doi", "current_version", + "article", ) list_display_links = ("pk", "title") list_filter = ( From c37df199ebea4c14389cf5f87230ae5302a75511 Mon Sep 17 00:00:00 2001 From: Mauro MSL Date: Wed, 24 Jan 2024 17:50:10 +0100 Subject: [PATCH 32/76] Fix conflicting migrations --- src/identifiers/migrations/0010_auto_20231107_1750.py | 2 +- .../{0040_auto_20231107_1750.py => 0042_auto_20231107_1750.py} | 2 +- ...eprintversion_file.py => 0043_alter_preprintversion_file.py} | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/repository/migrations/{0040_auto_20231107_1750.py => 0042_auto_20231107_1750.py} (98%) rename src/repository/migrations/{0041_alter_preprintversion_file.py => 0043_alter_preprintversion_file.py} (89%) diff --git a/src/identifiers/migrations/0010_auto_20231107_1750.py b/src/identifiers/migrations/0010_auto_20231107_1750.py index ea754e5e64..0b563f8f0b 100644 --- a/src/identifiers/migrations/0010_auto_20231107_1750.py +++ b/src/identifiers/migrations/0010_auto_20231107_1750.py @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ('repository', '0040_auto_20231107_1750'), + ('repository', '0042_auto_20231107_1750'), ('submission', '0073_bleach_title_20230523_1804'), ('review', '0022_remove_reviewform_slug'), ('identifiers', '0009_deduplicate_identifiers_20220527'), diff --git a/src/repository/migrations/0040_auto_20231107_1750.py b/src/repository/migrations/0042_auto_20231107_1750.py similarity index 98% rename from src/repository/migrations/0040_auto_20231107_1750.py rename to src/repository/migrations/0042_auto_20231107_1750.py index 32b38ea691..dda65dd408 100644 --- a/src/repository/migrations/0040_auto_20231107_1750.py +++ b/src/repository/migrations/0042_auto_20231107_1750.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('repository', '0039_alter_preprintversion_title'), + ('repository', '0041_auto_20231207_1658'), ] operations = [ diff --git a/src/repository/migrations/0041_alter_preprintversion_file.py b/src/repository/migrations/0043_alter_preprintversion_file.py similarity index 89% rename from src/repository/migrations/0041_alter_preprintversion_file.py rename to src/repository/migrations/0043_alter_preprintversion_file.py index 3b612e134a..ecd14b3af2 100644 --- a/src/repository/migrations/0041_alter_preprintversion_file.py +++ b/src/repository/migrations/0043_alter_preprintversion_file.py @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ('repository', '0040_auto_20231107_1750'), + ('repository', '0042_auto_20231107_1750'), ] operations = [ From 5c0cc741a89d4d90af1fd9675b814d2cb9b1bf58 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 31 Jan 2024 10:29:55 +0000 Subject: [PATCH 33/76] Updates the preprint API to include versions and public download links. --- src/api/serializers.py | 26 ++++++++++++++++++++++---- src/repository/models.py | 12 ++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/api/serializers.py b/src/api/serializers.py index 03113b667f..be3f0568f4 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -81,12 +81,14 @@ class Meta: galleys = GalleySerializer(source="galley_set", many=True) + class PreprintSubjectSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = repository_models.Subject fields = ("name",) + class PreprintFileSerializer(serializers.ModelSerializer): class Meta: model = repository_models.PreprintFile @@ -97,6 +99,20 @@ class Meta: ) + +class PreprintVersionSerializer(serializers.ModelSerializer): + + class Meta: + model = repository_models.PreprintVersion + fields = ( + 'version', + 'date_time', + 'title', + 'abstract', + 'public_download_url', + ) + + class PreprintSupplementaryFileSerializer(serializers.ModelSerializer): class Meta: model = repository_models.PreprintSupplementaryFile @@ -221,6 +237,7 @@ class Meta: fields = ["pk", "answer"] + class PreprintSerializer(serializers.ModelSerializer): class Meta: model = repository_models.Preprint @@ -238,6 +255,7 @@ class Meta: "authors", "subject", "files", + "versions", "supplementary_files", ) depth = 2 @@ -255,13 +273,13 @@ class Meta: many=True, read_only=True, ) - files = PreprintFileSerializer( - source="preprintfile_set", + supplementary_files = PreprintSupplementaryFileSerializer( + source="preprintsupplementaryfile_set", many=True, read_only=True, ) - supplementary_files = PreprintSupplementaryFileSerializer( - source="preprintsupplementaryfile_set", + versions = PreprintVersionSerializer( + source="preprintversion_set", many=True, read_only=True, ) diff --git a/src/repository/models.py b/src/repository/models.py index 993b22ec69..2d2ee1b7f3 100755 --- a/src/repository/models.py +++ b/src/repository/models.py @@ -1461,6 +1461,18 @@ def get_doi(self, _object=False): except identifier_models.Identifier.DoesNotExist: return None + def public_download_url(self): + path = reverse( + 'repository_file_download', + kwargs={ + 'preprint_id': self.preprint.pk, + 'file_id': self.file.pk, + }, + ) + return self.preprint.repository.site_url( + path=path, + ) + class Comment(models.Model): author = models.ForeignKey( From 82a4b170e85918a7e5326a71b9cbc7407443f6a1 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 7 Mar 2024 13:20:41 +0000 Subject: [PATCH 34/76] Adds POST, PUT and DELETE to the preprint api endpoints. --- src/api/permissions.py | 17 +++ src/api/serializers.py | 258 +++++++++++++++++++++++++++++++++++++++-- src/api/urls.py | 5 +- src/api/views.py | 82 ++++++++++++- 4 files changed, 347 insertions(+), 15 deletions(-) diff --git a/src/api/permissions.py b/src/api/permissions.py index 7afeeed088..bdad8fe7d7 100755 --- a/src/api/permissions.py +++ b/src/api/permissions.py @@ -29,3 +29,20 @@ def has_permission(self, request, view): if request.user.is_section_editor(request): return True + + +class IsRepositoryManager(permissions.BasePermission): + message = 'Please ensure the user is a manager of this repository.' + + def has_permission(self, request, view): + if request.user and not request.user.is_authenticated: + return False + + if not request.repository: + return False + + if request.user.is_staff: + return True + + if request.repository and request.user in request.repository.managers.all(): + return True \ No newline at end of file diff --git a/src/api/serializers.py b/src/api/serializers.py index be3f0568f4..62927810f1 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -1,4 +1,7 @@ -from rest_framework import serializers +from rest_framework import serializers, validators +from rest_framework.exceptions import ValidationError + +from django.db import transaction from core import models as core_models from journal import models as journal_models @@ -9,10 +12,27 @@ class LicenceSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = submission_models.Licence - fields = ("name", "short_name", "text", "url") + fields = ("pk", "name", "short_name", "text", "url") class KeywordsSerializer(serializers.HyperlinkedModelSerializer): + + def get_fields(self): + fields = super().get_fields() + if "word" in fields: + fields["word"].validators = [ + validator + for validator in fields["word"].validators + if not isinstance(validator, validators.UniqueValidator) + ] + return fields + + def create(self, validated_data): + keyword, create = submission_models.Keyword.objects.get_or_create( + word=validated_data.get("word"), + ) + return keyword + class Meta: model = submission_models.Keyword fields = ("word",) @@ -194,16 +214,32 @@ class Meta: class PreprintAccountSerializer(serializers.HyperlinkedModelSerializer): + + def get_fields(self): + fields = super().get_fields() + if "email" in fields: + fields["email"].validators = [ + validator + for validator in fields["email"].validators + if not isinstance(validator, validators.UniqueValidator) + ] + return fields + + def create(self, validated_data): + account_email = validated_data.pop("email") + account, created = core_models.Account.objects.get_or_create( + email=account_email, + defaults={ + **validated_data, + } + ) + return account + class Meta: model = core_models.Account fields = ( - "pk", - "first_name", - "middle_name", - "last_name", - "salutation", - "suffix", - "orcid", + "pk", "email", "first_name", "middle_name", "last_name", + "salutation", "suffix", "orcid", "institution", ) @@ -231,11 +267,20 @@ def validate(self, data): return data +class RepositoryFieldSerializer(serializers.ModelSerializer): + class Meta: + model = repository_models.RepositoryField + fields = ['pk', 'name',] + + class RepositoryFieldAnswerSerializer(serializers.ModelSerializer): class Meta: model = repository_models.RepositoryFieldAnswer - fields = ["pk", "answer"] + fields = ["pk", "answer", "field"] + field = RepositoryFieldSerializer( + many=False, + ) class PreprintSerializer(serializers.ModelSerializer): @@ -245,6 +290,7 @@ class Meta: "pk", "title", "abstract", + "stage", "license", "keywords", "date_submitted", @@ -257,12 +303,13 @@ class Meta: "files", "versions", "supplementary_files", + "additional_field_answers", + "owner", ) depth = 2 authors = PreprintAccountSerializer( many=True, - read_only=True, ) license = LicenceSerializer() keywords = KeywordsSerializer( @@ -283,3 +330,192 @@ class Meta: many=True, read_only=True, ) + additional_field_answers = RepositoryFieldAnswerSerializer( + source="repositoryfieldanswer_set", + many=True, + read_only=True, + ) + + +class PreprintCreateSerializer(serializers.ModelSerializer): + + @transaction.atomic + def create(self, validated_data): + preprint = repository_models.Preprint.objects.create( + repository=validated_data.get('repository'), + title=validated_data.get('title'), + abstract=validated_data.get('abstract'), + owner=validated_data.get('owner'), + stage=validated_data.get('stage'), + license=validated_data.get('license'), + date_submitted=validated_data.get('date_submitted'), + date_accepted=validated_data.get('date_accepted'), + date_published=validated_data.get('date_published'), + doi=validated_data.get('doi'), + preprint_doi=validated_data.get('preprint_doi'), + ) + + for i, author_data in enumerate(validated_data.get('authors', [])): + author_email = author_data.pop('email').lower() + author, created = core_models.Account.objects.get_or_create( + email=author_email, + defaults={ + **author_data, + } + ) + repository_models.PreprintAuthor.objects.get_or_create( + account=author, + preprint=preprint, + defaults={ + 'order': i, + 'affiliation': author.affiliation(), + } + ) + + for keywords in validated_data.get('keywords', []): + for key, word in keywords.items(): + if word and word not in ['', ' ']: + kwd, c = submission_models.Keyword.objects.get_or_create( + word=word + ) + preprint.keywords.add(kwd) + + for subjects in validated_data.get('subject', []): + for key, subject in subjects.items(): + if subject not in ['', ' ']: + subject_obj, c = repository_models.Subject.objects.get_or_create( + name=subject + ) + preprint.subject.add(subject_obj) + + for fieldanswers in validated_data.get('repositoryfieldanswer_set', []): + answer = fieldanswers.get('answer') + field = fieldanswers.get('field').get('name') + + if field and answer: + field_obj, c = repository_models.RepositoryField.objects.get_or_create( + name=field, + repository=preprint.repository, + defaults={ + 'order': 0, + 'input_type': 'textarea', + 'required': False, + } + ) + repository_models.RepositoryFieldAnswer.objects.get_or_create( + field=field_obj, + answer=answer, + preprint=preprint, + ) + + return preprint + + @transaction.atomic + def update(self, instance, validated_data): + instance.title = validated_data.get('title') + instance.abstract = validated_data.get('abstract') + instance.owner = validated_data.get('owner') + instance.repository = validated_data.get('repository') + instance.stage = validated_data.get('stage') + instance.license = validated_data.get('license') + instance.date_submitted = validated_data.get('date_submitted') + instance.date_accepted = validated_data.get('date_accepted') + instance.date_published = validated_data.get('date_published') + instance.doi = validated_data.get('doi') + instance.preprint_doi = validated_data.get('preprint_doi') + instance.save() + + authors = [] + for i, author_data in enumerate(validated_data.get('authors', [])): + author_email = author_data.pop('email').lower() + # check if there is an existing PreprintAuthor record and update it + account, created = core_models.Account.objects.update_or_create( + email=author_email, + defaults={ + **author_data, + } + ) + preprint_author, c = repository_models.PreprintAuthor.objects.update_or_create( + account=account, + preprint=instance, + defaults={ + 'order': i, + 'affiliation': author_data.get('institution'), + } + ) + authors.append(preprint_author) + + # Delete any authors not present in the list of authors + # that were found/created above. + for preprint_author in instance.preprintauthor_set.all(): + if preprint_author not in authors: + preprint_author.delete() + + # Remove all keywords and add those present back. + instance.keywords.clear() + for keywords in validated_data.get('keywords', []): + for key, word in keywords.items(): + if word and word not in ['', ' ']: + kwd, c = submission_models.Keyword.objects.get_or_create( + word=word + ) + instance.keywords.add(kwd) + # Remove all subjects and add those present back. + instance.subject.clear() + for subjects in validated_data.get('subject', []): + for key, subject in subjects.items(): + if subject not in ['', ' ']: + subject_obj, c = repository_models.Subject.objects.get_or_create( + name=subject, + repository=instance.repository, + ) + instance.subject.add(subject_obj) + + answers = [] + for fieldanswers in validated_data.get('repositoryfieldanswer_set', []): + answer = fieldanswers.get('answer') + field = fieldanswers.get('field').get('name') + if field and answer: + field_obj, c = repository_models.RepositoryField.objects.get_or_create( + name=field, + repository=instance.repository, + defaults={ + 'order': 0, + 'input_type': 'textarea', + 'required': False, + } + ) + answer, c = repository_models.RepositoryFieldAnswer.objects.update_or_create( + field=field_obj, + answer=answer, + preprint=instance, + ) + answers.append(answer) + + # Remove answers not part of the update. + for answer_obj in instance.repositoryfieldanswer_set.all(): + if answer_obj not in answers: + answer_obj.delete() + + return instance + + class Meta: + model = repository_models.Preprint + fields = ('pk', 'authors', 'title', 'abstract', 'stage', 'license', 'keywords', + 'date_submitted', 'date_accepted', 'date_published', + 'doi', 'preprint_doi', 'subject', + 'additional_field_answers', 'owner', 'repository') + + authors = PreprintAccountSerializer( + many=True, + ) + keywords = KeywordsSerializer( + many=True, + ) + subject = PreprintSubjectSerializer( + many=True, + ) + additional_field_answers = RepositoryFieldAnswerSerializer( + source="repositoryfieldanswer_set", + many=True, + ) diff --git a/src/api/urls.py b/src/api/urls.py index ac9afce953..8785e512cb 100755 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -11,11 +11,14 @@ router.register(r"journals", views.JournalViewSet, "journal") router.register(r"issues", views.IssueViewSet, "issue") router.register(r"articles", views.ArticleViewSet, "article") -router.register(r"preprints", views.PreprintViewSet, "preprint") router.register(r"licences", views.LicenceViewSet, "licence") router.register(r"keywords", views.KeywordsViewSet, "keywords") router.register(r"accounts", views.AccountViewSet, "accounts") +router.register(r'preprints', views.PreprintViewSet, 'preprint') +router.register(r'repository_licenses', views.PreprintLicenses, 'repository_licenses') +router.register(r'repository_fields', views.RepositoryFields, 'repository_fields') + # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. urlpatterns = [ diff --git a/src/api/views.py b/src/api/views.py index b772f76f52..5d91a02518 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -1,6 +1,6 @@ import collections import csv -import io +import operator import json import re @@ -159,14 +159,90 @@ class PreprintViewSet(viewsets.ModelViewSet): """ serializer_class = serializers.PreprintSerializer - http_method_names = ["get"] + http_method_names = ["get", "post", "delete", "put"] + permission_classes = [ + api_permissions.IsRepositoryManager + ] + + def get_serializer_class(self): + if self.request.method in "GET": + return serializers.PreprintSerializer + elif self.request.method in ["POST", "PUT"]: + return serializers.PreprintCreateSerializer + return serializers.PreprintSerializer def get_queryset(self): - return repository_models.Preprint.objects.filter( + preprints = repository_models.Preprint.objects.filter( repository=self.request.repository, date_published__lte=timezone.now(), stage=repository_models.STAGE_PREPRINT_PUBLISHED, ) + search_term = self.request.query_params.get("search") + if search_term: + split_search_term = search_term.split(" ") + # Initial filter on Title, Abstract and Keywords. + preprint_search = preprints.filter( + (Q(title__icontains=search_term) | + Q(abstract__icontains=search_term) | + Q(keywords__word__in=split_search_term)) + ) + from_author = repository_models.PreprintAuthor.objects.filter( + ( + Q(account__first_name__in=split_search_term) | + Q(account__middle_name__in=split_search_term) | + Q(account__last_name__in=split_search_term) | + Q(account__institution__icontains=search_term) + ) + ) + preprints_from_author = [ + pa.preprint for pa in + repository_models.PreprintAuthor.objects.filter( + pk__in=from_author, + preprint__date_published__lte=timezone.now(), + ) + ] + preprint_pks = list(preprint.pk for preprint in + set(list(preprint_search) + preprints_from_author) + ) + preprints = repository_models.Preprint.objects.filter( + pk__in=preprint_pks, + ) + return preprints + + +class PreprintLicenses(viewsets.ModelViewSet): + serializer_class = serializers.LicenceSerializer + http_method_names = ["get", "post", "delete"] + permission_classes = [ + api_permissions.IsRepositoryManager, + api_permissions.IsEditor, + ] + + def get_queryset(self): + if self.request.repository: + return self.request.repository.active_licenses.all() + else: + raise NotImplementedError( + "This view only works with Repositories.", + ) + + +class RepositoryFields(viewsets.ModelViewSet): + serializer_class = serializers.RepositoryFieldSerializer + http_method_names = ["get"] + permission_classes = [ + api_permissions.IsRepositoryManager, + ] + + def get_queryset(self): + if self.request.repository: + return repository_models.RepositoryField.objects.filter( + repository=self.request.repository, + ) + else: + raise NotImplementedError( + "This view only works with Repositories.", + ) def oai(request): From 815e57e9d3d9af386ad4325b8048d3a6469b5de9 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 7 Mar 2024 13:25:59 +0000 Subject: [PATCH 35/76] Fixes missing repo when creating a new subject --- src/api/serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api/serializers.py b/src/api/serializers.py index 62927810f1..86bed6bb95 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -384,7 +384,8 @@ def create(self, validated_data): for key, subject in subjects.items(): if subject not in ['', ' ']: subject_obj, c = repository_models.Subject.objects.get_or_create( - name=subject + name=subject, + repository=preprint.repository, ) preprint.subject.add(subject_obj) From fbbaf9973f3195a314f14146cbf6580d6bb80f77 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 19 Mar 2024 12:03:04 +0000 Subject: [PATCH 36/76] Adds an endpoint for adding preprintfiles. --- src/api/serializers.py | 53 +++++++++++++++++++++++++++++++++++++++--- src/api/urls.py | 1 + src/api/views.py | 23 ++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/api/serializers.py b/src/api/serializers.py index 86bed6bb95..76c5387fcf 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers, validators -from rest_framework.exceptions import ValidationError from django.db import transaction +from django.shortcuts import reverse from core import models as core_models from journal import models as journal_models @@ -109,16 +109,63 @@ class Meta: -class PreprintFileSerializer(serializers.ModelSerializer): +class PreprintFileCreateSerializer(serializers.ModelSerializer): + class Meta: model = repository_models.PreprintFile fields = ( + "pk", "original_filename", + "file", + "preprint", "mime_type", - "download_url", + "public_download_url", + "manager_download_url", + ) + + manager_download_url = serializers.SerializerMethodField( + method_name="get_manager_url", + ) + public_download_url = serializers.SerializerMethodField( + method_name="get_public_url", + ) + + def get_manager_url(self, obj): + return obj.preprint.repository.site_url( + path=reverse( + "repository_download_file", + kwargs={ + "preprint_id": obj.preprint.pk, + "file_id": obj.pk, + } + ) + ) + + def get_public_url(self, obj): + return obj.preprint.repository.site_url( + path=reverse( + "repository_file_download", + kwargs={ + "preprint_id": obj.preprint.pk, + "file_id": obj.pk, + } + ) ) +class PreprintFileSerializer(PreprintFileCreateSerializer): + + class Meta: + model = repository_models.PreprintFile + fields = ( + "pk", + "preprint", + "original_filename", + "mime_type", + "public_download_url", + "manager_download_url", + ) + class PreprintVersionSerializer(serializers.ModelSerializer): diff --git a/src/api/urls.py b/src/api/urls.py index 8785e512cb..8677faf580 100755 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -18,6 +18,7 @@ router.register(r'preprints', views.PreprintViewSet, 'preprint') router.register(r'repository_licenses', views.PreprintLicenses, 'repository_licenses') router.register(r'repository_fields', views.RepositoryFields, 'repository_fields') +router.register(r'preprint_files', views.PreprintFiles, 'preprint_files') # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. diff --git a/src/api/views.py b/src/api/views.py index 5d91a02518..dc8c08405f 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -245,6 +245,29 @@ def get_queryset(self): ) +class PreprintFiles(viewsets.ModelViewSet): + serializer_class = serializers.PreprintFileSerializer + http_method_names = ['get', 'post', 'delete'] + permission_classes = [ + api_permissions.IsRepositoryManager, + ] + + def get_serializer_class(self): + if self.request.method in ['POST']: + return serializers.PreprintFileCreateSerializer + return serializers.PreprintFileSerializer + + def get_queryset(self): + if self.request.repository: + return repository_models.PreprintFile.objects.filter( + preprint__repository=self.request.repository, + ) + else: + raise NotImplementedError( + "This view only works with Repositories.", + ) + + def oai(request): articles = submission_models.Article.objects.filter( stage=submission_models.STAGE_PUBLISHED From eef8b97daa253c553154a20198b36315908d1dd4 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 16 Jul 2024 14:28:44 +0100 Subject: [PATCH 37/76] Adds a user preprints view. --- src/api/permissions.py | 32 +++++++++++++++++++++++++++++++- src/api/serializers.py | 2 +- src/api/urls.py | 1 + src/api/views.py | 28 ++++++++++++++++++++++++---- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/api/permissions.py b/src/api/permissions.py index bdad8fe7d7..b637a2c000 100755 --- a/src/api/permissions.py +++ b/src/api/permissions.py @@ -1,7 +1,10 @@ from django.core.exceptions import PermissionDenied +from django.shortcuts import get_object_or_404 from rest_framework import permissions +from repository import models as rm + class IsEditor(permissions.BasePermission): message = "Please ensure the user is an Editor or Staff Member." @@ -45,4 +48,31 @@ def has_permission(self, request, view): return True if request.repository and request.user in request.repository.managers.all(): - return True \ No newline at end of file + return True + + +class IsPreprintOwner(permissions.BasePermission): + message = 'You must be the owner of this preprint to edit it.' + + def has_permission(self, request, view): + # grant access to non-create/update requests + if request.method not in ['PUT', 'PATCH']: + return True + + # grant access if user is the preprint's owner + preprint_id = request.data.get('pk') + if not preprint_id: + preprint_id = view.kwargs.get('pk') + + preprint = get_object_or_404( + rm.Preprint, + pk=preprint_id, + ) + if request.user == preprint.owner: + return True + + if request.user.is_staff: + return True + + # Otherwise don't grant access + return False \ No newline at end of file diff --git a/src/api/serializers.py b/src/api/serializers.py index 76c5387fcf..b469aa98dd 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -393,7 +393,7 @@ def create(self, validated_data): title=validated_data.get('title'), abstract=validated_data.get('abstract'), owner=validated_data.get('owner'), - stage=validated_data.get('stage'), + stage=validated_data.get('stage', 'preprint_review'), license=validated_data.get('license'), date_submitted=validated_data.get('date_submitted'), date_accepted=validated_data.get('date_accepted'), diff --git a/src/api/urls.py b/src/api/urls.py index 8677faf580..2236630244 100755 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -19,6 +19,7 @@ router.register(r'repository_licenses', views.PreprintLicenses, 'repository_licenses') router.register(r'repository_fields', views.RepositoryFields, 'repository_fields') router.register(r'preprint_files', views.PreprintFiles, 'preprint_files') +router.register(r'user_preprints', views.UserPreprintsViewSet, 'user_preprints') # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. diff --git a/src/api/views.py b/src/api/views.py index dc8c08405f..5845961a93 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -1,6 +1,5 @@ import collections import csv -import operator import json import re @@ -9,7 +8,7 @@ from django.utils import timezone from django.db.models import Q -from rest_framework import viewsets, generics +from rest_framework import viewsets from rest_framework.decorators import api_view, permission_classes from rest_framework import permissions @@ -174,8 +173,6 @@ def get_serializer_class(self): def get_queryset(self): preprints = repository_models.Preprint.objects.filter( repository=self.request.repository, - date_published__lte=timezone.now(), - stage=repository_models.STAGE_PREPRINT_PUBLISHED, ) search_term = self.request.query_params.get("search") if search_term: @@ -210,6 +207,29 @@ def get_queryset(self): return preprints +class UserPreprintsViewSet(viewsets.ModelViewSet): + serializer_class = serializers.PreprintSerializer + http_method_names = ['get', 'post', 'put'] + permission_classes = [ + permissions.IsAuthenticated, + api_permissions.IsPreprintOwner + ] + + def get_serializer_class(self): + if self.request.method in 'GET': + return serializers.PreprintSerializer + elif self.request.method in ['POST', 'PUT']: + return serializers.PreprintCreateSerializer + return serializers.PreprintSerializer + + def get_queryset(self): + preprints = repository_models.Preprint.objects.filter( + repository=self.request.repository, + owner=self.request.user, + ) + return preprints + + class PreprintLicenses(viewsets.ModelViewSet): serializer_class = serializers.LicenceSerializer http_method_names = ["get", "post", "delete"] From b71e9c97511316ee75146b4084378a653a762efe Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 16 Jul 2024 14:31:06 +0100 Subject: [PATCH 38/76] Handle potentially missing objects. --- src/repository/models.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/repository/models.py b/src/repository/models.py index 2d2ee1b7f3..389b973a54 100755 --- a/src/repository/models.py +++ b/src/repository/models.py @@ -1462,16 +1462,19 @@ def get_doi(self, _object=False): return None def public_download_url(self): - path = reverse( - 'repository_file_download', - kwargs={ - 'preprint_id': self.preprint.pk, - 'file_id': self.file.pk, - }, - ) - return self.preprint.repository.site_url( - path=path, - ) + if self.preprint and self.file: + path = reverse( + 'repository_file_download', + kwargs={ + 'preprint_id': self.preprint.pk, + 'file_id': self.file.pk, + }, + ) + return self.preprint.repository.site_url( + path=path, + ) + else: + return '' class Comment(models.Model): From c9a5dca26a544c086bf4e83f2d0d9f107d54a58c Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 16 Jul 2024 14:37:01 +0100 Subject: [PATCH 39/76] Add account filtering by ORCID. --- src/api/views.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/api/views.py b/src/api/views.py index 5845961a93..725acd6071 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -57,6 +57,11 @@ def get_queryset(self): | Q(first_name__iregex=search_regex) | Q(last_name__iregex=search_regex) ) + orcid = self.request.query_params.get('orcid') + if orcid: + queryset = queryset.filter( + orcid=orcid, + ) return queryset From 664468d8d01821fefa086c1af9747dd1ea2ebe86 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 16 Jul 2024 14:59:22 +0100 Subject: [PATCH 40/76] Added a subject endpoint that shows preprints in the subject --- src/api/serializers.py | 7 +++++++ src/api/urls.py | 1 + src/api/views.py | 16 ++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/src/api/serializers.py b/src/api/serializers.py index b469aa98dd..1c2d40a152 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -109,6 +109,13 @@ class Meta: +class PreprintSubjectGroupSerializer(serializers.HyperlinkedModelSerializer): + + class Meta: + model = repository_models.Subject + fields = ('name', 'preprint_set') + + class PreprintFileCreateSerializer(serializers.ModelSerializer): class Meta: diff --git a/src/api/urls.py b/src/api/urls.py index 2236630244..6be5c76bc0 100755 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -20,6 +20,7 @@ router.register(r'repository_fields', views.RepositoryFields, 'repository_fields') router.register(r'preprint_files', views.PreprintFiles, 'preprint_files') router.register(r'user_preprints', views.UserPreprintsViewSet, 'user_preprints') +router.register(r'repository_subjects', views.RepositorySubjects, 'preprint_subjects') # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. diff --git a/src/api/views.py b/src/api/views.py index 725acd6071..c139d8e2a5 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -180,6 +180,7 @@ def get_queryset(self): repository=self.request.repository, ) search_term = self.request.query_params.get("search") + stage = self.request.query_params.get("stage") if search_term: split_search_term = search_term.split(" ") # Initial filter on Title, Abstract and Keywords. @@ -209,6 +210,11 @@ def get_queryset(self): preprints = repository_models.Preprint.objects.filter( pk__in=preprint_pks, ) + + if stage: + preprints = preprints.filter( + stage=stage, + ) return preprints @@ -293,6 +299,16 @@ def get_queryset(self): ) +class RepositorySubjects(viewsets.ModelViewSet): + serializer_class = serializers.PreprintSubjectGroupSerializer + http_method_names = ['get'] + + def get_queryset(self): + return repository_models.Subject.objects.filter( + repository=self.request.repository, + ) + + def oai(request): articles = submission_models.Article.objects.filter( stage=submission_models.STAGE_PUBLISHED From 0b61c8dbc11820d0347ef16e01decd7408ae0fa4 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 6 Aug 2024 15:20:08 +0100 Subject: [PATCH 41/76] Adds updates required by Carbon Plan. --- src/api/serializers.py | 53 ++++++++++++ src/api/urls.py | 19 ++++- src/api/views.py | 124 ++++++++++++++++++++++++---- src/core/janeway_global_settings.py | 2 + src/core/plugin_loader.py | 7 +- 5 files changed, 180 insertions(+), 25 deletions(-) diff --git a/src/api/serializers.py b/src/api/serializers.py index 1c2d40a152..da61a8787e 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -574,3 +574,56 @@ class Meta: source="repositoryfieldanswer_set", many=True, ) + + +class SubmissionAccountSearch(serializers.ModelSerializer): + class Meta: + model = core_models.Account + fields = ( + 'pk', + 'first_name', + 'middle_name', + 'last_name', + 'orcid', + ) + + +class VersionQueueCreateSerializer(serializers.ModelSerializer): + class Meta: + model = repository_models.VersionQueue + fields = ( + 'preprint', + 'update_type', + 'title', + 'abstract', + 'published_doi', + 'file', + ) + + def validate(self, data): + request = self.context.get('request', None) + preprint = data.get("preprint") + + if not request.user == preprint.owner: + raise serializers.ValidationError( + {"error": "You cannot add a version for a preprint " + "that you do not own."} + ) + + return data + + +class VersionQueueSerializer(serializers.ModelSerializer): + class Meta: + model = repository_models.VersionQueue + fields = ( + 'preprint', + 'update_type', + 'date_submitted', + 'date_decision', + 'approved', + 'published_doi', + 'title', + 'abstract', + 'file', + ) diff --git a/src/api/urls.py b/src/api/urls.py index 6be5c76bc0..2d3c9efd5b 100755 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -1,4 +1,5 @@ from django.urls import re_path, include +from django.conf import settings from rest_framework import routers from rest_framework.schemas import get_schema_view @@ -15,12 +16,22 @@ router.register(r"keywords", views.KeywordsViewSet, "keywords") router.register(r"accounts", views.AccountViewSet, "accounts") -router.register(r'preprints', views.PreprintViewSet, 'preprint') +router.register(r'preprints', views.PreprintViewSet, 'repository_preprints') router.register(r'repository_licenses', views.PreprintLicenses, 'repository_licenses') router.register(r'repository_fields', views.RepositoryFields, 'repository_fields') -router.register(r'preprint_files', views.PreprintFiles, 'preprint_files') -router.register(r'user_preprints', views.UserPreprintsViewSet, 'user_preprints') -router.register(r'repository_subjects', views.RepositorySubjects, 'preprint_subjects') +router.register(r'preprint_files', views.PreprintFiles, 'repository_preprint_files') +router.register(r'user_preprints', views.UserPreprintsViewSet, 'repository_user_preprints') +router.register(r'repository_subjects', views.RepositorySubjects, 'repository_preprint_subjects') +router.register(r'published_preprints', views.PublishedPreprintViewSet, 'repository_published_preprint') +router.register(r'version_queue', views.RepositoryVersionQueue, 'repository_version_queue') +router.register(r'user_info', views.UserInfo, 'api_user_info') + +if settings.API_ENABLE_SUBMISSION_ACCOUNT_SEARCH: + router.register( + r'submission_account_search', + views.SubmissionAccountSearch, + 'submission_account_search', + ) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. diff --git a/src/api/views.py b/src/api/views.py index c139d8e2a5..f89a153216 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -7,6 +7,7 @@ from django.shortcuts import render from django.utils import timezone from django.db.models import Q +from django.db.models.functions import Lower from rest_framework import viewsets from rest_framework.decorators import api_view, permission_classes @@ -181,22 +182,31 @@ def get_queryset(self): ) search_term = self.request.query_params.get("search") stage = self.request.query_params.get("stage") + subject = self.request.query_params.get("subject") + if search_term: split_search_term = search_term.split(" ") + lower_split_search_term = [term.lower() for term in + split_search_term] + # Initial filter on Title, Abstract and Keywords. preprint_search = preprints.filter( - (Q(title__icontains=search_term) | - Q(abstract__icontains=search_term) | - Q(keywords__word__in=split_search_term)) + Q(title__icontains=search_term) | + Q(abstract__icontains=search_term) | + Q(keywords__word__in=split_search_term) ) - from_author = repository_models.PreprintAuthor.objects.filter( - ( - Q(account__first_name__in=split_search_term) | - Q(account__middle_name__in=split_search_term) | - Q(account__last_name__in=split_search_term) | - Q(account__institution__icontains=search_term) - ) + + from_author = repository_models.PreprintAuthor.objects.annotate( + lower_first_name=Lower('account__first_name'), + lower_middle_name=Lower('account__middle_name'), + lower_last_name=Lower('account__last_name') + ).filter( + Q(lower_first_name__in=lower_split_search_term) | + Q(lower_middle_name__in=lower_split_search_term) | + Q(lower_last_name__in=lower_split_search_term) | + Q(account__institution__icontains=search_term) ) + preprints_from_author = [ pa.preprint for pa in repository_models.PreprintAuthor.objects.filter( @@ -204,9 +214,12 @@ def get_queryset(self): preprint__date_published__lte=timezone.now(), ) ] + preprint_pks = list(preprint.pk for preprint in - set(list(preprint_search) + preprints_from_author) - ) + set(list( + preprint_search) + preprints_from_author) + ) + preprints = repository_models.Preprint.objects.filter( pk__in=preprint_pks, ) @@ -215,10 +228,30 @@ def get_queryset(self): preprints = preprints.filter( stage=stage, ) + + if subject: + preprints = preprints.filter( + subject__name=subject, + ) + return preprints -class UserPreprintsViewSet(viewsets.ModelViewSet): +class PublishedPreprintViewSet(PreprintViewSet): + http_method_names = ['get'] + permission_classes = [ + permissions.AllowAny, + ] + + def get_queryset(self): + preprints = super().get_queryset() + return preprints.filter( + date_published__isnull=False, + stage=repository_models.STAGE_PREPRINT_PUBLISHED, + ) + + +class UserPreprintsViewSet(PreprintViewSet): serializer_class = serializers.PreprintSerializer http_method_names = ['get', 'post', 'put'] permission_classes = [ @@ -234,8 +267,8 @@ def get_serializer_class(self): return serializers.PreprintSerializer def get_queryset(self): - preprints = repository_models.Preprint.objects.filter( - repository=self.request.repository, + preprints = super().get_queryset() + preprints = preprints.filter( owner=self.request.user, ) return preprints @@ -309,6 +342,67 @@ def get_queryset(self): ) +class RepositoryVersionQueue(viewsets.ModelViewSet): + serializer_class = serializers.VersionQueueSerializer + http_method_names = ['get', 'post'] + permission_classes = [ + permissions.IsAuthenticated, + ] + + def get_serializer_class(self): + if self.request.method in 'GET': + return serializers.VersionQueueSerializer + elif self.request.method in ['POST']: + return serializers.VersionQueueCreateSerializer + return serializers.VersionQueueSerializer + + def get_queryset(self): + return repository_models.VersionQueue.objects.filter( + preprint__repository=self.request.repository, + preprint__owner=self.request.user, + ) + + +class SubmissionAccountSearch(viewsets.ModelViewSet): + """ + Limited search feature for authenticated users. Can search by + exact email or exact ORCID. Returns 0 results if no exact match. + + The availability of this view is controlled by the Django setting: + API_ENABLE_SUBMISSION_ACCOUNT_SEARCH which is False by default. + """ + serializer_class = serializers.SubmissionAccountSearch + http_method_names = ['get'] + permission_classes = [ + permissions.IsAuthenticated, + ] + + def get_queryset(self): + search = self.request.GET.get('search') + if not search: + return core_models.Account.objects.none() + return core_models.Account.objects.filter( + Q(email=search) | Q(orcid=search), + )[:1] + + +class UserInfo(AccountViewSet): + """ + Account viewset limited to a single payload based on the current user. + """ + http_method_names = ['get'] + permission_classes = [ + permissions.IsAuthenticated, + ] + + def get_queryset(self): + accounts = super().get_queryset() + accounts = accounts.filter( + pk=self.request.user.pk, + ) + return accounts + + def oai(request): articles = submission_models.Article.objects.filter( stage=submission_models.STAGE_PUBLISHED diff --git a/src/core/janeway_global_settings.py b/src/core/janeway_global_settings.py index 0343c2f73f..fd85646768 100755 --- a/src/core/janeway_global_settings.py +++ b/src/core/janeway_global_settings.py @@ -721,3 +721,5 @@ def __len__(self): # Note that the provided theme CSS expects a default crop size of (1500, 648) # and may not work properly with a different size. DEFAULT_CROP_SIZE = (1500, 648) + +API_ENABLE_SUBMISSION_ACCOUNT_SEARCH = False diff --git a/src/core/plugin_loader.py b/src/core/plugin_loader.py index 15a91715c0..19f81d23f3 100755 --- a/src/core/plugin_loader.py +++ b/src/core/plugin_loader.py @@ -95,12 +95,7 @@ def validate_plugin_version(plugin_settings): current_version = version.parse(janeway_version.base_version) valid = current_version >= wants_version - if not valid: - raise ImproperlyConfigured( - "Plugin {} not compatibile with current install: {} < {}".format( - plugin_settings.PLUGIN_NAME, current_version, wants_version - ) - ) + def get_plugin(module_name, permissive): From 2d343a3a1c32d5c345658f2bfdfcc1d43def6828 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 20 Aug 2024 15:16:05 +0100 Subject: [PATCH 42/76] Fixes PreprintSubjectGroupSerializer. --- src/api/serializers.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/api/serializers.py b/src/api/serializers.py index da61a8787e..9bb22fe5cc 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -110,10 +110,16 @@ class Meta: class PreprintSubjectGroupSerializer(serializers.HyperlinkedModelSerializer): + preprints = serializers.HyperlinkedRelatedField( + many=True, + read_only=True, + view_name='repository_preprints-detail', + source='preprint_set', + ) class Meta: model = repository_models.Subject - fields = ('name', 'preprint_set') + fields = ('name', 'preprints') class PreprintFileCreateSerializer(serializers.ModelSerializer): From 091bf04c8bc1009a7b62a54d2d1e2be9bc4a06ed Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 28 Aug 2024 16:15:27 +0100 Subject: [PATCH 43/76] Tweaked features. --- src/api/serializers.py | 76 ++++++++++++++++++++++++++--- src/api/views.py | 11 +++-- src/core/janeway_global_settings.py | 2 + 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/src/api/serializers.py b/src/api/serializers.py index 9bb22fe5cc..9e57422225 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -7,6 +7,7 @@ from journal import models as journal_models from submission import models as submission_models from repository import models as repository_models +from events import logic as event_logic class LicenceSerializer(serializers.HyperlinkedModelSerializer): @@ -110,17 +111,31 @@ class Meta: class PreprintSubjectGroupSerializer(serializers.HyperlinkedModelSerializer): - preprints = serializers.HyperlinkedRelatedField( - many=True, - read_only=True, - view_name='repository_preprints-detail', - source='preprint_set', - ) + preprints = serializers.SerializerMethodField() class Meta: model = repository_models.Subject fields = ('name', 'preprints') + def get_preprints(self, obj): + # You can filter or modify the queryset here + custom_queryset = repository_models.Preprint.objects.filter( + subject=obj, + date_published__isnull=False, + stage=repository_models.STAGE_PREPRINT_PUBLISHED, + ) + request = self.context.get('request') + format = self.context.get( + 'format', + None, + ) + view_name = 'repository_preprints-detail' + return [ + serializers.HyperlinkedIdentityField(view_name=view_name).get_url( + preprint, 'repository_preprints-detail', request, format) + for preprint in custom_queryset + ] + class PreprintFileCreateSerializer(serializers.ModelSerializer): @@ -413,6 +428,7 @@ def create(self, validated_data): date_published=validated_data.get('date_published'), doi=validated_data.get('doi'), preprint_doi=validated_data.get('preprint_doi'), + comments_editor=validated_data.get('comments_editor'), ) for i, author_data in enumerate(validated_data.get('authors', [])): @@ -468,11 +484,37 @@ def create(self, validated_data): answer=answer, preprint=preprint, ) + for supp_file in validated_data.get('preprintsupplementaryfile_set'): + url = supp_file.get('url') + label = supp_file.get('label') + + if url and label: + repository_models.PreprintSupplementaryFile.objects.update_or_create( + preprint=preprint, + url=url, + defaults={ + 'label': label, + } + ) return preprint @transaction.atomic def update(self, instance, validated_data): + pre_save_stage = instance.stage + + if ( + pre_save_stage == repository_models.STAGE_PREPRINT_UNSUBMITTED and + validated_data.get('stage') == repository_models.STAGE_PREPRINT_REVIEW + ): + request = self.context.get('request', None) + instance.submit_preprint() + kwargs = {'request': request, 'preprint': instance} + event_logic.Events.raise_event( + event_logic.Events.ON_PREPRINT_SUBMISSION, + **kwargs, + ) + instance.title = validated_data.get('title') instance.abstract = validated_data.get('abstract') instance.owner = validated_data.get('owner') @@ -484,6 +526,7 @@ def update(self, instance, validated_data): instance.date_published = validated_data.get('date_published') instance.doi = validated_data.get('doi') instance.preprint_doi = validated_data.get('preprint_doi') + instance.comments_editor = validated_data.get('comments_editor') instance.save() authors = [] @@ -558,6 +601,19 @@ def update(self, instance, validated_data): if answer_obj not in answers: answer_obj.delete() + for supp_file in validated_data.get('preprintsupplementaryfile_set'): + url = supp_file.get('url') + label = supp_file.get('label') + + if url and label: + repository_models.PreprintSupplementaryFile.objects.update_or_create( + preprint=instance, + url=url, + defaults={ + 'label': label, + } + ) + return instance class Meta: @@ -565,7 +621,8 @@ class Meta: fields = ('pk', 'authors', 'title', 'abstract', 'stage', 'license', 'keywords', 'date_submitted', 'date_accepted', 'date_published', 'doi', 'preprint_doi', 'subject', - 'additional_field_answers', 'owner', 'repository') + 'additional_field_answers', 'owner', 'repository', + 'supplementary_files', 'comments_editor') authors = PreprintAccountSerializer( many=True, @@ -580,6 +637,10 @@ class Meta: source="repositoryfieldanswer_set", many=True, ) + supplementary_files = PreprintSupplementaryFileSerializer( + source="preprintsupplementaryfile_set", + many=True, + ) class SubmissionAccountSearch(serializers.ModelSerializer): @@ -591,6 +652,7 @@ class Meta: 'middle_name', 'last_name', 'orcid', + 'email', ) diff --git a/src/api/views.py b/src/api/views.py index f89a153216..72f3e2b80b 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -248,6 +248,9 @@ def get_queryset(self): return preprints.filter( date_published__isnull=False, stage=repository_models.STAGE_PREPRINT_PUBLISHED, + ).order_by( + '-date_published', + 'title', ) @@ -267,9 +270,9 @@ def get_serializer_class(self): return serializers.PreprintSerializer def get_queryset(self): - preprints = super().get_queryset() - preprints = preprints.filter( + preprints = repository_models.Preprint.objects.filter( owner=self.request.user, + repository=self.request.repository, ) return preprints @@ -313,7 +316,8 @@ class PreprintFiles(viewsets.ModelViewSet): serializer_class = serializers.PreprintFileSerializer http_method_names = ['get', 'post', 'delete'] permission_classes = [ - api_permissions.IsRepositoryManager, + permissions.IsAuthenticated, + api_permissions.IsPreprintOwner ] def get_serializer_class(self): @@ -325,6 +329,7 @@ def get_queryset(self): if self.request.repository: return repository_models.PreprintFile.objects.filter( preprint__repository=self.request.repository, + preprint__owner=self.request.user, ) else: raise NotImplementedError( diff --git a/src/core/janeway_global_settings.py b/src/core/janeway_global_settings.py index fd85646768..337166c904 100755 --- a/src/core/janeway_global_settings.py +++ b/src/core/janeway_global_settings.py @@ -722,4 +722,6 @@ def __len__(self): # and may not work properly with a different size. DEFAULT_CROP_SIZE = (1500, 648) +# This setting should only be enabled on Dev or where CORS is properly +# configured to stop misuse of this endpoint. API_ENABLE_SUBMISSION_ACCOUNT_SEARCH = False From 6f34235c824cd9027327e9d26ea5dfb8dcb94647 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Fri, 13 Sep 2024 13:58:36 +0100 Subject: [PATCH 44/76] Adds API filters. --- src/api/views.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/api/views.py b/src/api/views.py index 72f3e2b80b..e3b6dbce26 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -274,6 +274,9 @@ def get_queryset(self): owner=self.request.user, repository=self.request.repository, ) + stage_filter = self.request.GET.get('stage') + if stage_filter: + preprints = preprints.filter(stage=stage_filter) return preprints @@ -362,10 +365,16 @@ def get_serializer_class(self): return serializers.VersionQueueSerializer def get_queryset(self): - return repository_models.VersionQueue.objects.filter( + version_queues = repository_models.VersionQueue.objects.filter( preprint__repository=self.request.repository, preprint__owner=self.request.user, ) + preprint_filter = self.request.GET.get('preprint') + if preprint_filter: + version_queues = version_queues.filter( + preprint=preprint_filter + ) + return version_queues class SubmissionAccountSearch(viewsets.ModelViewSet): From ee9afcb0b9183c126719c0be39ce055161ecc0d2 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Fri, 13 Sep 2024 13:59:02 +0100 Subject: [PATCH 45/76] Adds an email when a new version is uploaded. --- src/events/logic.py | 4 ++ src/events/registration.py | 7 +++ src/repository/forms.py | 4 +- ...pository_new_version_submitted_and_more.py | 28 ++++++++++ src/repository/models.py | 4 ++ src/repository/views.py | 9 ++++ .../admin/elements/repository/4_help.html | 6 +++ src/utils/install/repository_settings.json | 4 +- src/utils/transactional_emails.py | 51 +++++++++++++++++++ 9 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 src/repository/migrations/0044_historicalrepository_new_version_submitted_and_more.py diff --git a/src/events/logic.py b/src/events/logic.py index cdf1088258..86099737f4 100755 --- a/src/events/logic.py +++ b/src/events/logic.py @@ -269,6 +269,10 @@ class Events: # raised when a Review changes status ON_PREPRINT_REVIEW_STATUS_CHANGE = "on_preprint_review_status_change" + # kwargs: request, new_version, preprint + # raised when a preprint author uploads a new version + ON_PREPRINT_NEW_VERSION = 'on_preprint_new_version' + # kwargs: handshake_url, request, article, switch_stage (optional) # raised when a workflow element completes to hand over to the next one ON_WORKFLOW_ELEMENT_COMPLETE = "on_workflow_element_complete" diff --git a/src/events/registration.py b/src/events/registration.py index 76c24e4acf..4cee4c8b7b 100755 --- a/src/events/registration.py +++ b/src/events/registration.py @@ -238,6 +238,12 @@ transactional_emails.preprint_version_update, ) +event_logic.Events.register_for_event( + event_logic.Events.ON_PREPRINT_NEW_VERSION, + transactional_emails.preprint_new_version, +) + + event_logic.Events.register_for_event( event_logic.Events.ON_ACCESS_REQUEST, transactional_emails.access_request_notification, @@ -258,6 +264,7 @@ transactional_emails.preprint_review_status_change, ) + # wire up task-creation events event_logic.Events.register_for_event( event_logic.Events.ON_ARTICLE_SUBMITTED, workflow_tasks.assign_editors diff --git a/src/repository/forms.py b/src/repository/forms.py index f1c71eed65..c6d7cf89ec 100755 --- a/src/repository/forms.py +++ b/src/repository/forms.py @@ -592,6 +592,7 @@ class RepositoryEmails(RepositoryBase): class Meta: model = models.Repository fields = ( + 'submission_notification_recipients', 'submission', 'publication', 'decline', @@ -601,7 +602,7 @@ class Meta: 'review_invitation', 'manager_review_status_change', 'reviewer_review_status_change', - 'submission_notification_recipients', + 'new_version_submitted', ) widgets = { @@ -614,6 +615,7 @@ class Meta: 'review_invitation': TinyMCE, 'manager_review_status_change': TinyMCE, 'reviewer_review_status_change': TinyMCE, + 'new_version_submitted': TinyMCE, 'submission_notification_recipients': TableMultiSelectUser() } diff --git a/src/repository/migrations/0044_historicalrepository_new_version_submitted_and_more.py b/src/repository/migrations/0044_historicalrepository_new_version_submitted_and_more.py new file mode 100644 index 0000000000..8b5c0519b9 --- /dev/null +++ b/src/repository/migrations/0044_historicalrepository_new_version_submitted_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2 on 2024-09-13 12:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('repository', '0043_alter_preprintversion_file'), + ] + + operations = [ + migrations.AddField( + model_name='historicalrepository', + name='new_version_submitted', + field=models.TextField(blank=True, help_text='Email sent when an author uploads a new version.'), + ), + migrations.AddField( + model_name='repository', + name='new_version_submitted', + field=models.TextField(blank=True, help_text='Email sent when an author uploads a new version.'), + ), + migrations.AlterField( + model_name='preprint', + name='abstract', + field=models.TextField(blank=True, help_text='Copying and pasting from word processors is supported.', null=True), + ), + ] diff --git a/src/repository/models.py b/src/repository/models.py index 389b973a54..ee29b71a86 100755 --- a/src/repository/models.py +++ b/src/repository/models.py @@ -213,6 +213,10 @@ class Repository(model_utils.AbstractSiteModel): reviewer_review_status_change = model_utils.JanewayBleachField( blank=True, null=True ) + new_version_submitted = model_utils.JanewayBleachField( + blank=True, + help_text="Email sent when an author uploads a new version.", + ) footer = model_utils.JanewayBleachField( blank=True, null=True, diff --git a/src/repository/views.py b/src/repository/views.py index c36783f2df..e0c9221c6e 100644 --- a/src/repository/views.py +++ b/src/repository/views.py @@ -258,6 +258,15 @@ def repository_submit_update(request, preprint_id, action): new_version.save() + event_logic.Events.raise_event( + event_logic.Events.ON_PREPRINT_NEW_VERSION, + **{ + 'request': request, + 'new_version': new_version, + 'preprint': preprint, + }, + ) + return redirect( reverse( "repository_author_article", diff --git a/src/templates/admin/elements/repository/4_help.html b/src/templates/admin/elements/repository/4_help.html index e7fbd25eca..17e857c4c6 100644 --- a/src/templates/admin/elements/repository/4_help.html +++ b/src/templates/admin/elements/repository/4_help.html @@ -47,4 +47,10 @@ Email sent to a reviewer asking them to make an invited review comment on a {{ request.repository.object_name }}. +
  • New version submitted
  • +
      +
    • + Email sent to a managers when a new version is uploaded by an author. +
    • +
    \ No newline at end of file diff --git a/src/utils/install/repository_settings.json b/src/utils/install/repository_settings.json index 34ac485e37..5556efd32b 100644 --- a/src/utils/install/repository_settings.json +++ b/src/utils/install/repository_settings.json @@ -9,7 +9,7 @@ "review_invitation": "

    Dear {{ review.reviewer.full_name }},

    I hope this message finds you well. We would like to invite you to comment on a {{ request.repository.object_name }} titled {{ review.preprint.title }} submitted to {{ request.repository.name }}. We would be most grateful for your time and feedback.

    {{ url }}

    {{ request.user.signature }}
    {{ request.repository.site_url }}

    ", "review_helper": "

    Add your comments below. These will be displayed publicly once approved. You can mark your comments as anonymous if you do not want to display your name alongside the comments.

    ", "manager_review_status_change": "

    Dear {{ review.manager.full_name }},

    The Review invitation you initiated for \"{{ review.preprint.title }}\" has been updated.

    {{ status_text }}

    You can view the review here: {{url}}

    {{ request.repository.name }} Team

    ", - "reviewer_review_status_change": "

    Dear {{ review.reviewer.full_name }},

    I hope this message finds you well. I am writing to inform you that the invitation we sent to ask that you add a review comment to \"{{ review.preprint.title }}\" has been withdrawn. The following reason has been given:

    {{ review.status_reason|linebreaksbr }}

    {{ request.user.signature|safe }}
    {{ request.repository.site_url }}

    " - + "reviewer_review_status_change": "

    Dear {{ review.reviewer.full_name }},

    I hope this message finds you well. I am writing to inform you that the invitation we sent to ask that you add a review comment to \"{{ review.preprint.title }}\" has been withdrawn. The following reason has been given:

    {{ review.status_reason|linebreaksbr }}

    {{ request.user.signature|safe }}
    {{ request.repository.site_url }}

    ", + "new_version_submitted": "

    {{ preprint.owner.full_name }} has uploaded a new version of the {{ request.repository.object_name }} '{{ preprint.title }}' to {{ request.repository.name }}.

    The new version can be viewed in the queue: {{ url }}.

    {{ request.repository.name }} Team
    {{ request.repository.site_url }}

    " } ] \ No newline at end of file diff --git a/src/utils/transactional_emails.py b/src/utils/transactional_emails.py index 9be1476210..39a34e930c 100644 --- a/src/utils/transactional_emails.py +++ b/src/utils/transactional_emails.py @@ -1851,6 +1851,57 @@ def preprint_version_update(**kwargs): ) +def preprint_new_version(**kwargs): + """ + Called by events.Event.ON_PREPRINT_NEW_VERSION + :param kwargs: Dictionary containing new_version, preprint and request + objects + :return: None + """ + request = kwargs.get('request') + preprint = kwargs.get('preprint') + new_version = kwargs.get('new_version') + + description = '{author} has submitted a new {obj} version.'.format( + author=request.user.full_name(), + obj=request.repository.object_name, + title=preprint.title, + ) + log_dict = { + 'level': 'Info', + 'action_text': description, + 'types': 'Submission', + 'target': preprint, + } + url = request.repository.site_url( + path=reverse( + 'version_queue' + ) + ) + # Send an email to the preprint editors + template = request.repository.new_version_submitted + email_text = render_template.get_message_content( + request, + { + 'preprint': preprint, + 'new_version': new_version, + 'url': url + }, + template, + template_is_setting=True, + ) + repo = request.repository + recipients = repo.submission_notification_recipients if repo.submission_notification_recipients.count() > 0 else repo.managers + for r in recipients.all(): + notify_helpers.send_email_with_body_from_user( + request, + '{} New Version'.format(request.repository.object_name), + r.email, + email_text, + log_dict=log_dict, + ) + + def send_cancel_corrections(**kwargs): request = kwargs.get("request") article = kwargs.get("article") From f00a476ef3532c2580deefe6fc7096f75c2a6176 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 17 Sep 2024 10:40:02 +0100 Subject: [PATCH 46/76] Remove non updated supp files. --- src/api/serializers.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/api/serializers.py b/src/api/serializers.py index 9e57422225..18f5938ce5 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -601,6 +601,7 @@ def update(self, instance, validated_data): if answer_obj not in answers: answer_obj.delete() + urls = [] for supp_file in validated_data.get('preprintsupplementaryfile_set'): url = supp_file.get('url') label = supp_file.get('label') @@ -613,6 +614,10 @@ def update(self, instance, validated_data): 'label': label, } ) + urls.append(url) + for supp_file in instance.preprintsupplementaryfile_set.all(): + if supp_file.url not in urls: + supp_file.delete() return instance From 75a3020b3542adfff792be917085a62494c78fac Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 24 Sep 2024 16:36:18 +0100 Subject: [PATCH 47/76] Added reg and activation endpoints. --- src/api/serializers.py | 69 +++++++++++++++++++++++++++++++++++++++++- src/api/urls.py | 11 +++++++ src/api/views.py | 20 ++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/api/serializers.py b/src/api/serializers.py index 18f5938ce5..f279dae8f6 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -1,9 +1,11 @@ +import uuid + from rest_framework import serializers, validators from django.db import transaction from django.shortcuts import reverse -from core import models as core_models +from core import models as core_models, logic as core_logic from journal import models as journal_models from submission import models as submission_models from repository import models as repository_models @@ -700,3 +702,68 @@ class Meta: 'abstract', 'file', ) + + +class RegisterAccountSerializer(serializers.ModelSerializer): + class Meta: + model = core_models.Account + fields = ( + 'pk', + 'email', + 'salutation', + 'first_name', + 'middle_name', + 'last_name', + 'orcid', + 'institution', + 'department', + 'biography', + 'password', + 'confirmation_code', + ) + + def create(self, validated_data): + user = super().create(validated_data) + user.set_password(validated_data['password']) + user.confirmation_code = uuid.uuid4() + user.save() + request = self.context.get("request") + core_logic.send_confirmation_link(request, user) + return user + + def update(self, instance, validated_data): + user = super().update(instance, validated_data) + try: + if validated_data.get('password'): + user.set_password(validated_data['password']) + user.save() + except KeyError: + pass + return user + + password = serializers.CharField( + max_length=128, + write_only=True, + required=True, + ) + confirmation_code = serializers.CharField( + read_only=True, + ) + + +class ActivateAccountSerializer(serializers.ModelSerializer): + class Meta: + model = core_models.Account + fields = ( + 'confirmation_code', + ) + + def update(self, instance, validated_data): + user = super().update(instance, validated_data) + user.is_active = True + user.save() + return user + + confirmation_code = serializers.CharField( + read_only=True, + ) diff --git a/src/api/urls.py b/src/api/urls.py index 2d3c9efd5b..edbe9daea3 100755 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -3,6 +3,7 @@ from rest_framework import routers from rest_framework.schemas import get_schema_view +from rest_framework.urlpatterns import format_suffix_patterns from api import views from api.oai import views as oai_views @@ -32,6 +33,16 @@ views.SubmissionAccountSearch, 'submission_account_search', ) + router.register( + r'register', + views.RegisterAccount, + 'register_account', + ) + router.register( + r'activate', + views.ActivateAccount, + 'activate_account', + ) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. diff --git a/src/api/views.py b/src/api/views.py index e3b6dbce26..b04fa43fb4 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -12,6 +12,7 @@ from rest_framework import viewsets from rest_framework.decorators import api_view, permission_classes from rest_framework import permissions +from rest_framework.views import APIView from api import serializers, permissions as api_permissions from core import models as core_models @@ -417,6 +418,25 @@ def get_queryset(self): return accounts +class RegisterAccount(viewsets.ModelViewSet): + serializer_class = serializers.RegisterAccountSerializer + http_method_names = ['post'] + + # TODO: on PUT allow only the current user + + +class ActivateAccount(viewsets.ModelViewSet): + serializer_class = serializers.ActivateAccountSerializer + http_method_names = ['put'] + + def get_queryset(self): + accounts = core_models.Account.objects.filter( + confirmation_code=self.request.data.get('confirmation_code'), + is_active=False, + ) + return accounts + + def oai(request): articles = submission_models.Article.objects.filter( stage=submission_models.STAGE_PUBLISHED From b4b21407822247a3ed389f32ed209e481e720fd3 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Fri, 11 Oct 2024 14:44:25 +0100 Subject: [PATCH 48/76] Added logout and removed password requirement --- src/api/serializers.py | 2 +- src/api/urls.py | 1 + src/api/views.py | 24 ++++++++++++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/api/serializers.py b/src/api/serializers.py index f279dae8f6..559d6e88e4 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -744,7 +744,7 @@ def update(self, instance, validated_data): password = serializers.CharField( max_length=128, write_only=True, - required=True, + required=False, ) confirmation_code = serializers.CharField( read_only=True, diff --git a/src/api/urls.py b/src/api/urls.py index edbe9daea3..0e03a7852f 100755 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -26,6 +26,7 @@ router.register(r'published_preprints', views.PublishedPreprintViewSet, 'repository_published_preprint') router.register(r'version_queue', views.RepositoryVersionQueue, 'repository_version_queue') router.register(r'user_info', views.UserInfo, 'api_user_info') +router.register(r'logout', views.Logout, basename='logout') if settings.API_ENABLE_SUBMISSION_ACCOUNT_SEARCH: router.register( diff --git a/src/api/views.py b/src/api/views.py index b04fa43fb4..e95d0ecbad 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -8,11 +8,12 @@ from django.utils import timezone from django.db.models import Q from django.db.models.functions import Lower +from django.contrib.auth import logout -from rest_framework import viewsets +from rest_framework import viewsets, status from rest_framework.decorators import api_view, permission_classes from rest_framework import permissions -from rest_framework.views import APIView +from rest_framework.response import Response from api import serializers, permissions as api_permissions from core import models as core_models @@ -418,6 +419,25 @@ def get_queryset(self): return accounts +class Logout(viewsets.ViewSet): + """ + A ViewSet for logging out the current user. + """ + + http_method_names = ['post'] + permission_classes = [ + permissions.IsAuthenticated, + ] + + @staticmethod + def create(self, request): + logout(request) + return Response( + {'detail': 'Successfully logged out.'}, + status=status.HTTP_200_OK, + ) + + class RegisterAccount(viewsets.ModelViewSet): serializer_class = serializers.RegisterAccountSerializer http_method_names = ['post'] From 8a8d12603cd3b7d1c64b76b912566cd429cd8496 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Fri, 25 Oct 2024 15:01:11 +0100 Subject: [PATCH 49/76] Adds some requested fixes. --- src/api/serializers.py | 10 ++++++++-- src/api/urls.py | 5 +++-- src/api/views.py | 31 +++++++++++++++++++++++++++++-- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/api/serializers.py b/src/api/serializers.py index 559d6e88e4..ae0a9cd748 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -724,11 +724,17 @@ class Meta: def create(self, validated_data): user = super().create(validated_data) - user.set_password(validated_data['password']) + password = validated_data.get('password') + if password: + user.set_password(password) + user.confirmation_code = uuid.uuid4() user.save() + request = self.context.get("request") - core_logic.send_confirmation_link(request, user) + if request: + core_logic.send_confirmation_link(request, user) + return user def update(self, instance, validated_data): diff --git a/src/api/urls.py b/src/api/urls.py index 0e03a7852f..d4555c52e8 100755 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -35,12 +35,12 @@ 'submission_account_search', ) router.register( - r'register', + r'account/register', views.RegisterAccount, 'register_account', ) router.register( - r'activate', + r'account/activate', views.ActivateAccount, 'activate_account', ) @@ -61,4 +61,5 @@ ), re_path(r"^swagger_ui/$", views.swagger_ui, name="swagger_ui"), re_path(r"^redoc/$", views.redoc, name="redoc"), + re_path(r"^account/update/$", views.UpdateAccountView.as_view(), name="update_account"), ] diff --git a/src/api/views.py b/src/api/views.py index e95d0ecbad..217594542e 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -9,8 +9,10 @@ from django.db.models import Q from django.db.models.functions import Lower from django.contrib.auth import logout +from django.shortcuts import get_object_or_404 from rest_framework import viewsets, status +from rest_framework.views import APIView from rest_framework.decorators import api_view, permission_classes from rest_framework import permissions from rest_framework.response import Response @@ -430,7 +432,7 @@ class Logout(viewsets.ViewSet): ] @staticmethod - def create(self, request): + def create(request): logout(request) return Response( {'detail': 'Successfully logged out.'}, @@ -442,7 +444,32 @@ class RegisterAccount(viewsets.ModelViewSet): serializer_class = serializers.RegisterAccountSerializer http_method_names = ['post'] - # TODO: on PUT allow only the current user + +class UpdateAccountView(APIView): + serializer_class = serializers.RegisterAccountSerializer + http_method_names = ['put'] + permission_classes = [ + permissions.IsAuthenticated, + ] + + def put(self, request, *args, **kwargs): + account = get_object_or_404( + core_models.Account, + email=request.user.email, + ) + data = request.data.copy() + data.pop('email', None) + serializer = self.serializer_class( + account, + data=data, + partial=True, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response( + serializer.data, + status=status.HTTP_200_OK, + ) class ActivateAccount(viewsets.ModelViewSet): From a77390e0223a119e02b4b140dc1e7d67a50aa298 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 13 Nov 2024 14:05:42 +0000 Subject: [PATCH 50/76] WIP - adds preprint version dois to identifier pages --- src/identifiers/forms.py | 35 +- src/identifiers/logic.py | 46 ++- src/identifiers/preprints.py | 8 +- src/identifiers/urls.py | 21 +- src/identifiers/views.py | 344 +++++++++++------- ...icle_identifiers.html => identifiers.html} | 37 +- .../admin/identifiers/manage_identifier.html | 4 +- 7 files changed, 310 insertions(+), 185 deletions(-) rename src/templates/admin/identifiers/{article_identifiers.html => identifiers.html} (73%) diff --git a/src/identifiers/forms.py b/src/identifiers/forms.py index 98e838ed1a..c673e83b6f 100644 --- a/src/identifiers/forms.py +++ b/src/identifiers/forms.py @@ -15,8 +15,9 @@ class Meta: ) def __init__(self, *args, **kwargs): - self.article = kwargs.pop("article") - super(IdentifierForm, self).__init__(*args, **kwargs) + self.article = kwargs.pop("article", None) + self.preprint = kwargs.pop("preprint", None) + super().__init__(*args, **kwargs) def clean(self): super().clean() @@ -46,13 +47,13 @@ def clean(self): if self.instance: idents = idents.exclude(id=self.instance.id) - if id_type == "doi" and idents.exists(): - self.add_error( - "identifier", - "This DOI already exists for another Article.", - ) - else: - if idents.filter( + if self.article: + if id_type == "doi" and idents.exists(): + self.add_error( + "identifier", + "This DOI already exists for another Article.", + ) + elif idents.filter( article__journal=self.article.journal, ).exists(): self.add_error( @@ -62,6 +63,22 @@ def clean(self): ), ) + elif self.preprint: + if id_type == "doi" and idents.exists(): + self.add_error( + "identifier", + "This DOI already exists for another Preprint.", + ) + elif idents.filter( + preprint_version__preprint__repository=self.preprint.repository, + ).exists(): + self.add_error( + "identifier", + "This identifier already exists on: {}.".format( + " ".join([ident.preprint_version.preprint.title for ident in idents]) + ), + ) + return cleaned_data def save(self, commit=True): diff --git a/src/identifiers/logic.py b/src/identifiers/logic.py index 30f9966c15..115d3d8cbc 100755 --- a/src/identifiers/logic.py +++ b/src/identifiers/logic.py @@ -9,17 +9,12 @@ import requests from bs4 import BeautifulSoup import time -import itertools -from django.urls import reverse from django.template.loader import render_to_string -from django.utils.http import urlencode from django.utils.html import strip_tags from django.conf import settings -from django.contrib import messages -from django.utils import timezone +from django.shortcuts import get_object_or_404 -import sys from utils import models as util_models from utils.function_cache import cache from utils.logger import get_logger @@ -28,6 +23,7 @@ from crossref.restful import Depositor from identifiers import models from submission import models as submission_models +from repository import models as repository_models logger = get_logger(__name__) @@ -687,3 +683,41 @@ def auto_assign_issue_doi(issue): def on_article_assign_to_issue(article, issue, user): auto_assign_issue_doi(issue) + + +def get_object_by_content_type(content_type, object_id, request): + """ + Fetches either an Article or a Preprint based on the content type. + """ + if content_type == 'article': + return get_object_or_404( + submission_models.Article, + pk=object_id, + journal=request.journal, + ) + else: + return get_object_or_404( + repository_models.Preprint, + pk=object_id, + repository=request.repository, + ) + + +def get_identifier_by_content_type(content_type, obj, identifier_id, id_type=None): + """ + Fetches the Identifier for either an Article or a Preprint. + """ + if content_type == 'article': + return get_object_or_404( + models.Identifier, + pk=identifier_id, + article=obj, + **({'id_type': id_type} if id_type else {}) + ) + else: + return get_object_or_404( + models.Identifier, + pk=identifier_id, + preprint_version__preprint=obj, + **({'id_type': id_type} if id_type else {}) + ) diff --git a/src/identifiers/preprints.py b/src/identifiers/preprints.py index 9dfd38faa1..eeafd29de6 100644 --- a/src/identifiers/preprints.py +++ b/src/identifiers/preprints.py @@ -41,6 +41,7 @@ def get_dois_for_preprint_versions(preprint_versions): def send_preprint_version_crossref_deposit(repository, versions, identifiers): + status, error = None, None identifiers = set((i for i in identifiers)) template = 'common/identifiers/crossref_preprint_batch.xml' template_context = { @@ -92,17 +93,22 @@ def send_preprint_version_crossref_deposit(repository, versions, identifiers): targets=versions, ) logger.info(status) + error = None for identifier in identifiers: crossref_status = models.CrossrefStatus.objects.get( identifier=identifier, ) crossref_status.update() + return status, error + def deposit_doi_for_preprint_version(repository, preprint_versions): if repository.crossref_enable: status, error = check_repository_crossref_settings(repository) + print(status, error) + if not error: identifiers = get_dois_for_preprint_versions(preprint_versions) return send_preprint_version_crossref_deposit( @@ -110,4 +116,4 @@ def deposit_doi_for_preprint_version(repository, preprint_versions): preprint_versions, identifiers ) - return error + return status, error diff --git a/src/identifiers/urls.py b/src/identifiers/urls.py index 92313df2f4..c3f45fe6fc 100755 --- a/src/identifiers/urls.py +++ b/src/identifiers/urls.py @@ -9,43 +9,42 @@ urlpatterns = [ re_path(r"^pingback$", views.pingback, name="crossref_pingback"), re_path( - r"^(?P\d+)/$", views.article_identifiers, name="article_identifiers" + r"^(?Particle|preprint)/(?P\d+)/$", + views.identifiers, + name="identifiers", ), re_path( - r"^(?P\d+)/$", views.article_identifiers, name="edit_identifiers" - ), - re_path( - r"^(?P\d+)/new/$", + r"^(?Particle|preprint)/(?P\d+)/new/$", views.manage_identifier, name="add_new_identifier", ), re_path( - r"^(?P\d+)/edit/(?P\d+)/$", + r"^(?Particle|preprint)/(?P\d+)/edit/(?P\d+)/$", views.manage_identifier, name="edit_identifier", ), re_path( - r"^(?P\d+)/delete/(?P\d+)/$", + r"^(?Particle|preprint)/(?P\d+)/delete/(?P\d+)/$", views.delete_identifier, name="delete_identifier", ), re_path( - r"^(?P\d+)/issue/(?P\d+)/$", + r"^(?Particle|preprint)/(?P\d+)/issue/(?P\d+)/$", views.issue_doi, name="issue_doi", ), re_path( - r"^(?P\d+)/show/(?P\d+)/$", + r"^(?Particle|preprint)/(?P\d+)/show/(?P\d+)/$", views.show_doi, name="show_doi", ), re_path( - r"^(?P\d+)/poll/(?P\d+)/$", + r"^(?Particle|preprint)/(?P\d+)/poll/(?P\d+)/$", views.poll_doi, name="poll_doi", ), re_path( - r"^(?P\d+)/poll/output/(?P\d+)/$", + r"^(?Particle|preprint)/(?P\d+)/poll/output/(?P\d+)/$", views.poll_doi_output, name="poll_doi_output", ), diff --git a/src/identifiers/views.py b/src/identifiers/views.py index 11446afc90..763a7f33ae 100755 --- a/src/identifiers/views.py +++ b/src/identifiers/views.py @@ -4,27 +4,28 @@ __maintainer__ = "Birkbeck Centre for Technology and Publishing" from django.http import HttpResponse -from django.shortcuts import reverse, get_object_or_404, redirect, render +from django.shortcuts import redirect, render from django.views.decorators.http import require_POST from django.utils.decorators import method_decorator from django.db.models import OuterRef, Subquery -from identifiers import models, forms from submission import models as submission_models -from journal import models as journal_models, views as journal_views - -from security.decorators import production_user_or_editor_required, editor_user_required -from identifiers import logic - +from journal import views as journal_views import datetime from uuid import uuid4 from django.urls import reverse from django.contrib import messages -from django.utils import timezone - +from identifiers import models, forms, logic, preprints +from core import views as core_views +from journal import models as journal_models +from security.decorators import ( + production_user_or_editor_required, + editor_user_required, +) from utils import models as util_models +from repository import models as repository_models def pingback(request): @@ -46,91 +47,116 @@ def pingback(request): @production_user_or_editor_required -def article_identifiers(request, article_id): +def identifiers( + request, + object_id, + content_type="article", +): """ - Displays a list of current article identifiers. - :param request: HttpRequest - :param article_id: Article object PK - :return: HttpResponse + Displays a list of current identifiers for either an article or a preprint. """ - article = get_object_or_404( - submission_models.Article, - pk=article_id, - journal=request.journal, + obj = logic.get_object_by_content_type( + content_type=content_type, + object_id=object_id, + request=request, ) - identifiers = models.Identifier.objects.filter(article=article) + # Only article and preprint content types are supported, all others + # will 404. + if content_type == "article": + identifier_objects = models.Identifier.objects.filter( + article=obj + ) + else: + identifier_objects = models.Identifier.objects.filter( + preprint_version__preprint=obj, + ) - template = "identifiers/article_identifiers.html" + template = "identifiers/identifiers.html" context = { - "article": article, - "identifiers": identifiers, + "object": obj, + "identifiers": identifier_objects, + "content_type": content_type, } - - return render(request, template, context) + return render( + request, + template, + context, + ) @production_user_or_editor_required -def manage_identifier(request, article_id, identifier_id=None): +def manage_identifier( + request, + object_id, + identifier_id=None, + content_type="article", +): """ - Allows an editor to add a new or edit and existing identifier. - :param request: HttpRequest - :param article_id: Article object PK - :param identifier_id: Identifier object PK, optional - :return: HttpResponse or Redirect + Allows an editor to add a new or edit an existing identifier. """ - article = get_object_or_404( - submission_models.Article, - pk=article_id, - journal=request.journal, + obj = logic.get_object_by_content_type( + content_type=content_type, + object_id=object_id, + request=request, ) identifier = ( - get_object_or_404( - models.Identifier, - pk=identifier_id, - article=article, - ) - if identifier_id - else None + logic.get_identifier_by_content_type( + content_type=content_type, + obj=obj, + identifier_id=identifier_id, + ) if identifier_id else None ) form = forms.IdentifierForm( instance=identifier, - article=article, + article=obj if content_type == "article" else None, + preprint=obj if content_type == "preprint" else None, ) if request.POST: form = forms.IdentifierForm( request.POST, instance=identifier, - article=article, + article=obj if content_type == "article" else None, + preprint=obj if content_type == "preprint" else None, ) - if form.is_valid(): form.save() - messages.add_message( + messages.success( request, - messages.SUCCESS, "Identifier saved.", ) return redirect( reverse( - "article_identifiers", - kwargs={"article_id": article.pk}, - ) + "identifiers", + kwargs={ + "content_type": content_type, + "object_id": obj.pk, + }, + ), ) template = "identifiers/manage_identifier.html" context = { - "article": article, + "object": obj, "identifier": identifier, "form": form, + "content_type": content_type, } - - return render(request, template, context) + return render( + request, + template, + context, + ) @production_user_or_editor_required -def show_doi(request, article_id, identifier_id): +def show_doi( + request, + object_id, + identifier_id, + content_type="article", +): """ Shows a DOI deposit :param request: HttpRequest @@ -138,17 +164,15 @@ def show_doi(request, article_id, identifier_id): :param identifier_id: Identifier object PK :return: HttpRedirect """ - from utils import setting_handler - - article = get_object_or_404( - submission_models.Article, - pk=article_id, - journal=request.journal, + obj = logic.get_object_by_content_type( + content_type=content_type, + object_id=object_id, + request=request, ) - identifier = get_object_or_404( - models.Identifier, - pk=identifier_id, - article=article, + identifier = logic.get_identifier_by_content_type( + content_type=content_type, + obj=obj, + identifier_id=identifier_id, id_type="doi", ) @@ -156,17 +180,31 @@ def show_doi(request, article_id, identifier_id): document = identifier.crossrefstatus.latest_deposit.document if not document: raise AttributeError - return HttpResponse(document, content_type="application/xml") + return HttpResponse( + document, + content_type="application/xml", + ) except AttributeError: template_context = logic.create_crossref_doi_batch_context( - request.journal, set([identifier]) + request.journal, + {identifier}, ) template = "common/identifiers/crossref_doi_batch.xml" - return render(None, template, template_context, content_type="application/xml") + return render( + None, + template, + template_context, + content_type="application/xml", + ) @production_user_or_editor_required -def poll_doi(request, article_id, identifier_id): +def poll_doi( + request, + object_id, + identifier_id, + content_type="article", +): """ Polls crossref for DOI info :param request: HttpRequest @@ -174,31 +212,32 @@ def poll_doi(request, article_id, identifier_id): :param identifier_id: Identifier object PK :return: HttpRedirect """ - from utils import setting_handler - - article = get_object_or_404( - submission_models.Article, - pk=article_id, - journal=request.journal, + obj = logic.get_object_by_content_type( + content_type=content_type, + object_id=object_id, + request=request, ) - identifier = get_object_or_404( - models.Identifier, - pk=identifier_id, - article=article, + identifier = logic.get_identifier_by_content_type( + content_type=content_type, + obj=obj, + identifier_id=identifier_id, id_type="doi", ) # Scenario 1: The identifier has not been polled or deposited before. # It needs a CrossrefStatus object created. if not identifier.crossrefstatus: - models.CrossrefStatus.objects.create(identifier=identifier) - + models.CrossrefStatus.objects.create( + identifier=identifier, + ) # Scenario 2: The identifier has been deposited before. # It will have a CrossrefStatus and a CrossrefDeposit already. elif identifier.crossrefstatus.latest_deposit: status, error = identifier.crossrefstatus.latest_deposit.poll() messages.add_message( - request, messages.INFO if not error else messages.ERROR, status + request, + messages.INFO if not error else messages.ERROR, + status, ) # Scenario 3: The identifier has only been polled before @@ -208,17 +247,24 @@ def poll_doi(request, article_id, identifier_id): # In all scenarios, update the CrossrefStatus last. identifier.crossrefstatus.update() - return redirect( reverse( - "article_identifiers", - kwargs={"article_id": article.pk}, - ) + "identifiers", + kwargs={ + "content_type": content_type, + "object_id": obj.pk, + }, + ), ) @production_user_or_editor_required -def poll_doi_output(request, article_id, identifier_id): +def poll_doi_output( + request, + object_id, + identifier_id, + content_type="article", +): """ Gets Crossref response stored on CrossrefDeposit :param request: HttpRequest @@ -226,17 +272,15 @@ def poll_doi_output(request, article_id, identifier_id): :param identifier_id: Identifier object PK :return: HttpRedirect """ - from utils import setting_handler - - article = get_object_or_404( - submission_models.Article, - pk=article_id, - journal=request.journal, + obj = logic.get_object_by_content_type( + content_type=content_type, + object_id=object_id, + request=request, ) - identifier = get_object_or_404( - models.Identifier, - pk=identifier_id, - article=article, + identifier = logic.get_identifier_by_content_type( + content_type=content_type, + obj=obj, + identifier_id=identifier_id, id_type="doi", ) @@ -244,24 +288,26 @@ def poll_doi_output(request, article_id, identifier_id): return HttpResponse("Error: no deposit found") elif "doi_batch" not in identifier.crossrefstatus.latest_deposit.result_text: return HttpResponse(identifier.crossrefstatus.latest_deposit.result_text) - else: - text = identifier.crossrefstatus.latest_deposit.get_record_diagnostic( - identifier.identifier - ) - if text: - resp = HttpResponse(text, content_type="application/xml") - else: - resp = HttpResponse( - identifier.crossrefstatus.latest_deposit.result_text, - content_type="application/xml", - ) - resp["Content-Disposition"] = "inline;" - return resp + + text = identifier.crossrefstatus.latest_deposit.get_record_diagnostic( + identifier.identifier, + ) + resp = HttpResponse( + text or identifier.crossrefstatus.latest_deposit.result_text, + content_type="application/xml", + ) + resp["Content-Disposition"] = "inline;" + return resp @require_POST @production_user_or_editor_required -def issue_doi(request, article_id, identifier_id): +def issue_doi( + request, + object_id, + identifier_id, + content_type="article", +): """ Issues a DOI identifier :param request: HttpRequest @@ -269,34 +315,53 @@ def issue_doi(request, article_id, identifier_id): :param identifier_id: Identifier object PK :return: HttpRedirect """ - article = get_object_or_404( - submission_models.Article, - pk=article_id, - journal=request.journal, + obj = logic.get_object_by_content_type( + content_type=content_type, + object_id=object_id, + request=request, ) - identifier = get_object_or_404( - models.Identifier, - pk=identifier_id, - article=article, + identifier = logic.get_identifier_by_content_type( + content_type=content_type, + obj=obj, + identifier_id=identifier_id, id_type="doi", ) - status, error = identifier.register() + if content_type == "article": + status, error = identifier.register() + else: + preprint_versions = repository_models.PreprintVersion.objects.filter( + preprint=obj, + ) + status, error = preprints.deposit_doi_for_preprint_version( + request.repository, + preprint_versions, + ) + messages.add_message( - request, messages.INFO if not error else messages.ERROR, status + request, + messages.INFO if not error else messages.ERROR, + status, ) - return redirect( reverse( - "article_identifiers", - kwargs={"article_id": article.pk}, - ) + "identifiers", + kwargs={ + "content_type": content_type, + "object_id": obj.pk, + }, + ), ) @require_POST @production_user_or_editor_required -def delete_identifier(request, article_id, identifier_id): +def delete_identifier( + request, + object_id, + identifier_id, + content_type="article", +): """ Deletes an identifier :param request: HttpRequest @@ -304,25 +369,30 @@ def delete_identifier(request, article_id, identifier_id): :param identifier_id: Identifier object PK :return: HttpRedirect """ - article = get_object_or_404( - submission_models.Article, - pk=article_id, - journal=request.journal, + obj = logic.get_object_by_content_type( + content_type=content_type, + object_id=object_id, + request=request, ) - identifier = get_object_or_404( - models.Identifier, - pk=identifier_id, - article=article, + identifier = logic.get_identifier_by_content_type( + content_type=content_type, + obj=obj, + identifier_id=identifier_id, ) identifier.delete() - messages.add_message(request, messages.SUCCESS, "Identifier deleted.") - + messages.success( + request, + "Identifier deleted.", + ) return redirect( reverse( - "article_identifiers", - kwargs={"article_id": article.pk}, - ) + "identifiers", + kwargs={ + "content_type": content_type, + "object_id": obj.pk, + }, + ), ) diff --git a/src/templates/admin/identifiers/article_identifiers.html b/src/templates/admin/identifiers/identifiers.html similarity index 73% rename from src/templates/admin/identifiers/article_identifiers.html rename to src/templates/admin/identifiers/identifiers.html index df4ce08a60..4e09dd7dd7 100644 --- a/src/templates/admin/identifiers/article_identifiers.html +++ b/src/templates/admin/identifiers/identifiers.html @@ -1,24 +1,21 @@ -{% extends "admin/core/base.html" %}} +{% extends "admin/core/base.html" %} {% load foundation %} -{% block title %}Edit Identifiers - {{ article.pk }}{% endblock title %} - +{% block title %}Edit Identifiers - {{ object.pk }}{% endblock title %} {% block breadcrumbs %} {{ block.super }}
  • Edit
  • -
  • {{ article.safe_title }}
  • +
  • {{ object.safe_title }}
  • Identifiers
  • {% endblock breadcrumbs %} {% block body %}
    -
    - -
    +

    Edit Identifiers

    - Add Identifier + Add Identifier
    @@ -37,12 +34,15 @@

    Edit Identifiers

    {% csrf_token %} - + diff --git a/src/templates/admin/identifiers/manage_identifier.html b/src/templates/admin/identifiers/manage_identifier.html index 7fda63bdb3..10b61fc7fa 100644 --- a/src/templates/admin/identifiers/manage_identifier.html +++ b/src/templates/admin/identifiers/manage_identifier.html @@ -10,7 +10,7 @@ {{ block.super }}
  • Edit
  • {{ article.safe_title }}
  • -
  • Identifiers
  • +
  • Identifiers
  • {% endblock breadcrumbs %} {% block body %} @@ -20,7 +20,7 @@

    {% if identifier %}Edit Identifier - {{ identifier.pk }}{% else %}Add New Identifier{% endif %}

    -  Back +  Back

    Dois should be entered on their ID format, not their URL format

    Example DOI based on pattern for this article: {{ article.render_sample_doi }}

    From 2a618a3640b07228ebfaba3a7fafad89984ea673 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 14 Nov 2024 14:42:06 +0000 Subject: [PATCH 51/76] Preprint version dois can now be polled for status. --- src/identifiers/forms.py | 9 +-- src/identifiers/logic.py | 36 +++++++---- src/identifiers/models.py | 64 ++++++++++++++----- .../admin/identifiers/identifiers.html | 1 - .../admin/identifiers/manage_identifier.html | 5 +- src/templates/admin/repository/article.html | 4 ++ .../common/identifiers/crossref_preprint.xml | 2 +- 7 files changed, 83 insertions(+), 38 deletions(-) diff --git a/src/identifiers/forms.py b/src/identifiers/forms.py index c673e83b6f..a79204083a 100644 --- a/src/identifiers/forms.py +++ b/src/identifiers/forms.py @@ -83,12 +83,13 @@ def clean(self): def save(self, commit=True): identifier = super(IdentifierForm, self).save(commit=False) - - if self.article: - identifier.article = self.article + if not self.instance: + if self.article: + identifier.article = self.article + elif self.preprint: + identifier.preprint_version = self.preprint.current_version if commit: - pass identifier.save() return identifier diff --git a/src/identifiers/logic.py b/src/identifiers/logic.py index 115d3d8cbc..6aeef4ab71 100755 --- a/src/identifiers/logic.py +++ b/src/identifiers/logic.py @@ -24,6 +24,7 @@ from identifiers import models from submission import models as submission_models from repository import models as repository_models +from journal import models as journal_models logger = get_logger(__name__) @@ -113,20 +114,29 @@ def check_crossref_settings(journal): @cache(30) -def get_poll_settings(journal): - test_mode = ( - setting_handler.get_setting( - "Identifiers", "crossref_test", journal +def get_poll_settings(parent_object): + if isinstance(parent_object, journal_models.Journal): + test_mode = ( + setting_handler.get_setting( + "Identifiers", + "crossref_test", + parent_object, + ).processed_value + or settings.DEBUG + ) + username = setting_handler.get_setting( + "Identifiers", + "crossref_username", + parent_object, ).processed_value - or settings.DEBUG - ) - username = setting_handler.get_setting( - "Identifiers", "crossref_username", journal - ).processed_value - password = setting_handler.get_setting( - "Identifiers", "crossref_password", journal - ).processed_value - return test_mode, username, password + password = setting_handler.get_setting( + "Identifiers", + "crossref_password", + parent_object, + ).processed_value + return test_mode, username, password + elif isinstance(parent_object, repository_models.Repository): + return parent_object.crossref_test_mode, parent_object.crossref_username, parent_object.crossref_password def get_dois_for_articles(articles, create=False): diff --git a/src/identifiers/models.py b/src/identifiers/models.py index 2445309a37..160bdc707f 100755 --- a/src/identifiers/models.py +++ b/src/identifiers/models.py @@ -5,23 +5,22 @@ import re import sys -from django.utils import timezone +import warnings import requests +from bs4 import BeautifulSoup + +from django.utils import timezone from django.db import models from django.dispatch import receiver from django.db.models.signals import post_save -from django.core.exceptions import ObjectDoesNotExist +from django.conf import settings from identifiers import logic from utils import shared from utils.logger import get_logger -from utils import setting_handler from utils.function_cache import cache -from django.conf import settings - -from bs4 import BeautifulSoup logger = get_logger(__name__) @@ -81,26 +80,59 @@ class Meta: @property @cache(30) - def journal(self): + def parent_object(self): + """ + Returns either a single journal or repository object. + Raises an error if multiple journals or repositories are linked. + """ + # Step 1: Check for linked journals journals = set( - [ - crossref_status.identifier.article.journal - for crossref_status in self.crossrefstatus_set.all() - ] + crossref_status.identifier.article.journal + for crossref_status in self.crossrefstatus_set.all() + if crossref_status.identifier.article ) + if len(journals) > 1: error = f"Identifiers from multiple journals passed to CrossrefDeposit: {journals}" logger.debug(error) + return None elif len(journals) == 1: return journals.pop() - else: + + # Step 2: If no journals found, check for linked repositories + repositories = set( + crossref_status.identifier.preprint_version.preprint.repository + for crossref_status in self.crossrefstatus_set.all() + if crossref_status.identifier.preprint_version + ) + + if len(repositories) > 1: + error = f"Identifiers from multiple repositories passed to CrossrefDeposit: {repositories}" + logger.debug(error) return None + elif len(repositories) == 1: + return repositories.pop() + + # Step 3: If no journals or repositories found, return None + return None + def journal(self): + """ + Deprecated. Returns self.parent_object for backwards compatibility. + """ + warnings.warn( + "The 'journal' method is deprecated. Use 'parent_object' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.parent_object def poll(self): self.polling_attempts += 1 self.save() - test_mode, username, password = logic.get_poll_settings(self.journal) + test_mode, username, password = logic.get_poll_settings( + self.parent_object + ) if test_mode: test_var = "test" @@ -127,15 +159,15 @@ def poll(self): self.citation_success = not ' status="error"' in self.result_text self.save() logger.debug(self) - return f"Polled ({self.journal.code})", False + return f"Polled ({self.parent_object.name})", False except requests.RequestException as e: self.success = False self.has_result = True - self.result_text = f"Error ({self.journal.code}): {e}" + self.result_text = f"Error ({self.parent_object.name}): {e}" self.save() logger.error(self.result_text) logger.error(self) - return f"Error ({self.journal.code})", True + return f"Error ({self.parent_object.name})", True def get_record_diagnostic(self, doi): soup = BeautifulSoup(self.result_text, "lxml-xml") diff --git a/src/templates/admin/identifiers/identifiers.html b/src/templates/admin/identifiers/identifiers.html index 4e09dd7dd7..41c09310fd 100644 --- a/src/templates/admin/identifiers/identifiers.html +++ b/src/templates/admin/identifiers/identifiers.html @@ -5,7 +5,6 @@ {% block breadcrumbs %} {{ block.super }} -
  • Edit
  • {{ object.safe_title }}
  • Identifiers
  • {% endblock breadcrumbs %} diff --git a/src/templates/admin/identifiers/manage_identifier.html b/src/templates/admin/identifiers/manage_identifier.html index 10b61fc7fa..a27ab3498a 100644 --- a/src/templates/admin/identifiers/manage_identifier.html +++ b/src/templates/admin/identifiers/manage_identifier.html @@ -8,9 +8,8 @@ {% block breadcrumbs %} {{ block.super }} -
  • Edit
  • -
  • {{ article.safe_title }}
  • -
  • Identifiers
  • +
  • {{ object.safe_title }}
  • +
  • Manage
  • {% endblock breadcrumbs %} {% block body %} diff --git a/src/templates/admin/repository/article.html b/src/templates/admin/repository/article.html index 28ff184d51..994c298aa0 100644 --- a/src/templates/admin/repository/article.html +++ b/src/templates/admin/repository/article.html @@ -182,6 +182,10 @@

    Controls

     Send to Journal +
  • +  Manage Identifiers + +
  • diff --git a/src/templates/common/identifiers/crossref_preprint.xml b/src/templates/common/identifiers/crossref_preprint.xml index cbbb64f1a3..4cc091730a 100644 --- a/src/templates/common/identifiers/crossref_preprint.xml +++ b/src/templates/common/identifiers/crossref_preprint.xml @@ -19,7 +19,7 @@ {{ version.date_time.year }} - + {{ version.preprint.license.url }} From eaddfe7a64d91d429d1807cc65b1665f6ec222c8 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 14 Nov 2024 15:40:03 +0000 Subject: [PATCH 52/76] Tweaks form logic, adds note for repos. --- src/identifiers/forms.py | 9 ++++----- src/templates/admin/identifiers/identifiers.html | 8 ++++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/identifiers/forms.py b/src/identifiers/forms.py index a79204083a..bf3096d106 100644 --- a/src/identifiers/forms.py +++ b/src/identifiers/forms.py @@ -83,11 +83,10 @@ def clean(self): def save(self, commit=True): identifier = super(IdentifierForm, self).save(commit=False) - if not self.instance: - if self.article: - identifier.article = self.article - elif self.preprint: - identifier.preprint_version = self.preprint.current_version + if self.article: + identifier.article = self.article + elif self.preprint: + identifier.preprint_version = self.preprint.current_version if commit: identifier.save() diff --git a/src/templates/admin/identifiers/identifiers.html b/src/templates/admin/identifiers/identifiers.html index 41c09310fd..c30e7d6feb 100644 --- a/src/templates/admin/identifiers/identifiers.html +++ b/src/templates/admin/identifiers/identifiers.html @@ -16,6 +16,14 @@

    Edit Identifiers

    Add Identifier
    + {% if request.repository %} +
    +

    + + Note: You can only add a DOI to a preprint if it has a current version.{% if not object.current_version %} This preprint does not have a current version.{% endif %} +

    +
    + {% endif %}
    {{ ident.get_id_type_display }}{{ ident.get_id_type_display }} + {% if ident.preprint_version %} + (Preprint Version: {{ ident.preprint_version.version }}) + {% endif %} + {{ ident.identifier }} - -   - Edit + +  Edit @@ -63,14 +63,14 @@

    Edit Identifiers

    {% if ident.get_id_type_display == 'DOI' %} - {% endif %} {% if ident.get_id_type_display == 'DOI' %} - + {% if ident.crossrefstatus.latest_deposit %} View XML {% else %} @@ -81,22 +81,21 @@

    Edit Identifiers

    {% if ident.get_id_type_display == 'DOI' and ident.crossrefstatus.latest_deposit %} - + View XML {% endif %} {% if ident.get_id_type_display == 'DOI' %} - + Poll for status {% endif %} -
    From df21b973a34d4ebd48917ea4565b96359583ecca Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 19 Nov 2024 11:39:37 +0000 Subject: [PATCH 53/76] Adds an identifiers endpoint. Displays only published and enabled identifiers --- src/api/serializers.py | 12 +++++++++++ src/api/urls.py | 2 ++ src/api/views.py | 46 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/src/api/serializers.py b/src/api/serializers.py index ae0a9cd748..b352ec3d06 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -9,6 +9,7 @@ from journal import models as journal_models from submission import models as submission_models from repository import models as repository_models +from identifiers import models as identifier_models from events import logic as event_logic @@ -773,3 +774,14 @@ def update(self, instance, validated_data): confirmation_code = serializers.CharField( read_only=True, ) + + +class IdentifierSerializer(serializers.ModelSerializer): + class Meta: + model = identifier_models.Identifier + fields = ( + 'id_type', + 'identifier', + 'article', + 'preprint_version', + ) diff --git a/src/api/urls.py b/src/api/urls.py index d4555c52e8..0fbf2912a2 100755 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -25,6 +25,8 @@ router.register(r'repository_subjects', views.RepositorySubjects, 'repository_preprint_subjects') router.register(r'published_preprints', views.PublishedPreprintViewSet, 'repository_published_preprint') router.register(r'version_queue', views.RepositoryVersionQueue, 'repository_version_queue') +router.register(r'identifiers', views.Identifiers, 'api_identifiers') + router.register(r'user_info', views.UserInfo, 'api_user_info') router.register(r'logout', views.Logout, basename='logout') diff --git a/src/api/views.py b/src/api/views.py index 217594542e..d2697c7946 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -22,6 +22,7 @@ from submission import models as submission_models from journal import models as journal_models from repository import models as repository_models +from identifiers import models as identifier_models @api_view(["GET"]) @@ -484,6 +485,51 @@ def get_queryset(self): return accounts +class Identifiers(viewsets.ModelViewSet): + serializer_class = serializers.IdentifierSerializer + http_method_names = ['get'] + + def get_queryset(self): + preprint_id = self.request.GET.get('preprint_id') + preprint_version_id = self.request.GET.get('preprint_version_id') + article_id = self.request.GET.get('article_id') + + if self.request.repository: + return self._get_repository_identifiers( + preprint_id, + preprint_version_id, + ) + elif self.request.journal: + return self._get_journal_identifiers(article_id) + return identifier_models.Identifier.objects.none() + + def _get_repository_identifiers(self, preprint_id, preprint_version_id): + queryset = identifier_models.Identifier.objects.filter( + preprint_version__preprint__repository=self.request.repository, + preprint_version__preprint__date_published__lte=timezone.now(), + enabled=True, + ) + if preprint_id: + queryset = queryset.filter( + preprint_version__preprint__pk=preprint_id, + ) + elif preprint_version_id: + queryset = queryset.filter( + preprint_version__pk=preprint_version_id, + ) + return queryset + + def _get_journal_identifiers(self, article_id): + queryset = identifier_models.Identifier.objects.filter( + article__journal=self.request.journal, + article__date_published__lte=timezone.now(), + enabled=True, + ) + if article_id: + queryset = queryset.filter(article__pk=article_id) + return queryset + + def oai(request): articles = submission_models.Article.objects.filter( stage=submission_models.STAGE_PUBLISHED From 0f57beb6ea2cf8ae69d76c5eb2593e5e82014528 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 21 Nov 2024 12:49:27 +0000 Subject: [PATCH 54/76] Limits published preprints to the current repo. --- src/api/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/views.py b/src/api/views.py index d2697c7946..b11de947f8 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -253,6 +253,7 @@ def get_queryset(self): return preprints.filter( date_published__isnull=False, stage=repository_models.STAGE_PREPRINT_PUBLISHED, + repository=self.request.repository, ).order_by( '-date_published', 'title', From acc4b7ce58c7433a9865abc686c1fa0921e53c3e Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 8 May 2025 10:22:18 +0100 Subject: [PATCH 55/76] Add support for multi subject filtering --- src/api/views.py | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/api/views.py b/src/api/views.py index b11de947f8..8b89eabd76 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -185,14 +185,17 @@ def get_queryset(self): preprints = repository_models.Preprint.objects.filter( repository=self.request.repository, ) + search_term = self.request.query_params.get("search") stage = self.request.query_params.get("stage") - subject = self.request.query_params.get("subject") + subjects = self.request.query_params.getlist("subject") if search_term: split_search_term = search_term.split(" ") - lower_split_search_term = [term.lower() for term in - split_search_term] + lower_split_search_term = [ + term.lower() + for term in split_search_term + ] # Initial filter on Title, Abstract and Keywords. preprint_search = preprints.filter( @@ -204,7 +207,7 @@ def get_queryset(self): from_author = repository_models.PreprintAuthor.objects.annotate( lower_first_name=Lower('account__first_name'), lower_middle_name=Lower('account__middle_name'), - lower_last_name=Lower('account__last_name') + lower_last_name=Lower('account__last_name'), ).filter( Q(lower_first_name__in=lower_split_search_term) | Q(lower_middle_name__in=lower_split_search_term) | @@ -213,17 +216,17 @@ def get_queryset(self): ) preprints_from_author = [ - pa.preprint for pa in - repository_models.PreprintAuthor.objects.filter( + pa.preprint + for pa in repository_models.PreprintAuthor.objects.filter( pk__in=from_author, preprint__date_published__lte=timezone.now(), ) ] - preprint_pks = list(preprint.pk for preprint in - set(list( - preprint_search) + preprints_from_author) - ) + preprint_pks = list({ + preprint.pk + for preprint in list(preprint_search) + preprints_from_author + }) preprints = repository_models.Preprint.objects.filter( pk__in=preprint_pks, @@ -234,9 +237,9 @@ def get_queryset(self): stage=stage, ) - if subject: + if subjects: preprints = preprints.filter( - subject__name=subject, + subject__name__in=subjects, ) return preprints @@ -250,10 +253,20 @@ class PublishedPreprintViewSet(PreprintViewSet): def get_queryset(self): preprints = super().get_queryset() + + subjects = self.request.query_params.getlist('subject') + + filters = { + 'date_published__isnull': False, + 'stage': repository_models.STAGE_PREPRINT_PUBLISHED, + 'repository': self.request.repository, + } + + if subjects: + filters['subject__name__in'] = subjects + return preprints.filter( - date_published__isnull=False, - stage=repository_models.STAGE_PREPRINT_PUBLISHED, - repository=self.request.repository, + **filters, ).order_by( '-date_published', 'title', From e193bf0f6e0d4d7111a1756a5463c3170c86259a Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 8 May 2025 10:24:05 +0100 Subject: [PATCH 56/76] Add remote fix --- src/api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/views.py b/src/api/views.py index 8b89eabd76..3e967e7ebf 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -201,7 +201,7 @@ def get_queryset(self): preprint_search = preprints.filter( Q(title__icontains=search_term) | Q(abstract__icontains=search_term) | - Q(keywords__word__in=split_search_term) + Q(keywords__word=search_term) ) from_author = repository_models.PreprintAuthor.objects.annotate( From 9597d0642f6d232ce02ad8019ed730a90893000c Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 26 Feb 2026 15:42:30 +0000 Subject: [PATCH 57/76] chore: fixing issues post merge. --- src/api/serializers.py | 1 - src/api/tests/test_api.py | 1 + src/api/tests/test_preprints.py | 301 ++++++++++++++++++ src/events/registration.py | 1 + src/identifiers/forms.py | 9 +- src/identifiers/logic.py | 10 + src/identifiers/tests/test_models.py | 2 +- src/identifiers/urls.py | 11 + src/identifiers/views.py | 39 ++- .../migrations/0052_merge_20260226_1524.py | 15 + ...pository_new_version_submitted_and_more.py | 35 ++ .../migrations/0089_merge_20260226_1524.py | 14 + 12 files changed, 425 insertions(+), 14 deletions(-) create mode 100644 src/api/tests/test_preprints.py create mode 100644 src/repository/migrations/0052_merge_20260226_1524.py create mode 100644 src/repository/migrations/0053_alter_historicalrepository_new_version_submitted_and_more.py create mode 100644 src/submission/migrations/0089_merge_20260226_1524.py diff --git a/src/api/serializers.py b/src/api/serializers.py index b352ec3d06..ccd2f701f9 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -378,7 +378,6 @@ class Meta: "preprint_doi", "authors", "subject", - "files", "versions", "supplementary_files", "additional_field_answers", diff --git a/src/api/tests/test_api.py b/src/api/tests/test_api.py index 876c562b46..227a79301e 100644 --- a/src/api/tests/test_api.py +++ b/src/api/tests/test_api.py @@ -89,3 +89,4 @@ def test_editor_cannot_assign_journal_manager_role(self): journal_manager_role, ) ) + diff --git a/src/api/tests/test_preprints.py b/src/api/tests/test_preprints.py new file mode 100644 index 0000000000..46afea6e40 --- /dev/null +++ b/src/api/tests/test_preprints.py @@ -0,0 +1,301 @@ +""" +Tests for preprint-related conflict resolutions from the iowa-and-isolinear integration. + +NOTE: This file is intentionally in api/tests/ and named test_preprints.py so that +it runs AFTER test_preprint_oai.py alphabetically. The OAI tests hardcode object PKs +in expected XML output, so any test class that creates Preprint objects before them +will shift those PKs and cause failures. +""" +from uuid import uuid4 + +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils import timezone + +from rest_framework.test import APIClient + +from identifiers import forms as identifier_forms +from identifiers import models as identifier_models +from repository import models as repository_models +from utils.testing import helpers +from utils.setting_handler import save_setting + + +PREPRINT_FORMS_DOMAIN = "preprint-forms-test.domain.com" +PREPRINT_FORMS_DOMAIN_2 = "preprint-forms-test-2.domain.com" +PREPRINT_API_DOMAIN = "preprint-api-test.domain.com" + + +class TestIdentifierFormWithPreprint(TestCase): + """ + Tests for IdentifierForm when used with preprints (commit 54 conflict resolution). + + The form gained a `preprint` kwarg alongside the existing `article` kwarg. + DOI uniqueness is global; pubid uniqueness is scoped to the repository. + """ + + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.press.save() + cls.author = helpers.create_user("preprint.identifier@test.com") + + cls.repo_one, cls.subject_one = helpers.create_repository( + cls.press, [], [], domain=PREPRINT_FORMS_DOMAIN + ) + cls.repo_two = repository_models.Repository.objects.create( + press=cls.press, + name="Second Test Repository", + short_name="testrepo2", + object_name="Preprint", + object_name_plural="Preprints", + publisher="Test Publisher", + live=True, + domain=PREPRINT_FORMS_DOMAIN_2, + ) + cls.subject_two = repository_models.Subject.objects.create( + repository=cls.repo_two, + name="Repo Two Subject", + slug="repo-two-subject", + enabled=True, + ) + + cls.preprint_one = helpers.create_preprint( + cls.repo_one, cls.author, cls.subject_one, title="Preprint One" + ) + cls.preprint_one.make_new_version(cls.preprint_one.submission_file) + + cls.preprint_two = helpers.create_preprint( + cls.repo_one, cls.author, cls.subject_one, title="Preprint Two" + ) + cls.preprint_two.make_new_version(cls.preprint_two.submission_file) + + cls.preprint_other_repo = helpers.create_preprint( + cls.repo_two, cls.author, cls.subject_two, title="Preprint Other Repo" + ) + cls.preprint_other_repo.make_new_version(cls.preprint_other_repo.submission_file) + + def test_add_doi_to_preprint(self): + """Can add a new DOI to a preprint.""" + form = identifier_forms.IdentifierForm( + {"id_type": "doi", "identifier": "10.9999/preprint-new", "enabled": True}, + preprint=self.preprint_one, + ) + self.assertTrue(form.is_valid()) + + def test_add_pubid_to_preprint(self): + """Can add a new pubid to a preprint.""" + form = identifier_forms.IdentifierForm( + {"id_type": "pubid", "identifier": "preprint-new-pubid", "enabled": True}, + preprint=self.preprint_one, + ) + self.assertTrue(form.is_valid()) + + def test_duplicate_doi_same_repository(self): + """Cannot add a DOI that already exists on another preprint in the same repository.""" + identifier_models.Identifier.objects.create( + id_type="doi", + identifier="10.9999/preprint-dup-doi", + enabled=True, + preprint_version=self.preprint_one.current_version, + ) + form = identifier_forms.IdentifierForm( + {"id_type": "doi", "identifier": "10.9999/preprint-dup-doi", "enabled": True}, + preprint=self.preprint_two, + ) + self.assertFalse(form.is_valid()) + + def test_duplicate_doi_different_repositories(self): + """DOIs must be globally unique: cannot reuse a DOI even across different repositories.""" + identifier_models.Identifier.objects.create( + id_type="doi", + identifier="10.9999/preprint-cross-repo-doi", + enabled=True, + preprint_version=self.preprint_one.current_version, + ) + form = identifier_forms.IdentifierForm( + { + "id_type": "doi", + "identifier": "10.9999/preprint-cross-repo-doi", + "enabled": True, + }, + preprint=self.preprint_other_repo, + ) + self.assertFalse(form.is_valid()) + + def test_duplicate_pubid_same_repository(self): + """Cannot add a pubid that already exists in the same repository.""" + identifier_models.Identifier.objects.create( + id_type="pubid", + identifier="preprint-dup-pubid", + enabled=True, + preprint_version=self.preprint_one.current_version, + ) + form = identifier_forms.IdentifierForm( + {"id_type": "pubid", "identifier": "preprint-dup-pubid", "enabled": True}, + preprint=self.preprint_two, + ) + self.assertFalse(form.is_valid()) + + def test_duplicate_pubid_different_repositories_is_allowed(self): + """The same pubid can exist in two different repositories.""" + identifier_models.Identifier.objects.create( + id_type="pubid", + identifier="preprint-shared-pubid", + enabled=True, + preprint_version=self.preprint_one.current_version, + ) + form = identifier_forms.IdentifierForm( + {"id_type": "pubid", "identifier": "preprint-shared-pubid", "enabled": True}, + preprint=self.preprint_other_repo, + ) + self.assertTrue(form.is_valid()) + + +class TestCrossrefDepositParentObject(TestCase): + """ + Tests for CrossrefDeposit.parent_object (commit 55 conflict resolution). + + The property replaced `journal` with `parent_object` so it can return either + a Journal (for article DOIs) or a Repository (for preprint version DOIs). + """ + + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.journal_one, _ = helpers.create_journals() + save_setting("general", "journal_issn", cls.journal_one, "1234-5678") + save_setting("general", "print_issn", cls.journal_one, "8765-4321") + save_setting("Identifiers", "use_crossref", cls.journal_one, True) + save_setting("Identifiers", "crossref_prefix", cls.journal_one, "10.0000") + + cls.author = helpers.create_user("deposit.parent@test.com") + cls.repo, cls.subject = helpers.create_repository( + cls.press, [], [], domain="deposit-parent-test.domain.com" + ) + cls.article = helpers.create_article(cls.journal_one) + cls.preprint = helpers.create_preprint(cls.repo, cls.author, cls.subject) + cls.preprint.make_new_version(cls.preprint.submission_file) + + def _make_deposit_with_identifier(self, identifier): + deposit = identifier_models.CrossrefDeposit.objects.create( + document="", + file_name=uuid4(), + ) + crossref_status, _ = identifier_models.CrossrefStatus.objects.get_or_create( + identifier=identifier, + ) + crossref_status.deposits.add(deposit) + crossref_status.save() + return deposit + + def test_parent_object_returns_journal_for_article_identifier(self): + """parent_object returns the journal when the deposit covers article DOIs.""" + identifier = identifier_models.Identifier.objects.create( + id_type="doi", + identifier="10.0000/parent-obj-article-test", + article=self.article, + ) + deposit = self._make_deposit_with_identifier(identifier) + self.assertEqual(deposit.parent_object, self.article.journal) + + def test_parent_object_returns_repository_for_preprint_identifier(self): + """parent_object returns the repository when the deposit covers preprint DOIs.""" + identifier = identifier_models.Identifier.objects.create( + id_type="doi", + identifier="10.0000/parent-obj-preprint-test", + preprint_version=self.preprint.current_version, + ) + deposit = self._make_deposit_with_identifier(identifier) + self.assertEqual(deposit.parent_object, self.repo) + + def test_parent_object_returns_none_with_no_linked_identifiers(self): + """parent_object returns None when the deposit has no CrossrefStatus linked.""" + deposit = identifier_models.CrossrefDeposit.objects.create( + document="", + file_name=uuid4(), + ) + self.assertIsNone(deposit.parent_object) + + +class TestPreprintSubjectFilter(TestCase): + """ + Tests for multi-subject filtering in PreprintViewSet (commit 59 conflict resolution). + + Iowa changed subject= from .get() (single value) to .getlist() (multiple values) + so that ?subject=A&subject=B returns preprints in either subject. + """ + + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.repo, cls.subject_a = helpers.create_repository( + cls.press, [], [], domain=PREPRINT_API_DOMAIN + ) + cls.subject_b = repository_models.Subject.objects.create( + repository=cls.repo, + name="Subject B", + slug="subject-b", + enabled=True, + ) + + cls.author = helpers.create_user("preprint.api.filter@test.com") + + now = timezone.now() + + cls.preprint_a = helpers.create_preprint( + cls.repo, cls.author, cls.subject_a, title="Preprint A" + ) + cls.preprint_a.stage = repository_models.STAGE_PREPRINT_PUBLISHED + cls.preprint_a.date_published = now + cls.preprint_a.save() + + cls.preprint_b = helpers.create_preprint( + cls.repo, cls.author, cls.subject_b, title="Preprint B" + ) + cls.preprint_b.stage = repository_models.STAGE_PREPRINT_PUBLISHED + cls.preprint_b.date_published = now + cls.preprint_b.save() + + cls.api_client = APIClient() + + def _get_titles(self, response): + results = response.data.get("results", response.data) + return {r["title"] for r in results} + + @override_settings(URL_CONFIG="domain") + def test_filter_by_single_subject_returns_matching_preprints(self): + """?subject=X returns only preprints in that subject.""" + url = reverse("repository_published_preprint-list") + response = self.api_client.get( + url, + {"subject": "Repo Subject"}, + SERVER_NAME=PREPRINT_API_DOMAIN, + ) + self.assertEqual(response.status_code, 200) + titles = self._get_titles(response) + self.assertIn("Preprint A", titles) + self.assertNotIn("Preprint B", titles) + + @override_settings(URL_CONFIG="domain") + def test_filter_by_multiple_subjects_returns_all_matching(self): + """?subject=A&subject=B returns preprints in either subject.""" + url = ( + reverse("repository_published_preprint-list") + + "?subject=Repo+Subject&subject=Subject+B" + ) + response = self.api_client.get(url, SERVER_NAME=PREPRINT_API_DOMAIN) + self.assertEqual(response.status_code, 200) + titles = self._get_titles(response) + self.assertIn("Preprint A", titles) + self.assertIn("Preprint B", titles) + + @override_settings(URL_CONFIG="domain") + def test_no_subject_filter_returns_all_preprints(self): + """Without a subject filter, all published preprints are returned.""" + url = reverse("repository_published_preprint-list") + response = self.api_client.get(url, SERVER_NAME=PREPRINT_API_DOMAIN) + self.assertEqual(response.status_code, 200) + titles = self._get_titles(response) + self.assertIn("Preprint A", titles) + self.assertIn("Preprint B", titles) diff --git a/src/events/registration.py b/src/events/registration.py index 4cee4c8b7b..b00c74a762 100755 --- a/src/events/registration.py +++ b/src/events/registration.py @@ -4,6 +4,7 @@ __maintainer__ = "Birkbeck Centre for Technology and Publishing" from core import models as core_models, workflow +from events import logic as event_logic from utils import transactional_emails, workflow_tasks from journal import logic as journal_logic from identifiers import logic as id_logic, reviews diff --git a/src/identifiers/forms.py b/src/identifiers/forms.py index bf3096d106..3238eddab6 100644 --- a/src/identifiers/forms.py +++ b/src/identifiers/forms.py @@ -83,10 +83,11 @@ def clean(self): def save(self, commit=True): identifier = super(IdentifierForm, self).save(commit=False) - if self.article: - identifier.article = self.article - elif self.preprint: - identifier.preprint_version = self.preprint.current_version + if not self.instance.pk: + if self.article: + identifier.article = self.article + elif self.preprint: + identifier.preprint_version = self.preprint.current_version if commit: identifier.save() diff --git a/src/identifiers/logic.py b/src/identifiers/logic.py index 6aeef4ab71..e47c0150b0 100755 --- a/src/identifiers/logic.py +++ b/src/identifiers/logic.py @@ -273,6 +273,16 @@ def register_crossref_component(article, xml, supp_file): ) +def create_crossref_preprint_doi_batch_context(repository, identifiers): + versions = [ident.preprint_version for ident in identifiers if ident.preprint_version] + return { + "batch_id": uuid4(), + "now": datetime.datetime.now(), + "repository": repository, + "versions": versions, + } + + def create_crossref_doi_batch_context(journal, identifiers): timestamp_suffix = journal.get_setting( "crossref", diff --git a/src/identifiers/tests/test_models.py b/src/identifiers/tests/test_models.py index 3f1b63b0c8..d218e71232 100644 --- a/src/identifiers/tests/test_models.py +++ b/src/identifiers/tests/test_models.py @@ -1,9 +1,9 @@ from django.test import TestCase +from uuid import uuid4 from identifiers import logic, models from utils.testing import helpers from utils.shared import clear_cache -from uuid import uuid4 class TestLogic(TestCase): diff --git a/src/identifiers/urls.py b/src/identifiers/urls.py index c3f45fe6fc..f4a07383d6 100755 --- a/src/identifiers/urls.py +++ b/src/identifiers/urls.py @@ -48,6 +48,17 @@ views.poll_doi_output, name="poll_doi_output", ), + # Legacy article-only URL aliases for backward compatibility with templates + re_path( + r"^(?P\d+)/$", + views.identifiers, + name="article_identifiers", + ), + re_path( + r"^(?P\d+)/$", + views.identifiers, + name="edit_identifiers", + ), # DOI Manager re_path( r"^doi_manager/$", diff --git a/src/identifiers/views.py b/src/identifiers/views.py index 763a7f33ae..245cc3bdd2 100755 --- a/src/identifiers/views.py +++ b/src/identifiers/views.py @@ -185,11 +185,18 @@ def show_doi( content_type="application/xml", ) except AttributeError: - template_context = logic.create_crossref_doi_batch_context( - request.journal, - {identifier}, - ) - template = "common/identifiers/crossref_doi_batch.xml" + if content_type == "preprint": + template_context = logic.create_crossref_preprint_doi_batch_context( + request.repository, + {identifier}, + ) + template = "common/identifiers/crossref_preprint_batch.xml" + else: + template_context = logic.create_crossref_doi_batch_context( + request.journal, + {identifier}, + ) + template = "common/identifiers/crossref_doi_batch.xml" return render( None, template, @@ -226,9 +233,25 @@ def poll_doi( # Scenario 1: The identifier has not been polled or deposited before. # It needs a CrossrefStatus object created. - if not identifier.crossrefstatus: - models.CrossrefStatus.objects.create( - identifier=identifier, + try: + has_status = identifier.crossrefstatus is not None + except models.CrossrefStatus.DoesNotExist: + has_status = False + + if not has_status: + messages.add_message( + request, + messages.WARNING, + "This identifier has not been deposited with Crossref yet.", + ) + return redirect( + reverse( + "identifiers", + kwargs={ + "content_type": content_type, + "object_id": obj.pk, + }, + ), ) # Scenario 2: The identifier has been deposited before. # It will have a CrossrefStatus and a CrossrefDeposit already. diff --git a/src/repository/migrations/0052_merge_20260226_1524.py b/src/repository/migrations/0052_merge_20260226_1524.py new file mode 100644 index 0000000000..d71dd25fb1 --- /dev/null +++ b/src/repository/migrations/0052_merge_20260226_1524.py @@ -0,0 +1,15 @@ +# Generated by Django 4.2.20 on 2026-02-26 15:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('repository', '0044_historicalrepository_new_version_submitted_and_more'), + ('repository', '0047_remove_preprintauthor_affiliation_and_more'), + ('repository', '0051_repositorysubmissiontype_preprint_submission_type'), + ] + + operations = [ + ] diff --git a/src/repository/migrations/0053_alter_historicalrepository_new_version_submitted_and_more.py b/src/repository/migrations/0053_alter_historicalrepository_new_version_submitted_and_more.py new file mode 100644 index 0000000000..6994e0c471 --- /dev/null +++ b/src/repository/migrations/0053_alter_historicalrepository_new_version_submitted_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.20 on 2026-02-26 15:28 + +import core.model_utils +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('repository', '0052_merge_20260226_1524'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalrepository', + name='new_version_submitted', + field=core.model_utils.JanewayBleachField(blank=True, help_text='Email sent when an author uploads a new version.'), + ), + migrations.AlterField( + model_name='preprint', + name='abstract', + field=core.model_utils.JanewayBleachField(blank=True, null=True), + ), + migrations.AlterField( + model_name='repository', + name='new_version_submitted', + field=core.model_utils.JanewayBleachField(blank=True, help_text='Email sent when an author uploads a new version.'), + ), + migrations.AlterField( + model_name='repositoryfield', + name='submission_type', + field=models.ForeignKey(blank=True, help_text='Optional, allows you to tie this field to a specific submission type. Leave blank to tie this to all submission types.', null=True, on_delete=django.db.models.deletion.CASCADE, to='repository.repositorysubmissiontype'), + ), + ] diff --git a/src/submission/migrations/0089_merge_20260226_1524.py b/src/submission/migrations/0089_merge_20260226_1524.py new file mode 100644 index 0000000000..1157f2a5d5 --- /dev/null +++ b/src/submission/migrations/0089_merge_20260226_1524.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.20 on 2026-02-26 15:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('submission', '0074_submissionconfiguration_open_peer_review_license'), + ('submission', '0088_auto_20250506_1214'), + ] + + operations = [ + ] From a5363cca3c24804e9c956bbf4d056db065a7c39e Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Fri, 27 Feb 2026 11:35:35 +0000 Subject: [PATCH 58/76] chore: add DELETE to permission list on API --- src/api/permissions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/permissions.py b/src/api/permissions.py index b637a2c000..695feaf8ab 100755 --- a/src/api/permissions.py +++ b/src/api/permissions.py @@ -56,7 +56,7 @@ class IsPreprintOwner(permissions.BasePermission): def has_permission(self, request, view): # grant access to non-create/update requests - if request.method not in ['PUT', 'PATCH']: + if request.method not in ['PUT', 'PATCH', 'DELETE']: return True # grant access if user is the preprint's owner From 853665b92bd6a270646d4e701104924b3b905b73 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 11 Mar 2026 15:19:08 +0000 Subject: [PATCH 59/76] chore: updates account endpoint wording. --- src/api/urls.py | 2 +- src/api/views.py | 2 +- src/core/janeway_global_settings.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/api/urls.py b/src/api/urls.py index 0fbf2912a2..ff6a7e9394 100755 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -30,7 +30,7 @@ router.register(r'user_info', views.UserInfo, 'api_user_info') router.register(r'logout', views.Logout, basename='logout') -if settings.API_ENABLE_SUBMISSION_ACCOUNT_SEARCH: +if settings.API_ENABLE_ACCOUNT_ENDPOINTS: router.register( r'submission_account_search', views.SubmissionAccountSearch, diff --git a/src/api/views.py b/src/api/views.py index 3e967e7ebf..db32e2271e 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -402,7 +402,7 @@ class SubmissionAccountSearch(viewsets.ModelViewSet): exact email or exact ORCID. Returns 0 results if no exact match. The availability of this view is controlled by the Django setting: - API_ENABLE_SUBMISSION_ACCOUNT_SEARCH which is False by default. + API_ENABLE_ACCOUNT_ENDPOINTS which is False by default. """ serializer_class = serializers.SubmissionAccountSearch http_method_names = ['get'] diff --git a/src/core/janeway_global_settings.py b/src/core/janeway_global_settings.py index 337166c904..b8e40bb62d 100755 --- a/src/core/janeway_global_settings.py +++ b/src/core/janeway_global_settings.py @@ -722,6 +722,6 @@ def __len__(self): # and may not work properly with a different size. DEFAULT_CROP_SIZE = (1500, 648) -# This setting should only be enabled on Dev or where CORS is properly +# This setting should only be enabled where CORS is properly # configured to stop misuse of this endpoint. -API_ENABLE_SUBMISSION_ACCOUNT_SEARCH = False +API_ENABLE_ACCOUNT_ENDPOINTS = False From a059fe1659d02ef351b4ba4feb0f6cfc60a7d28f Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 11 Mar 2026 15:26:18 +0000 Subject: [PATCH 60/76] fix: warn on incompatible plugin version in debug mode, raise otherwise --- src/core/plugin_loader.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/core/plugin_loader.py b/src/core/plugin_loader.py index 19f81d23f3..fea5563bb2 100755 --- a/src/core/plugin_loader.py +++ b/src/core/plugin_loader.py @@ -11,12 +11,15 @@ from django.db.utils import OperationalError, ProgrammingError from packaging import version +from utils.logger import get_logger from core.workflow import ELEMENT_STAGES, STAGES_ELEMENTS from core.plugin_installed_apps import EXCLUDED_PLUGIN_DIRS from janeway import __version__ as janeway_version from submission.models import PLUGIN_WORKFLOW_STAGES from utils import models +logger = get_logger(__name__) + def get_dirs(directory): path = os.path.join(settings.BASE_DIR, directory) @@ -95,7 +98,14 @@ def validate_plugin_version(plugin_settings): current_version = version.parse(janeway_version.base_version) valid = current_version >= wants_version - + if not valid: + msg = "Plugin {} not compatibile with current install: {} < {}".format( + plugin_settings.PLUGIN_NAME, current_version, wants_version + ) + if settings.DEBUG: + logger.warning(msg) + else: + raise ImproperlyConfigured(msg) def get_plugin(module_name, permissive): From 4147d2d05b19912507abccdcc981b83b00478afd Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 11 Mar 2026 15:32:20 +0000 Subject: [PATCH 61/76] chore: rename permission function. --- src/api/permissions.py | 2 +- src/api/views.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/permissions.py b/src/api/permissions.py index 695feaf8ab..de67b9ecad 100755 --- a/src/api/permissions.py +++ b/src/api/permissions.py @@ -51,7 +51,7 @@ def has_permission(self, request, view): return True -class IsPreprintOwner(permissions.BasePermission): +class CanEditPreprint(permissions.BasePermission): message = 'You must be the owner of this preprint to edit it.' def has_permission(self, request, view): diff --git a/src/api/views.py b/src/api/views.py index db32e2271e..f97905deaa 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -278,7 +278,7 @@ class UserPreprintsViewSet(PreprintViewSet): http_method_names = ['get', 'post', 'put'] permission_classes = [ permissions.IsAuthenticated, - api_permissions.IsPreprintOwner + api_permissions.CanEditPreprint ] def get_serializer_class(self): @@ -339,7 +339,7 @@ class PreprintFiles(viewsets.ModelViewSet): http_method_names = ['get', 'post', 'delete'] permission_classes = [ permissions.IsAuthenticated, - api_permissions.IsPreprintOwner + api_permissions.CanEditPreprint ] def get_serializer_class(self): From bb8b2cebfb1761f119cab3d4943c6251dbeae70a Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 11 Mar 2026 15:38:12 +0000 Subject: [PATCH 62/76] refactor: simplify return logic in hooks --- src/core/templatetags/hooks.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/core/templatetags/hooks.py b/src/core/templatetags/hooks.py index 010f4e7036..7373925b10 100755 --- a/src/core/templatetags/hooks.py +++ b/src/core/templatetags/hooks.py @@ -12,19 +12,16 @@ @register.simple_tag(takes_context=True) def hook(context, hook_name, default_value='', *args, **kwargs): - try: - html = "" - for hook in settings.PLUGIN_HOOKS.get(hook_name, []): + html = "" + for hook in settings.PLUGIN_HOOKS.get(hook_name, []): + try: hook_module = import_module(hook.get("module")) function = getattr(hook_module, hook.get("function")) hook_output = function(context, *args, **kwargs) - if hook_output: - html = html + hook_output - if html: - return mark_safe(html) - except Exception as e: - logger.error("Error rendering hook {0}: {1}".format(hook_name, e)) - if settings.DEBUG: - return f"[DEBUG] Error rendering hook output: {e}" - return mark_safe(default_value) + html += hook_output + except Exception as e: + logger.error("Error rendering hook {0}: {1}".format(hook_name, e)) + if settings.DEBUG: + return f"[DEBUG] Error rendering hook output: {e}" + return mark_safe(html or default_value) From 84bc975dffc3e14dcb546ff668b70ccd6569fd00 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 11 Mar 2026 15:43:08 +0000 Subject: [PATCH 63/76] fix: guard against None obj before accessing crossrefstatus_set in admin --- src/identifiers/admin.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/identifiers/admin.py b/src/identifiers/admin.py index d904c27340..47ee8c12f8 100755 --- a/src/identifiers/admin.py +++ b/src/identifiers/admin.py @@ -108,14 +108,14 @@ class CrossrefDepositAdmin(admin.ModelAdmin): list_select_related = True def _journal(self, obj): - fist_status = obj.crossrefstatus_set.first() - - if obj and fist_status and fist_status.identifier.article: - return fist_status.identifier.article.journal - elif obj and fist_status and fist_status.identifier.review: - return fist_status.identifier.review.article.journal - else: + if not obj: return "" + first_status = obj.crossrefstatus_set.first() + if first_status and first_status.identifier.article: + return first_status.identifier.article.journal + elif first_status and first_status.identifier.review: + return first_status.identifier.review.article.journal + return "" inlines = [ admin_utils.DepositCrossrefStatusInline, From d2e577f43cae34de98c3ea2038f35768245ccada Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 11 Mar 2026 15:43:48 +0000 Subject: [PATCH 64/76] fix: use response.ok instead of checking for status code 200 --- src/identifiers/preprints.py | 2 +- src/identifiers/reviews.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/identifiers/preprints.py b/src/identifiers/preprints.py index eeafd29de6..7babc565ec 100644 --- a/src/identifiers/preprints.py +++ b/src/identifiers/preprints.py @@ -84,7 +84,7 @@ def send_preprint_version_crossref_deposit(repository, versions, identifiers): crossref_deposit.save() logger.error(status) return status, e - if response.status_code == 200: + if response.ok: status = f"Deposit sent ({repository.short_name})" util_models.LogEntry.bulk_add_simple_entry( 'Submission', diff --git a/src/identifiers/reviews.py b/src/identifiers/reviews.py index 0fda2f1a49..ee2a4c9ec5 100644 --- a/src/identifiers/reviews.py +++ b/src/identifiers/reviews.py @@ -133,7 +133,7 @@ def send_review_crossref_deposit(mode, reviews, identifiers, journal): logger.error(status) return status, e - if response.status_code == 200: + if response.ok: status = f"Deposit sent ({journal.code})" util_models.LogEntry.bulk_add_simple_entry( 'Submission', From 1a84f083372f6bff79d953900470a6ad995f05bc Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Wed, 11 Mar 2026 16:17:11 +0000 Subject: [PATCH 65/76] chore: move submission start styles to admin.css and use CSS custom property for depth --- src/static/admin/css/admin.css | 32 ++++++++++++ .../admin/repository/submit/start.html | 49 +------------------ 2 files changed, 33 insertions(+), 48 deletions(-) diff --git a/src/static/admin/css/admin.css b/src/static/admin/css/admin.css index cc49bab8a5..f0a0f04e8c 100644 --- a/src/static/admin/css/admin.css +++ b/src/static/admin/css/admin.css @@ -1089,3 +1089,35 @@ ul.menu { grid-template-columns: 20rem minmax(auto, 60rem); } } + +.custom-radio-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 1rem; + margin-bottom: 1rem; +} + +.custom-radio { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border: 2px solid #ddd; + border-radius: 6px; + cursor: pointer; + transition: border-color 0.3s ease; + background: #fdfdfd; +} + +.custom-radio:hover { + border-color: #2684ff; +} + +.custom-radio input[type="radio"] { + margin: 0; +} + +.ou-depth { + margin-left: calc(var(--ou-depth, 0) * 1rem); +} diff --git a/src/templates/admin/repository/submit/start.html b/src/templates/admin/repository/submit/start.html index 75d09af936..d21a424278 100644 --- a/src/templates/admin/repository/submit/start.html +++ b/src/templates/admin/repository/submit/start.html @@ -12,53 +12,6 @@ {% include "admin/elements/breadcrumbs/repository_submission.html" with start=True %} {% endblock %} -{% block css %} - -{% endblock %} {% block body %}
    @@ -103,7 +56,7 @@

    {{ request.repository.rou_default_name }}

    {% for radio in form.organisation_unit %} {% with radio.data.value|stringformat:"s" as val %} {% with form.ou_depth_map|get:val as depth %} -
    Type
    '] - table.append("{}".format( - "".join(f"" for col in display_cols) - )) + table.append( + "{}".format( + "".join(f"" for col in display_cols) + ) + ) table.append("") for row in data_rows: - table.append("{}".format( - "".join(f"" for cell in row) - )) + table.append( + "{}".format("".join(f"" for cell in row)) + ) table.append("
    {col}
    {col}
    {cell}
    {cell}
    ") messages = [] @@ -1397,18 +1400,24 @@ def render_excel(self, max_rows=10, max_cols=5): total_rows = len(rows) - 1 display_cols = header[:max_cols] - data_rows = [row[:max_cols] for row in rows[1:max_rows + 1]] + data_rows = [row[:max_cols] for row in rows[1 : max_rows + 1]] table = [''] - table.append("{}".format( - "".join(f"" for col in display_cols) - )) + table.append( + "{}".format( + "".join(f"" for col in display_cols) + ) + ) table.append("") for row in data_rows: - table.append("{}".format( - "".join( - f"" for cell in row) - )) + table.append( + "{}".format( + "".join( + f"" + for cell in row + ) + ) + ) table.append("
    {col}
    {col}
    {cell if cell is not None else ''}
    {cell if cell is not None else ''}
    ") messages = [] @@ -1425,8 +1434,6 @@ def render_excel(self, max_rows=10, max_cols=5): except Exception as e: return format_html("

    Error rendering Excel file: {}

    ", str(e)) - - def html(self): if self.file.mime_type in files.HTML_MIMETYPES: return self.file.contents() @@ -1450,12 +1457,11 @@ def get_doi(self, _object=False): try: try: doi = identifier_models.Identifier.objects.get( - id_type='doi', - preprint_version=self + id_type="doi", preprint_version=self ) except identifier_models.Identifier.MultipleObjectsReturned: doi = identifier_models.Identifier.objects.filter( - id_type='doi', + id_type="doi", preprint_version=self, ).first() if not _object: @@ -1468,17 +1474,17 @@ def get_doi(self, _object=False): def public_download_url(self): if self.preprint and self.file: path = reverse( - 'repository_file_download', + "repository_file_download", kwargs={ - 'preprint_id': self.preprint.pk, - 'file_id': self.file.pk, + "preprint_id": self.preprint.pk, + "file_id": self.file.pk, }, ) return self.preprint.repository.site_url( path=path, ) else: - return '' + return "" class Comment(models.Model): diff --git a/src/repository/views.py b/src/repository/views.py index e0c9221c6e..6b1cee79b2 100644 --- a/src/repository/views.py +++ b/src/repository/views.py @@ -261,9 +261,9 @@ def repository_submit_update(request, preprint_id, action): event_logic.Events.raise_event( event_logic.Events.ON_PREPRINT_NEW_VERSION, **{ - 'request': request, - 'new_version': new_version, - 'preprint': preprint, + "request": request, + "new_version": new_version, + "preprint": preprint, }, ) @@ -364,16 +364,19 @@ def redirect_old_subject(request, subject_id): def repository_list(request): form = forms.PreprintFilterForm(request.GET, repository=request.repository) - preprints = models.Preprint.objects.filter( - date_published__lte=timezone.now(), - repository=request.repository, - ).select_related('submission_type') \ - .prefetch_related('organisation_units') + preprints = ( + models.Preprint.objects.filter( + date_published__lte=timezone.now(), + repository=request.repository, + ) + .select_related("submission_type") + .prefetch_related("organisation_units") + ) if form.is_valid(): - subject = form.cleaned_data.get('subject') - submission_type = form.cleaned_data.get('submission_type') - search_term = form.cleaned_data.get('search_term', '').strip() + subject = form.cleaned_data.get("subject") + submission_type = form.cleaned_data.get("submission_type") + search_term = form.cleaned_data.get("search_term", "").strip() if subject: preprints = preprints.filter(subject=subject) @@ -386,19 +389,22 @@ def repository_list(request): keyword_filter = Q(keywords__word__in=split_terms) title_abstract_filter = Q(title__icontains=search_term) | Q( - abstract__icontains=search_term) + abstract__icontains=search_term + ) - author_filter = Q(preprintauthor__account__first_name__in=split_terms) | \ - Q(preprintauthor__account__middle_name__in=split_terms) | \ - Q(preprintauthor__account__last_name__in=split_terms) | \ - Q(preprintauthor__account__institution__icontains=search_term) + author_filter = ( + Q(preprintauthor__account__first_name__in=split_terms) + | Q(preprintauthor__account__middle_name__in=split_terms) + | Q(preprintauthor__account__last_name__in=split_terms) + | Q(preprintauthor__account__institution__icontains=search_term) + ) preprints = preprints.filter( title_abstract_filter | keyword_filter | author_filter ).distinct() - paginator = Paginator(preprints.order_by('-date_published'), 15) - page = request.GET.get('page', 1) + paginator = Paginator(preprints.order_by("-date_published"), 15) + page = request.GET.get("page", 1) try: preprints = paginator.page(page) @@ -407,12 +413,18 @@ def repository_list(request): except EmptyPage: preprints = paginator.page(paginator.num_pages) - return render(request, 'repository/list.html', { - 'preprints': preprints, - 'form': form, - 'subject': form.cleaned_data.get('subject') if form.is_valid() else None, - 'submission_type': form.cleaned_data.get('submission_type') if form.is_valid() else None, - }) + return render( + request, + "repository/list.html", + { + "preprints": preprints, + "form": form, + "subject": form.cleaned_data.get("subject") if form.is_valid() else None, + "submission_type": form.cleaned_data.get("submission_type") + if form.is_valid() + else None, + }, + ) @decorators.headless_mode_check @@ -422,16 +434,15 @@ def repository_search(request, search_term=None): main repository list view. """ if request.method == "POST": - search_term = request.POST.get('search_term', '').strip() + search_term = request.POST.get("search_term", "").strip() query = {} if search_term: - query['search_term'] = search_term + query["search_term"] = search_term return redirect(f"{reverse('repository_list')}?{urlencode(query)}") - @decorators.headless_mode_check def repository_preprint(request, preprint_id): """ @@ -588,7 +599,7 @@ def repository_submit(request): submission_type = form.cleaned_data["submission_type"] organisation_unit = form.cleaned_data.get("organisation_unit") - url = reverse('repository_info') + url = reverse("repository_info") params = f"?submission_type={submission_type.slug}" if organisation_unit: @@ -596,9 +607,9 @@ def repository_submit(request): return redirect(f"{url}{params}") - template = 'admin/repository/submit/start.html' + template = "admin/repository/submit/start.html" context = { - 'form': form, + "form": form, } return render(request, template, context) @@ -616,7 +627,7 @@ def repository_info(request, preprint_id=None): return result submission_type = result - organisation_unit = getattr(request, 'organisation_unit', None) + organisation_unit = getattr(request, "organisation_unit", None) if preprint_id: preprint = get_object_or_404( @@ -1142,7 +1153,9 @@ def repository_edit_metadata(request, preprint_id): "preprint": preprint, "metadata_form": metadata_form, "additional_fields": request.repository.type_additional_submission_fields( - submission_type_slug=preprint.submission_type.slug if preprint.submission_type else None, + submission_type_slug=preprint.submission_type.slug + if preprint.submission_type + else None, ), } @@ -2621,12 +2634,16 @@ def preprints_by_rou(request, rou_code): relevant_rous = [rou] + descendant_rous # Fetch preprints associated with the selected ROU and its sub-units - preprints = models.Preprint.objects.filter( - organisation_units__in=relevant_rous, - date_published__lte=timezone.now(), - stage=models.STAGE_PREPRINT_PUBLISHED, - ).distinct().order_by( - "-date_published", + preprints = ( + models.Preprint.objects.filter( + organisation_units__in=relevant_rous, + date_published__lte=timezone.now(), + stage=models.STAGE_PREPRINT_PUBLISHED, + ) + .distinct() + .order_by( + "-date_published", + ) ) # Pagination setup @@ -2652,11 +2669,13 @@ def preprints_by_rou(request, rou_code): def build_hierarchy(units): - """ Recursively builds a nested dictionary structure for hierarchy """ + """Recursively builds a nested dictionary structure for hierarchy""" hierarchy = [] for unit in units: children = unit.children.annotate(preprint_count=Count("preprints")) - latest_preprints = unit.preprints.order_by("-date_published")[:10] # Get latest 10 preprints + latest_preprints = unit.preprints.order_by("-date_published")[ + :10 + ] # Get latest 10 preprints hierarchy.append( { "unit": unit, @@ -2667,6 +2686,7 @@ def build_hierarchy(units): ) return hierarchy + def rou_hierarchy_view(request, rou_code=None): repository = request.repository selected_rou = None @@ -2713,7 +2733,7 @@ def rou_hierarchy_view(request, rou_code=None): "recent_preprints": models.Preprint.objects.filter( repository=request.repository, date_published__lte=timezone.now(), - ).order_by('-date_published')[:10], + ).order_by("-date_published")[:10], "page_text": repository.render_setting(repository.rou_struct_page_text), }, ) @@ -2721,16 +2741,14 @@ def rou_hierarchy_view(request, rou_code=None): @is_repository_manager def submission_type_list(request): - types = (models.RepositorySubmissionType.objects.filter( + types = models.RepositorySubmissionType.objects.filter( repository=request.repository, - ).annotate( - preprint_count=Count('preprint') - )) + ).annotate(preprint_count=Count("preprint")) return render( request, - 'repository/submission_type_list.html', + "repository/submission_type_list.html", { - 'submission_types': types, + "submission_types": types, }, ) @@ -2744,7 +2762,7 @@ def delete_submission_type(request, pk): repository=request.repository, ) obj.delete() - return redirect('submission_type_list') + return redirect("submission_type_list") @is_repository_manager @@ -2776,4 +2794,3 @@ def edit_submission_type(request, pk=None): "submission_type": submission_type, }, ) - diff --git a/src/review/forms.py b/src/review/forms.py index 7e3f1c5a5e..4dac3d4600 100755 --- a/src/review/forms.py +++ b/src/review/forms.py @@ -39,8 +39,8 @@ def __init__(self, *args, **kwargs): self.fields["decision"].widget.attrs["onchange"] = "decision_change()" self.fields["decision"].widget.attrs["onfocus"] = "store_previous_decision()" self.fields["editor"].queryset = editors - self.fields["editor"].label_from_instance = ( - lambda obj: f"{obj.full_name()} ({obj.email})" + self.fields["editor"].label_from_instance = lambda obj: ( + f"{obj.full_name()} ({obj.email})" ) if not newly_created: self.fields["message_to_editor"].widget = forms.HiddenInput() @@ -438,7 +438,7 @@ class Meta: labels = { "for_author_consumption": _("Author can access this review"), "display_review_file": _("Author can access review file"), - "display_public": _("Display Review Publicly") + "display_public": _("Display Review Publicly"), } widgets = { "for_author_consumption": HTMLSwitchInput(), @@ -452,8 +452,7 @@ def __init__(self, *args, **kwargs): self.fields.pop("display_review_file") if self.instance: open_review_enabled = self.instance.article.journal.get_setting( - "general", - "open_peer_review" + "general", "open_peer_review" ) if not open_review_enabled or not self.instance.permission_to_make_public: self.fields.pop("display_public") diff --git a/src/review/models.py b/src/review/models.py index 729dd20d82..61f36d1419 100755 --- a/src/review/models.py +++ b/src/review/models.py @@ -405,13 +405,13 @@ def decision_to_crossref(self): Maps a decision to Crossref deposit recommendations. """ if self.decision == RD.DECISION_ACCEPT.value: - return 'accept' + return "accept" elif self.decision == RD.DECISION_MINOR.value: - return 'minor-revision' + return "minor-revision" elif self.decision == RD.DECISION_MAJOR.value: - return 'major-revision' + return "major-revision" elif self.decision == RD.DECISION_REJECT.value: - return 'reject' + return "reject" def get_doi_pattern(self): article_pattern = self.article.doi_pattern_preview @@ -421,12 +421,11 @@ def get_doi(self, _object=False): try: try: doi = identifier_models.Identifier.objects.get( - id_type='doi', - review=self + id_type="doi", review=self ) except identifier_models.Identifier.MultipleObjectsReturned: doi = identifier_models.Identifier.objects.filter( - id_type='doi', + id_type="doi", review=self, ).first() if not _object: diff --git a/src/security/decorators.py b/src/security/decorators.py index cf2adde817..7783471d09 100755 --- a/src/security/decorators.py +++ b/src/security/decorators.py @@ -747,7 +747,8 @@ def wrapper(request, *args, **kwargs): request.journal, identifier_type, identifier ) if article_object and article_object.journal.get_setting( - "general", "uses_isolinear_plugin", + "general", + "uses_isolinear_plugin", ): return func(request, *args, **kwargs) diff --git a/src/submission/forms.py b/src/submission/forms.py index 60eb71b6a9..ab92f53b5b 100755 --- a/src/submission/forms.py +++ b/src/submission/forms.py @@ -286,8 +286,8 @@ class EditorArticleInfoSubmit(ArticleInfo): def __init__(self, *args, **kwargs): super(EditorArticleInfoSubmit, self).__init__(*args, **kwargs) if self.fields.get("section"): - self.fields["section"].label_from_instance = ( - lambda obj: obj.display_name_public_submission + self.fields["section"].label_from_instance = lambda obj: ( + obj.display_name_public_submission ) self.fields["section"].help_text = ( "As an editor you will see all " @@ -414,7 +414,7 @@ def __init__(self, *args, **kwargs): journal=self.instance.journal, ) self.fields[ - 'open_peer_review_license' + "open_peer_review_license" ].queryset = models.Licence.objects.filter( journal=self.instance.journal, ) diff --git a/src/submission/migrations/0074_submissionconfiguration_open_peer_review_license.py b/src/submission/migrations/0074_submissionconfiguration_open_peer_review_license.py index 0423e5a5ae..28fe9a44d4 100644 --- a/src/submission/migrations/0074_submissionconfiguration_open_peer_review_license.py +++ b/src/submission/migrations/0074_submissionconfiguration_open_peer_review_license.py @@ -5,15 +5,21 @@ class Migration(migrations.Migration): - dependencies = [ - ('submission', '0073_bleach_title_20230523_1804'), + ("submission", "0073_bleach_title_20230523_1804"), ] operations = [ migrations.AddField( - model_name='submissionconfiguration', - name='open_peer_review_license', - field=models.ForeignKey(blank=True, help_text='The license that is applied to open peer reviews.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='open_peer_review_license', to='submission.licence'), + model_name="submissionconfiguration", + name="open_peer_review_license", + field=models.ForeignKey( + blank=True, + help_text="The license that is applied to open peer reviews.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="open_peer_review_license", + to="submission.licence", + ), ), ] diff --git a/src/submission/migrations/0089_merge_20260226_1524.py b/src/submission/migrations/0089_merge_20260226_1524.py index 1157f2a5d5..977d6712c4 100644 --- a/src/submission/migrations/0089_merge_20260226_1524.py +++ b/src/submission/migrations/0089_merge_20260226_1524.py @@ -4,11 +4,9 @@ class Migration(migrations.Migration): - dependencies = [ - ('submission', '0074_submissionconfiguration_open_peer_review_license'), - ('submission', '0088_auto_20250506_1214'), + ("submission", "0074_submissionconfiguration_open_peer_review_license"), + ("submission", "0088_auto_20250506_1214"), ] - operations = [ - ] + operations = [] diff --git a/src/submission/models.py b/src/submission/models.py index 7598331d76..80392bfa59 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -1679,9 +1679,7 @@ def in_review_stages(self): @property def stage_log_list(self): - return [ - stage.stage_to for stage in self.articlestagelog_set.all() - ] + return [stage.stage_to for stage in self.articlestagelog_set.all()] def peer_reviews_for_author_consumption(self): return self.reviewassignment_set.filter( @@ -3322,9 +3320,9 @@ class SubmissionConfiguration(models.Model): Licence, null=True, blank=True, - help_text=_('The license that is applied to open peer reviews.'), + help_text=_("The license that is applied to open peer reviews."), on_delete=models.SET_NULL, - related_name='open_peer_review_license', + related_name="open_peer_review_license", ) default_language = models.CharField( max_length=200, diff --git a/src/utils/management/commands/register_crossref_preprint_dois.py b/src/utils/management/commands/register_crossref_preprint_dois.py index 129d09011c..bae3494a8e 100644 --- a/src/utils/management/commands/register_crossref_preprint_dois.py +++ b/src/utils/management/commands/register_crossref_preprint_dois.py @@ -15,7 +15,7 @@ def add_arguments(self, parser): :param parser: the parser to which the required arguments will be added :return: None """ - parser.add_argument('version_id') + parser.add_argument("version_id") def handle(self, *args, **options): """Calls the Crossref registration options @@ -24,9 +24,7 @@ def handle(self, *args, **options): :param options: Dictionary containing 'article_id' :return: None """ - version = models.PreprintVersion.objects.get( - pk=options.get('version_id') - ) + version = models.PreprintVersion.objects.get(pk=options.get("version_id")) if version: preprints.deposit_doi_for_preprint_version( version.preprint.repository, diff --git a/src/utils/management/commands/register_crossref_review_dois.py b/src/utils/management/commands/register_crossref_review_dois.py index 7487c9d15b..a169b1f137 100644 --- a/src/utils/management/commands/register_crossref_review_dois.py +++ b/src/utils/management/commands/register_crossref_review_dois.py @@ -15,7 +15,7 @@ def add_arguments(self, parser): :param parser: the parser to which the required arguments will be added :return: None """ - parser.add_argument('article_id') + parser.add_argument("article_id") def handle(self, *args, **options): """Calls the Crossref registration options @@ -25,7 +25,7 @@ def handle(self, *args, **options): :return: None """ article = submission_models.Article.objects.get( - pk=options['article_id'], + pk=options["article_id"], ) identifier_logic.deposit_doi_for_reviews( diff --git a/src/utils/management/commands/update_journal_workflows.py b/src/utils/management/commands/update_journal_workflows.py index 463f2c7a59..1dfd75d6e7 100644 --- a/src/utils/management/commands/update_journal_workflows.py +++ b/src/utils/management/commands/update_journal_workflows.py @@ -5,8 +5,7 @@ class Command(BaseCommand): - help = 'Loops through all journals adding the Typesetting plugin' \ - ' to workflows' + help = "Loops through all journals adding the Typesetting plugin to workflows" def handle(self, *args, **options): try: @@ -17,10 +16,8 @@ def handle(self, *args, **options): journals = jm.Journal.objects.all() for journal in journals: - workflow = cm.Workflow.objects.get( - journal=journal - ) - elements_to_remove = ['production', 'proofing'] + workflow = cm.Workflow.objects.get(journal=journal) + elements_to_remove = ["production", "proofing"] cm.WorkflowElement.objects.filter( journal=journal, @@ -31,18 +28,18 @@ def handle(self, *args, **options): journal=journal, element_name=plugin_settings.PLUGIN_NAME, defaults={ - 'handshake_url': plugin_settings.HANDSHAKE_URL, - 'jump_url': plugin_settings.JUMP_URL, - 'stage': plugin_settings.STAGE, - } + "handshake_url": plugin_settings.HANDSHAKE_URL, + "jump_url": plugin_settings.JUMP_URL, + "stage": plugin_settings.STAGE, + }, ) workflow.elements.add(ts_element) stage_order = [ - 'review', - 'copyediting', - 'Typesetting Plugin', - 'prepublication', + "review", + "copyediting", + "Typesetting Plugin", + "prepublication", ] journal_elements = cm.WorkflowElement.objects.filter( diff --git a/src/utils/transactional_emails.py b/src/utils/transactional_emails.py index 39a34e930c..a78d5abef2 100644 --- a/src/utils/transactional_emails.py +++ b/src/utils/transactional_emails.py @@ -1858,44 +1858,40 @@ def preprint_new_version(**kwargs): objects :return: None """ - request = kwargs.get('request') - preprint = kwargs.get('preprint') - new_version = kwargs.get('new_version') + request = kwargs.get("request") + preprint = kwargs.get("preprint") + new_version = kwargs.get("new_version") - description = '{author} has submitted a new {obj} version.'.format( + description = "{author} has submitted a new {obj} version.".format( author=request.user.full_name(), obj=request.repository.object_name, title=preprint.title, ) log_dict = { - 'level': 'Info', - 'action_text': description, - 'types': 'Submission', - 'target': preprint, + "level": "Info", + "action_text": description, + "types": "Submission", + "target": preprint, } - url = request.repository.site_url( - path=reverse( - 'version_queue' - ) - ) + url = request.repository.site_url(path=reverse("version_queue")) # Send an email to the preprint editors template = request.repository.new_version_submitted email_text = render_template.get_message_content( request, - { - 'preprint': preprint, - 'new_version': new_version, - 'url': url - }, + {"preprint": preprint, "new_version": new_version, "url": url}, template, template_is_setting=True, ) repo = request.repository - recipients = repo.submission_notification_recipients if repo.submission_notification_recipients.count() > 0 else repo.managers + recipients = ( + repo.submission_notification_recipients + if repo.submission_notification_recipients.count() > 0 + else repo.managers + ) for r in recipients.all(): notify_helpers.send_email_with_body_from_user( request, - '{} New Version'.format(request.repository.object_name), + "{} New Version".format(request.repository.object_name), r.email, email_text, log_dict=log_dict, From 9b8eae034e0db2c393b69f6a7eb0359c5a8e5b4c Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 12 Mar 2026 11:14:19 +0000 Subject: [PATCH 68/76] fix: adds missing max_lengths --- .../migrations/0046_repositoryorganisationunit.py | 2 +- ...050_historicalrepository_rou_default_name_and_more.py | 2 ++ src/repository/models.py | 9 +++++---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/repository/migrations/0046_repositoryorganisationunit.py b/src/repository/migrations/0046_repositoryorganisationunit.py index 88aa871ae5..50818ff7e3 100644 --- a/src/repository/migrations/0046_repositoryorganisationunit.py +++ b/src/repository/migrations/0046_repositoryorganisationunit.py @@ -25,7 +25,7 @@ class Migration(migrations.Migration): ), ( "name", - models.CharField(verbose_name=django.db.models.fields.CharField), + models.CharField(max_length=255, verbose_name=django.db.models.fields.CharField), ), ( "code", diff --git a/src/repository/migrations/0050_historicalrepository_rou_default_name_and_more.py b/src/repository/migrations/0050_historicalrepository_rou_default_name_and_more.py index 4100ef69cd..95361eaf09 100644 --- a/src/repository/migrations/0050_historicalrepository_rou_default_name_and_more.py +++ b/src/repository/migrations/0050_historicalrepository_rou_default_name_and_more.py @@ -16,6 +16,7 @@ class Migration(migrations.Migration): field=models.CharField( default="Organisational Units", help_text="Default name for the organisation structure within this repository.", + max_length=255, ), ), migrations.AddField( @@ -33,6 +34,7 @@ class Migration(migrations.Migration): field=models.CharField( default="Organisational Units", help_text="Default name for the organisation structure within this repository.", + max_length=255, ), ), migrations.AddField( diff --git a/src/repository/models.py b/src/repository/models.py index f886d6d0e2..19cab7a925 100755 --- a/src/repository/models.py +++ b/src/repository/models.py @@ -8,7 +8,6 @@ import json import csv -from django.apps import apps from django.db import models from django.db.models import Q from django.utils import timezone @@ -17,12 +16,10 @@ from django.utils.translation import gettext_lazy as _ from django.dispatch import receiver from django.shortcuts import reverse -from django.http.request import split_domain_port from django.templatetags.static import static from django.template import Template, Context from django.utils.html import format_html from django.core.validators import RegexValidator -from django.core.exceptions import ValidationError from simple_history.models import HistoricalRecords from openpyxl import load_workbook @@ -281,6 +278,7 @@ class Repository(model_utils.AbstractSiteModel): default=False, help_text="Enable this setting to display metrics publicly." ) rou_default_name = models.CharField( + max_length=255, default="Organisational Units", help_text="Default name for the organisation structure within this repository.", ) @@ -429,7 +427,10 @@ class RepositoryOrganisationUnit(models.Model): Repository, on_delete=models.CASCADE, ) - name = models.CharField() + name = models.CharField( + max_length=255, + help_text="The name of the unit, eg. 'Research' or 'Publications'.", + ) code = models.SlugField( max_length=50, help_text="A unique code within the repository for URL generation.", From 863b4afc9363c4edf1ba6762a843a7082d39d2f9 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 12 Mar 2026 11:17:40 +0000 Subject: [PATCH 69/76] chore: more ruff formatting --- src/repository/migrations/0046_repositoryorganisationunit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/repository/migrations/0046_repositoryorganisationunit.py b/src/repository/migrations/0046_repositoryorganisationunit.py index 50818ff7e3..84d558912f 100644 --- a/src/repository/migrations/0046_repositoryorganisationunit.py +++ b/src/repository/migrations/0046_repositoryorganisationunit.py @@ -25,7 +25,9 @@ class Migration(migrations.Migration): ), ( "name", - models.CharField(max_length=255, verbose_name=django.db.models.fields.CharField), + models.CharField( + max_length=255, verbose_name=django.db.models.fields.CharField + ), ), ( "code", From 2180adb2f185843d3991d82160dd234af5db3628 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 12 Mar 2026 11:22:53 +0000 Subject: [PATCH 70/76] chore: hopefully the last fix here. --- src/repository/migrations/0046_repositoryorganisationunit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/repository/migrations/0046_repositoryorganisationunit.py b/src/repository/migrations/0046_repositoryorganisationunit.py index 84d558912f..69951f5413 100644 --- a/src/repository/migrations/0046_repositoryorganisationunit.py +++ b/src/repository/migrations/0046_repositoryorganisationunit.py @@ -26,7 +26,9 @@ class Migration(migrations.Migration): ( "name", models.CharField( - max_length=255, verbose_name=django.db.models.fields.CharField + help_text="The name of the unit, eg. 'Research' or 'Publications'.", + max_length=255, + verbose_name=django.db.models.fields.CharField, ), ), ( From 8dde4313ec813c134d4006bc5de4b9ac3d156e4c Mon Sep 17 00:00:00 2001 From: Mauro MSL Date: Thu, 12 Mar 2026 11:30:57 +0000 Subject: [PATCH 71/76] ci: Use in-memory sqlite for tests --- .github/workflows/ci.yml | 1 + src/core/janeway_global_settings.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26e03d7a40..7f1364b597 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,7 @@ jobs: - name: Set up environment variables run: | echo "DB_VENDOR=sqlite" >> "$GITHUB_ENV" + echo "DB_NAME=:memory:" >> "$GITHUB_ENV" echo "JANEWAY_SETTINGS_MODULE=core.janeway_global_settings" >> "$GITHUB_ENV" diff --git a/src/core/janeway_global_settings.py b/src/core/janeway_global_settings.py index b8e40bb62d..7c10139e9a 100755 --- a/src/core/janeway_global_settings.py +++ b/src/core/janeway_global_settings.py @@ -229,7 +229,7 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": "/db/janeway.sqlite3", + "NAME": os.environ.get("DB_NAME", "/db/janeway.sqlite3"), } } From 157754cb2ca7a6095eae70307fca206a7580fe3d Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 12 Mar 2026 11:39:50 +0000 Subject: [PATCH 72/76] chore: fix migrations --- src/repository/migrations/0046_repositoryorganisationunit.py | 1 - .../0047_repositoryorganisationunit_parent_and_more.py | 5 ----- 2 files changed, 6 deletions(-) diff --git a/src/repository/migrations/0046_repositoryorganisationunit.py b/src/repository/migrations/0046_repositoryorganisationunit.py index 69951f5413..a16a0b1e42 100644 --- a/src/repository/migrations/0046_repositoryorganisationunit.py +++ b/src/repository/migrations/0046_repositoryorganisationunit.py @@ -28,7 +28,6 @@ class Migration(migrations.Migration): models.CharField( help_text="The name of the unit, eg. 'Research' or 'Publications'.", max_length=255, - verbose_name=django.db.models.fields.CharField, ), ), ( diff --git a/src/repository/migrations/0047_repositoryorganisationunit_parent_and_more.py b/src/repository/migrations/0047_repositoryorganisationunit_parent_and_more.py index 0cb92054b3..8fdb0f504c 100644 --- a/src/repository/migrations/0047_repositoryorganisationunit_parent_and_more.py +++ b/src/repository/migrations/0047_repositoryorganisationunit_parent_and_more.py @@ -22,9 +22,4 @@ class Migration(migrations.Migration): to="repository.repositoryorganisationunit", ), ), - migrations.AlterField( - model_name="repositoryorganisationunit", - name="name", - field=models.CharField(), - ), ] From 36fe72333d49168c52b4510f13cde5d53b29a44c Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 12 Mar 2026 12:08:40 +0000 Subject: [PATCH 73/76] fix: fixes journal_defaults.json from bad merge --- src/utils/install/journal_defaults.json | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/utils/install/journal_defaults.json b/src/utils/install/journal_defaults.json index dfe6ba16f8..ee1653bb10 100644 --- a/src/utils/install/journal_defaults.json +++ b/src/utils/install/journal_defaults.json @@ -5637,7 +5637,6 @@ }, { "group": { -<<<<<<< integration/iowa-and-isolinear "name": "Identifiers" }, "setting": { @@ -5649,7 +5648,14 @@ }, "value": { "default": "" -======= + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { "name": "general" }, "setting": { @@ -5661,7 +5667,6 @@ }, "value": { "default": "on" ->>>>>>> master }, "editable_by": [ "editor", From 5a93be38b2bca2601e75a79c93bd45c29603ba6d Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 12 Mar 2026 13:15:19 +0000 Subject: [PATCH 74/76] fix: reverts use of abstract_display --- src/themes/OLH/templates/journal/article.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/themes/OLH/templates/journal/article.html b/src/themes/OLH/templates/journal/article.html index 1714e2b588..463884d33b 100644 --- a/src/themes/OLH/templates/journal/article.html +++ b/src/themes/OLH/templates/journal/article.html @@ -162,7 +162,7 @@

    {% trans "Abstract" %}

    {% if journal_settings.general.uses_isolinear_plugin and not article.is_published %} {% hook 'preprint_abstract_block' article.abstract %} {% else %} - {{ article.abstract_display|safe }} + {{ article.abstract|safe }} {% endif %} {% endif %} {% if journal_settings.general.keyword_list_page %} @@ -367,7 +367,7 @@

    {% trans "Download" %}

    {% trans 'Downloads are not available until this article is published ' %}

    {% endif %}
    - {% if article.published or proofing %} + {% if article.is_published or proofing %}
    {% include "elements/journal/article_issue_list.html" %}
    From d59dd583d39d8db7d097bcfb200bbf46f6a190b0 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Thu, 12 Mar 2026 13:15:40 +0000 Subject: [PATCH 75/76] fix adds kwargs to URL tag --- src/templates/admin/elements/metadata.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/templates/admin/elements/metadata.html b/src/templates/admin/elements/metadata.html index 5fdaff1d52..81792fd994 100644 --- a/src/templates/admin/elements/metadata.html +++ b/src/templates/admin/elements/metadata.html @@ -315,7 +315,7 @@

    Identifiers

    DOI Article has no DOI - {% if user_is_editor %}Add DOI{% else %}---{% endif %} + {% if user_is_editor %}Add DOI{% else %}---{% endif %} {% endif %} {% endwith %} From 6814a7a409af737a47fdb633c9b8c1191123c02c Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 17 Mar 2026 13:18:56 +0000 Subject: [PATCH 76/76] test: add some tests --- src/api/tests/test_preprints.py | 212 ++++++++++++++++++++++++++++ src/core/test_settings.py | 9 ++ src/identifiers/tests/test_logic.py | 100 +++++++++++++ src/repository/tests/test_models.py | 123 ++++++++++++++++ src/repository/tests/test_views.py | 70 +++++++++ 5 files changed, 514 insertions(+) create mode 100644 src/core/test_settings.py diff --git a/src/api/tests/test_preprints.py b/src/api/tests/test_preprints.py index 29917a9be6..a9771f2547 100644 --- a/src/api/tests/test_preprints.py +++ b/src/api/tests/test_preprints.py @@ -7,6 +7,7 @@ will shift those PKs and cause failures. """ +import mock from uuid import uuid4 from django.test import TestCase, override_settings @@ -310,3 +311,214 @@ def test_no_subject_filter_returns_all_preprints(self): titles = self._get_titles(response) self.assertIn("Preprint A", titles) self.assertIn("Preprint B", titles) + + +ACCOUNT_ENDPOINTS_DOMAIN = "account-endpoints-test.domain.com" +CAN_EDIT_DOMAIN = "can-edit-preprint-test.domain.com" +PREPRINT_FILES_DOMAIN = "preprint-files-test.domain.com" + + +class TestAccountEndpointsGating(TestCase): + """ + Tests for API_ENABLE_ACCOUNT_ENDPOINTS gating (iowa-and-isolinear). + + The URL registration happens at import time, so the routing test only + works when the setting is False (the default). The queryset/search + behaviour is tested directly via the view class. + """ + + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.press.save() + cls.repo, cls.subject = helpers.create_repository( + cls.press, [], [], domain=ACCOUNT_ENDPOINTS_DOMAIN + ) + cls.searcher = helpers.create_user("acct.searcher@test.com") + cls.target = helpers.create_user("exact.match@test.com") + cls.api_client = APIClient() + + @override_settings(URL_CONFIG="domain") + def test_account_search_not_routed_when_setting_disabled(self): + """submission_account_search returns 404 when API_ENABLE_ACCOUNT_ENDPOINTS is False (default).""" + response = self.api_client.get( + "/api/submission_account_search/", + SERVER_NAME=ACCOUNT_ENDPOINTS_DOMAIN, + ) + self.assertEqual(response.status_code, 404) + + def test_account_search_queryset_returns_exact_email_match(self): + """SubmissionAccountSearch queryset returns the account matching the exact email.""" + from api.views import SubmissionAccountSearch + from rest_framework.test import APIRequestFactory + + factory = APIRequestFactory() + request = factory.get( + "/api/submission_account_search/", + {"search": "exact.match@test.com"}, + ) + request.user = self.searcher + request.repository = self.repo + + view = SubmissionAccountSearch() + view.request = request + qs = view.get_queryset() + self.assertEqual(qs.count(), 1) + self.assertEqual(qs.first().email, "exact.match@test.com") + + def test_account_search_queryset_returns_nothing_for_partial_match(self): + """Partial email strings return no results (exact-only semantics).""" + from api.views import SubmissionAccountSearch + from rest_framework.test import APIRequestFactory + + factory = APIRequestFactory() + request = factory.get( + "/api/submission_account_search/", + {"search": "exact.match"}, + ) + request.user = self.searcher + request.repository = self.repo + + view = SubmissionAccountSearch() + view.request = request + qs = view.get_queryset() + self.assertEqual(qs.count(), 0) + + def test_account_search_queryset_returns_nothing_without_search_param(self): + """An empty search parameter returns an empty queryset.""" + from api.views import SubmissionAccountSearch + from rest_framework.test import APIRequestFactory + + factory = APIRequestFactory() + request = factory.get("/api/submission_account_search/") + request.user = self.searcher + request.repository = self.repo + + view = SubmissionAccountSearch() + view.request = request + qs = view.get_queryset() + self.assertEqual(qs.count(), 0) + + +class TestCanEditPreprintPermission(TestCase): + """ + Tests for the CanEditPreprint DRF permission class (iowa-and-isolinear). + + GET requests are open to any authenticated user. PUT/PATCH/DELETE + are limited to the preprint's owner and staff. + """ + + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.press.save() + cls.repo, cls.subject = helpers.create_repository( + cls.press, [], [], domain=CAN_EDIT_DOMAIN + ) + cls.owner = helpers.create_user("preprint.owner@test.com") + cls.other_user = helpers.create_user("preprint.other@test.com") + cls.staff_user = helpers.create_user("preprint.staff@test.com") + cls.staff_user.is_staff = True + cls.staff_user.save() + cls.preprint = helpers.create_preprint( + cls.repo, cls.owner, cls.subject, title="Permission Test Preprint" + ) + cls.api_client = APIClient() + + def _url(self, pk=None): + if pk: + return reverse("repository_user_preprints-detail", kwargs={"pk": pk}) + return reverse("repository_user_preprints-list") + + @override_settings(URL_CONFIG="domain") + def test_owner_can_get_own_preprints(self): + """Owner can GET their own preprint list.""" + self.api_client.force_authenticate(user=self.owner) + response = self.api_client.get(self._url(), SERVER_NAME=CAN_EDIT_DOMAIN) + self.assertEqual(response.status_code, 200) + self.api_client.force_authenticate(user=None) + + @override_settings(URL_CONFIG="domain") + def test_other_user_cannot_see_owners_preprints(self): + """Another user's preprint list does not include someone else's preprints.""" + self.api_client.force_authenticate(user=self.other_user) + response = self.api_client.get(self._url(), SERVER_NAME=CAN_EDIT_DOMAIN) + self.assertEqual(response.status_code, 200) + titles = [r["title"] for r in response.data.get("results", response.data)] + self.assertNotIn("Permission Test Preprint", titles) + self.api_client.force_authenticate(user=None) + + @override_settings(URL_CONFIG="domain") + def test_non_owner_cannot_delete_preprint(self): + """A non-owner receives 403 when attempting DELETE.""" + self.api_client.force_authenticate(user=self.other_user) + response = self.api_client.delete( + self._url(pk=self.preprint.pk), SERVER_NAME=CAN_EDIT_DOMAIN + ) + self.assertEqual(response.status_code, 403) + self.api_client.force_authenticate(user=None) + + def test_staff_can_edit_any_preprint(self): + """CanEditPreprint grants staff permission for mutation methods.""" + from api.permissions import CanEditPreprint + from rest_framework.test import APIRequestFactory + + factory = APIRequestFactory() + request = factory.delete(f"/api/user_preprints/{self.preprint.pk}/") + request.user = self.staff_user + request.data = {} + + view = mock.Mock() + view.kwargs = {"pk": self.preprint.pk} + + permission = CanEditPreprint() + self.assertTrue(permission.has_permission(request, view)) + + +class TestPreprintFilesScoping(TestCase): + """ + Tests for PreprintFiles viewset queryset scoping (iowa-and-isolinear). + + PreprintFile objects are scoped to the request's repository AND the + current user, so users cannot reach files belonging to other users' + preprints. + """ + + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.press.save() + cls.repo, cls.subject = helpers.create_repository( + cls.press, [], [], domain=PREPRINT_FILES_DOMAIN + ) + cls.owner = helpers.create_user("files.owner@test.com") + cls.other_user = helpers.create_user("files.other@test.com") + cls.preprint_owner = helpers.create_preprint( + cls.repo, cls.owner, cls.subject, title="Owner Preprint" + ) + cls.preprint_other = helpers.create_preprint( + cls.repo, cls.other_user, cls.subject, title="Other Preprint" + ) + cls.api_client = APIClient() + + def _url(self): + return reverse("repository_preprint_files-list") + + @override_settings(URL_CONFIG="domain") + def test_unauthenticated_request_is_rejected(self): + """PreprintFiles endpoint requires authentication.""" + response = self.api_client.get(self._url(), SERVER_NAME=PREPRINT_FILES_DOMAIN) + self.assertIn(response.status_code, [401, 403]) + + @override_settings(URL_CONFIG="domain") + def test_owner_sees_only_own_files(self): + """The owner's file list only contains files from their own preprints.""" + self.api_client.force_authenticate(user=self.owner) + response = self.api_client.get(self._url(), SERVER_NAME=PREPRINT_FILES_DOMAIN) + self.assertEqual(response.status_code, 200) + # submission_file created by create_preprint belongs to owner + preprint_ids = { + r["preprint"] for r in response.data.get("results", response.data) + } + self.assertNotIn(self.preprint_other.pk, preprint_ids) + self.api_client.force_authenticate(user=None) diff --git a/src/core/test_settings.py b/src/core/test_settings.py new file mode 100644 index 0000000000..61b12abab4 --- /dev/null +++ b/src/core/test_settings.py @@ -0,0 +1,9 @@ +from core.settings import * + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } +} +print("TEST SETTINGS LOADED") diff --git a/src/identifiers/tests/test_logic.py b/src/identifiers/tests/test_logic.py index f4ad94be4f..277e6ab454 100644 --- a/src/identifiers/tests/test_logic.py +++ b/src/identifiers/tests/test_logic.py @@ -485,3 +485,103 @@ def test_check_crossref_settings(self): missing_settings, ["crossref_prefix", "crossref_username", "crossref_password"], ) + + +class TestReviewDoiMinting(TestCase): + """ + Tests for review DOI minting via the event listener (iowa-and-isolinear). + + deposit_doi_for_reviews creates identifiers and a CrossrefDeposit when + Crossref is configured. The event listener only fires when the + mint_open_review_dois setting is enabled and the reviewer has given + permission_to_make_public. + """ + + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.press.save() + cls.journal, _ = helpers.create_journals() + + from utils.install import update_settings + update_settings() + + save_setting("general", "journal_issn", cls.journal, "0000-0001") + save_setting("general", "print_issn", cls.journal, "0000-0002") + save_setting("Identifiers", "use_crossref", cls.journal, True) + save_setting("Identifiers", "crossref_prefix", cls.journal, "10.9999") + save_setting("Identifiers", "crossref_email", cls.journal, "test@example.com") + save_setting("Identifiers", "crossref_name", cls.journal, "Test Depositor") + save_setting("Identifiers", "crossref_registrant", cls.journal, "Test Reg") + save_setting("Identifiers", "crossref_username", cls.journal, "testuser") + save_setting("Identifiers", "crossref_password", cls.journal, "testpass") + save_setting("crossref", "crossref_date_suffix", cls.journal, "") + save_setting("Identifiers", "mint_open_review_dois", cls.journal, True) + + cls.article = helpers.create_article(cls.journal, with_author=True) + + from review import models as review_models + cls.reviewer = helpers.create_user("review.doi.reviewer@test.com") + cls.editor = helpers.create_user("review.doi.editor@test.com") + cls.review = helpers.create_review_assignment( + journal=cls.journal, + article=cls.article, + reviewer=cls.reviewer, + editor=cls.editor, + ) + cls.review.permission_to_make_public = True + cls.review.date_complete = timezone.now() + cls.review.save() + + def test_get_dois_for_reviews_creates_identifier(self): + """get_dois_for_reviews creates a DOI Identifier for each review.""" + from identifiers import reviews as id_reviews + identifiers = id_reviews.get_dois_for_reviews([self.review]) + self.assertEqual(len(identifiers), 1) + self.assertEqual(identifiers[0].id_type, "doi") + self.assertEqual(identifiers[0].review, self.review) + + def test_get_dois_for_reviews_is_idempotent(self): + """Calling get_dois_for_reviews twice returns the same identifier.""" + from identifiers import reviews as id_reviews + first = id_reviews.get_dois_for_reviews([self.review]) + second = id_reviews.get_dois_for_reviews([self.review]) + self.assertEqual(first[0].pk, second[0].pk) + + @mock.patch("identifiers.reviews.Depositor") + def test_deposit_doi_for_reviews_creates_crossref_deposit(self, mock_depositor): + """deposit_doi_for_reviews creates a CrossrefDeposit record.""" + mock_response = mock.Mock() + mock_response.ok = True + mock_depositor.return_value.register_doi.return_value = mock_response + + from identifiers import reviews as id_reviews + initial_count = models.CrossrefDeposit.objects.count() + id_reviews.deposit_doi_for_reviews(self.journal, [self.review]) + self.assertGreater(models.CrossrefDeposit.objects.count(), initial_count) + + def test_event_listener_does_not_fire_without_permission(self): + """review_doi_mint_event_listener does nothing when permission_to_make_public is False.""" + from identifiers import reviews as id_reviews + article_no_perm = helpers.create_article(self.journal, with_author=True) + review_no_permission = helpers.create_review_assignment( + journal=self.journal, + article=article_no_perm, + ) + review_no_permission.permission_to_make_public = False + review_no_permission.save() + + request = helpers.Request() + request.journal = self.journal + + initial_count = models.Identifier.objects.filter( + review=review_no_permission + ).count() + id_reviews.review_doi_mint_event_listener( + request=request, + review_assignment=review_no_permission, + ) + self.assertEqual( + models.Identifier.objects.filter(review=review_no_permission).count(), + initial_count, + ) diff --git a/src/repository/tests/test_models.py b/src/repository/tests/test_models.py index 2194dbaa7f..650232333d 100644 --- a/src/repository/tests/test_models.py +++ b/src/repository/tests/test_models.py @@ -4,6 +4,8 @@ __maintainer__ = "Birkbeck Centre for Technology and Publishing" import mock +from django.core.exceptions import ValidationError +from django.db import IntegrityError from django.test import TestCase, override_settings from django.core.files.uploadedfile import SimpleUploadedFile @@ -130,3 +132,124 @@ def test_create_article_force(self): self.preprint_one.article, article_two, ) + + +class TestRepositoryOrganisationUnit(TestCase): + """Tests for the RepositoryOrganisationUnit model introduced in iowa-and-isolinear.""" + + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.press.save() + cls.repository, _ = helpers.create_repository( + cls.press, [], [], domain="rou-test.domain.com" + ) + cls.root = rm.RepositoryOrganisationUnit.objects.create( + repository=cls.repository, + name="Root", + code="root", + ) + cls.child_a = rm.RepositoryOrganisationUnit.objects.create( + repository=cls.repository, + name="Child A", + code="child-a", + parent=cls.root, + ) + cls.child_b = rm.RepositoryOrganisationUnit.objects.create( + repository=cls.repository, + name="Child B", + code="child-b", + parent=cls.root, + ) + cls.grandchild = rm.RepositoryOrganisationUnit.objects.create( + repository=cls.repository, + name="Grandchild", + code="grandchild", + parent=cls.child_a, + ) + + def test_get_descendants_returns_all_levels(self): + """get_descendants returns children and grandchildren recursively.""" + descendants = self.root.get_descendants() + self.assertIn(self.child_a, descendants) + self.assertIn(self.child_b, descendants) + self.assertIn(self.grandchild, descendants) + + def test_get_descendants_excludes_self(self): + """get_descendants does not include the unit itself.""" + descendants = self.root.get_descendants() + self.assertNotIn(self.root, descendants) + + def test_get_descendants_of_leaf_is_empty(self): + """get_descendants on a leaf node returns an empty list.""" + self.assertEqual(self.grandchild.get_descendants(), []) + + def test_unique_together_code_and_repository(self): + """Two ROUs in the same repository cannot share a code.""" + with self.assertRaises(IntegrityError): + rm.RepositoryOrganisationUnit.objects.create( + repository=self.repository, + name="Duplicate", + code="root", + ) + + def test_same_code_allowed_in_different_repository(self): + """The same code is allowed in a different repository.""" + other_repo, _ = helpers.create_repository( + self.press, [], [], domain="rou-other.domain.com" + ) + unit = rm.RepositoryOrganisationUnit.objects.create( + repository=other_repo, + name="Root", + code="root", + ) + self.assertEqual(unit.code, "root") + + def test_str_includes_repository_code_and_unit_code(self): + """__str__ returns a readable identifier.""" + result = str(self.root) + self.assertIn(self.repository.code, result) + self.assertIn("root", result) + + +class TestRepositorySubmissionType(TestCase): + """Tests for the RepositorySubmissionType model introduced in iowa-and-isolinear.""" + + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.press.save() + cls.repository, _ = helpers.create_repository( + cls.press, [], [], domain="subtype-test.domain.com" + ) + + def _make_type(self, pill_colour="#1e40af"): + return rm.RepositorySubmissionType( + repository=self.repository, + name="Article", + name_plural="Articles", + slug="article", + pill_colour=pill_colour, + ) + + def test_valid_six_digit_hex_passes_validation(self): + obj = self._make_type("#1e40af") + obj.full_clean() # should not raise + + def test_valid_three_digit_hex_passes_validation(self): + obj = self._make_type("#fff") + obj.full_clean() # should not raise + + def test_invalid_hex_fails_validation(self): + obj = self._make_type("blue") + with self.assertRaises(ValidationError): + obj.full_clean() + + def test_hex_without_hash_fails_validation(self): + obj = self._make_type("1e40af") + with self.assertRaises(ValidationError): + obj.full_clean() + + def test_str_returns_name(self): + obj = self._make_type() + self.assertEqual(str(obj), "Article") diff --git a/src/repository/tests/test_views.py b/src/repository/tests/test_views.py index 41f3ece34a..61730c1be0 100644 --- a/src/repository/tests/test_views.py +++ b/src/repository/tests/test_views.py @@ -378,3 +378,73 @@ def test_view_preprint_comment_login_link_has_return(self): ) content = response.content.decode() self.assertIn("/login/?next=", content) + + +class TestHierarchyView(TestCase): + """Tests for the rou_hierarchy_view introduced in iowa-and-isolinear.""" + + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.press.save() + cls.server_name = "hierarchy-test.domain.com" + cls.repository, cls.subject = helpers.create_repository( + cls.press, [], [], domain=cls.server_name + ) + cls.root = rm.RepositoryOrganisationUnit.objects.create( + repository=cls.repository, + name="Research", + code="research", + ) + cls.child = rm.RepositoryOrganisationUnit.objects.create( + repository=cls.repository, + name="Applied", + code="applied", + parent=cls.root, + ) + cls.author = helpers.create_user("hierarchy.author@janeway.systems") + cls.preprint = helpers.create_preprint( + cls.repository, cls.author, cls.subject + ) + cls.preprint.stage = rm.STAGE_PREPRINT_PUBLISHED + cls.preprint.date_published = timezone.now() + cls.preprint.save() + cls.preprint.organisation_units.add(cls.root) + + def setUp(self): + clear_script_prefix() + + @override_settings(URL_CONFIG="domain") + def test_hierarchy_root_view_returns_200(self): + """The hierarchy root page (no ROU selected) returns HTTP 200.""" + path = reverse("rou_hierarchy") + response = self.client.get(path, SERVER_NAME=self.server_name) + self.assertEqual(response.status_code, 200) + + @override_settings(URL_CONFIG="domain") + def test_hierarchy_root_view_lists_top_level_rous(self): + """The root hierarchy page contains the top-level ROU name.""" + path = reverse("rou_hierarchy") + response = self.client.get(path, SERVER_NAME=self.server_name) + self.assertContains(response, "Research") + + @override_settings(URL_CONFIG="domain") + def test_hierarchy_rou_view_returns_200(self): + """Navigating to a specific ROU returns HTTP 200.""" + path = reverse("rou_hierarchy", kwargs={"rou_code": self.root.code}) + response = self.client.get(path, SERVER_NAME=self.server_name) + self.assertEqual(response.status_code, 200) + + @override_settings(URL_CONFIG="domain") + def test_hierarchy_rou_view_shows_preprints(self): + """The selected-ROU page lists preprints belonging to that unit.""" + path = reverse("rou_hierarchy", kwargs={"rou_code": self.root.code}) + response = self.client.get(path, SERVER_NAME=self.server_name) + self.assertContains(response, self.preprint.title) + + @override_settings(URL_CONFIG="domain") + def test_hierarchy_unknown_rou_code_returns_404(self): + """A request for a non-existent ROU code returns HTTP 404.""" + path = reverse("rou_hierarchy", kwargs={"rou_code": "does-not-exist"}) + response = self.client.get(path, SERVER_NAME=self.server_name) + self.assertEqual(response.status_code, 404)