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/requirements.txt b/requirements.txt index f7dc70694c..e26c173b3a 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/api/permissions.py b/src/api/permissions.py index 7afeeed088..eb23dd12e9 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." @@ -29,3 +32,47 @@ 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 + + +class CanEditPreprint(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", "DELETE"]: + 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 diff --git a/src/api/serializers.py b/src/api/serializers.py index 03113b667f..f9caf721ff 100755 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -1,18 +1,41 @@ -from rest_framework import serializers +import uuid -from core import models as core_models +from rest_framework import serializers, validators + +from django.db import transaction +from django.shortcuts import reverse + +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 +from identifiers import models as identifier_models +from events import logic as event_logic 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",) @@ -87,13 +110,99 @@ class Meta: fields = ("name",) -class PreprintFileSerializer(serializers.ModelSerializer): +class PreprintSubjectGroupSerializer(serializers.HyperlinkedModelSerializer): + 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): + class Meta: + model = repository_models.PreprintFile + fields = ( + "pk", + "original_filename", + "file", + "preprint", + "mime_type", + "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", - "download_url", + "public_download_url", + "manager_download_url", + ) + + +class PreprintVersionSerializer(serializers.ModelSerializer): + class Meta: + model = repository_models.PreprintVersion + fields = ( + "version", + "date_time", + "title", + "abstract", + "public_download_url", ) @@ -178,16 +287,38 @@ 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", + "email", "first_name", "middle_name", "last_name", "salutation", "suffix", "orcid", + "institution", ) @@ -215,10 +346,23 @@ 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): @@ -228,6 +372,7 @@ class Meta: "pk", "title", "abstract", + "stage", "license", "keywords", "date_submitted", @@ -237,14 +382,15 @@ class Meta: "preprint_doi", "authors", "subject", - "files", + "versions", "supplementary_files", + "additional_field_answers", + "owner", ) depth = 2 authors = PreprintAccountSerializer( many=True, - read_only=True, ) license = LicenceSerializer() keywords = KeywordsSerializer( @@ -255,13 +401,404 @@ class Meta: many=True, read_only=True, ) - files = PreprintFileSerializer( - source="preprintfile_set", + supplementary_files = PreprintSupplementaryFileSerializer( + source="preprintsupplementaryfile_set", many=True, read_only=True, ) + versions = PreprintVersionSerializer( + source="preprintversion_set", + 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", "preprint_review"), + 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"), + comments_editor=validated_data.get("comments_editor"), + ) + + 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, + repository=preprint.repository, + ) + 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, + ) + 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") + 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.comments_editor = validated_data.get("comments_editor") + 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() + + urls = [] + 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, + }, + ) + urls.append(url) + for supp_file in instance.preprintsupplementaryfile_set.all(): + if supp_file.url not in urls: + supp_file.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", + "supplementary_files", + "comments_editor", + ) + + authors = PreprintAccountSerializer( + many=True, + ) + keywords = KeywordsSerializer( + many=True, + ) + subject = PreprintSubjectSerializer( + many=True, + ) + additional_field_answers = RepositoryFieldAnswerSerializer( + source="repositoryfieldanswer_set", + many=True, + ) supplementary_files = PreprintSupplementaryFileSerializer( source="preprintsupplementaryfile_set", many=True, + ) + + +class SubmissionAccountSearch(serializers.ModelSerializer): + class Meta: + model = core_models.Account + fields = ( + "pk", + "first_name", + "middle_name", + "last_name", + "orcid", + "email", + ) + + +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", + ) + + +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) + password = validated_data.get("password") + if password: + user.set_password(password) + + user.confirmation_code = uuid.uuid4() + user.save() + + request = self.context.get("request") + if 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=False, + ) + 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, ) + + +class IdentifierSerializer(serializers.ModelSerializer): + class Meta: + model = identifier_models.Identifier + fields = ( + "id_type", + "identifier", + "article", + "preprint_version", + ) diff --git a/src/api/tests/test_preprints.py b/src/api/tests/test_preprints.py new file mode 100644 index 0000000000..29917a9be6 --- /dev/null +++ b/src/api/tests/test_preprints.py @@ -0,0 +1,312 @@ +""" +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/api/urls.py b/src/api/urls.py index ac9afce953..7a47f512b8 100755 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -1,7 +1,9 @@ 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 +from rest_framework.urlpatterns import format_suffix_patterns from api import views from api.oai import views as oai_views @@ -11,11 +13,50 @@ 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, "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, "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"identifiers", views.Identifiers, "api_identifiers") + +router.register(r"user_info", views.UserInfo, "api_user_info") +router.register(r"logout", views.Logout, basename="logout") + +if settings.API_ENABLE_ACCOUNT_ENDPOINTS: + router.register( + r"submission_account_search", + views.SubmissionAccountSearch, + "submission_account_search", + ) + router.register( + r"account/register", + views.RegisterAccount, + "register_account", + ) + router.register( + r"account/activate", + views.ActivateAccount, + "activate_account", + ) + # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. urlpatterns = [ @@ -32,4 +73,7 @@ ), 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 b772f76f52..7a47aeafd4 100755 --- a/src/api/views.py +++ b/src/api/views.py @@ -1,6 +1,5 @@ import collections import csv -import io import json import re @@ -8,16 +7,22 @@ from django.shortcuts import render 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 django.shortcuts import get_object_or_404 -from rest_framework import viewsets, generics +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 from api import serializers, permissions as api_permissions from core import models as core_models 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"]) @@ -58,6 +63,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 @@ -159,14 +169,370 @@ class PreprintViewSet(viewsets.ModelViewSet): """ serializer_class = serializers.PreprintSerializer + 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): + 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") + 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] + + # Initial filter on Title, Abstract and Keywords. + preprint_search = preprints.filter( + Q(title__icontains=search_term) + | Q(abstract__icontains=search_term) + | Q(keywords__word=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( + pk__in=from_author, + preprint__date_published__lte=timezone.now(), + ) + ] + + preprint_pks = list( + { + preprint.pk + for preprint in list(preprint_search) + preprints_from_author + } + ) + + preprints = repository_models.Preprint.objects.filter( + pk__in=preprint_pks, + ) + + if stage: + preprints = preprints.filter( + stage=stage, + ) + + if subjects: + preprints = preprints.filter( + subject__name__in=subjects, + ) + + return preprints + + +class PublishedPreprintViewSet(PreprintViewSet): http_method_names = ["get"] + permission_classes = [ + permissions.AllowAny, + ] def get_queryset(self): - return repository_models.Preprint.objects.filter( + 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( + **filters, + ).order_by( + "-date_published", + "title", + ) + + +class UserPreprintsViewSet(PreprintViewSet): + serializer_class = serializers.PreprintSerializer + http_method_names = ["get", "post", "put"] + permission_classes = [permissions.IsAuthenticated, api_permissions.CanEditPreprint] + + 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( + owner=self.request.user, repository=self.request.repository, - date_published__lte=timezone.now(), - stage=repository_models.STAGE_PREPRINT_PUBLISHED, ) + stage_filter = self.request.GET.get("stage") + if stage_filter: + preprints = preprints.filter(stage=stage_filter) + 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.", + ) + + +class PreprintFiles(viewsets.ModelViewSet): + serializer_class = serializers.PreprintFileSerializer + http_method_names = ["get", "post", "delete"] + permission_classes = [permissions.IsAuthenticated, api_permissions.CanEditPreprint] + + 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, + preprint__owner=self.request.user, + ) + else: + raise NotImplementedError( + "This view only works with Repositories.", + ) + + +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, + ) + + +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): + 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): + """ + 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_ACCOUNT_ENDPOINTS 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 + + +class Logout(viewsets.ViewSet): + """ + A ViewSet for logging out the current user. + """ + + http_method_names = ["post"] + permission_classes = [ + permissions.IsAuthenticated, + ] + + @staticmethod + def create(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"] + + +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): + 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 + + +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): diff --git a/src/core/janeway_global_settings.py b/src/core/janeway_global_settings.py index 0343c2f73f..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"), } } @@ -721,3 +721,7 @@ 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) + +# This setting should only be enabled where CORS is properly +# configured to stop misuse of this endpoint. +API_ENABLE_ACCOUNT_ENDPOINTS = False diff --git a/src/core/plugin_loader.py b/src/core/plugin_loader.py index 15a91715c0..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) @@ -96,11 +99,13 @@ def validate_plugin_version(plugin_settings): 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 - ) + 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): diff --git a/src/core/templatetags/hooks.py b/src/core/templatetags/hooks.py index 7dffd10a1c..1c3d824a36 100755 --- a/src/core/templatetags/hooks.py +++ b/src/core/templatetags/hooks.py @@ -11,15 +11,17 @@ @register.simple_tag(takes_context=True) -def hook(context, hook_name, *args, **kwargs): - try: - html = "" - for hook in settings.PLUGIN_HOOKS.get(hook_name, []): +def hook(context, hook_name, default_value="", *args, **kwargs): + 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")) - html = html + function(context, *args, **kwargs) - - return mark_safe(html) - except Exception as e: - logger.error("Error rendering hook {0}: {1}".format(hook_name, e)) - return "" + hook_output = function(context, *args, **kwargs) + if hook_output: + 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) diff --git a/src/events/logic.py b/src/events/logic.py index cdf1088258..643eb3b369 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 0ab23a0952..b00c74a762 100755 --- a/src/events/registration.py +++ b/src/events/registration.py @@ -4,10 +4,10 @@ __maintainer__ = "Birkbeck Centre for Technology and Publishing" from core import models as core_models, workflow -from utils import transactional_emails, workflow_tasks 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 +from identifiers import logic as id_logic, reviews from typesetting.notifications import emails # wire up event notifications @@ -75,6 +75,10 @@ event_logic.Events.ON_REVIEW_SECURITY_OVERRIDE, transactional_emails.review_sec_override_notification, ) +event_logic.Events.register_for_event( + event_logic.Events.ON_REVIEW_COMPLETE, + reviews.review_doi_mint_event_listener, +) # Revisions event_logic.Events.register_for_event( @@ -235,6 +239,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, @@ -255,6 +265,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/identifiers/admin.py b/src/identifiers/admin.py index 7a76a1286b..7fde879fbb 100755 --- a/src/identifiers/admin.py +++ b/src/identifiers/admin.py @@ -43,10 +43,19 @@ 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: + 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,10 +108,14 @@ 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 - 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, diff --git a/src/identifiers/forms.py b/src/identifiers/forms.py index 98e838ed1a..5952fdc47d 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,16 +63,35 @@ 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): identifier = super(IdentifierForm, self).save(commit=False) - - if self.article: - identifier.article = self.article + if not self.instance.pk: + 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 646ec67b6c..9b12051b3b 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,8 @@ from crossref.restful import Depositor 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__) @@ -38,11 +35,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: @@ -112,20 +114,33 @@ 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): @@ -262,6 +277,18 @@ 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", @@ -657,110 +684,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( "Identifiers", "crossref_prefix", issue.journal @@ -785,3 +708,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/migrations/0010_auto_20231107_1750.py b/src/identifiers/migrations/0010_auto_20231107_1750.py new file mode 100644 index 0000000000..156d5ce6b5 --- /dev/null +++ b/src/identifiers/migrations/0010_auto_20231107_1750.py @@ -0,0 +1,46 @@ +# Generated by Django 3.2.20 on 2023-11-07 17:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("repository", "0042_auto_20231107_1750"), + ("submission", "0073_bleach_title_20230523_1804"), + ("review", "0022_remove_reviewform_slug"), + ("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..96a00d93b9 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,58 @@ 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 +158,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") @@ -259,7 +290,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 +331,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( diff --git a/src/identifiers/preprints.py b/src/identifiers/preprints.py new file mode 100644 index 0000000000..73d62f04b8 --- /dev/null +++ b/src/identifiers/preprints.py @@ -0,0 +1,119 @@ +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): + status, error = None, None + 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, + ) + 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.ok: + status = f"Deposit sent ({repository.short_name})" + util_models.LogEntry.bulk_add_simple_entry( + "Submission", + status, + "Info", + 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( + repository, preprint_versions, identifiers + ) + return status, error diff --git a/src/identifiers/reviews.py b/src/identifiers/reviews.py new file mode 100644 index 0000000000..d3f970676c --- /dev/null +++ b/src/identifiers/reviews.py @@ -0,0 +1,152 @@ +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 review_doi_mint_event_listener(**kwargs): + request = kwargs.get("request") + review = kwargs.get("review_assignment") + + mint_review_dois = request.journal.get_setting( + "Identifiers", + "mint_open_review_dois", + ) + + # Check if minting DOIs is enabled and the review has public permission + # from the author. + if mint_review_dois and review.permission_to_make_public: + deposit_doi_for_reviews(request.journal, [review]) + + +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) + 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 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.ok: + 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() + + return status 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 92313df2f4..f4a07383d6 100755 --- a/src/identifiers/urls.py +++ b/src/identifiers/urls.py @@ -9,46 +9,56 @@ 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", ), + # 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 11446afc90..460bc0fcdf 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,48 +47,61 @@ 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, + logic.get_identifier_by_content_type( + content_type=content_type, + obj=obj, + identifier_id=identifier_id, ) if identifier_id else None @@ -95,42 +109,54 @@ def manage_identifier(request, article_id, identifier_id=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,38 @@ 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]) + 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, + template_context, + content_type="application/xml", ) - template = "common/identifiers/crossref_doi_batch.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 +219,48 @@ 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) + 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. 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 +270,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 +295,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 +311,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 +338,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 +392,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/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)) diff --git a/src/journal/models.py b/src/journal/models.py index 731dd6d7f5..2cc3a0b551 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: diff --git a/src/journal/views.py b/src/journal/views.py index e9491ad805..3ea50ba3c6 100755 --- a/src/journal/views.py +++ b/src/journal/views.py @@ -3024,6 +3024,18 @@ 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")), diff --git a/src/repository/admin.py b/src/repository/admin.py index 54810abbd7..38f4288298 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, @@ -82,6 +87,7 @@ class PreprintAdmin(admin.ModelAdmin): "date_submitted", "doi", "current_version", + "article", ) list_display_links = ("pk", "title") list_filter = ( @@ -101,6 +107,7 @@ class PreprintAdmin(admin.ModelAdmin): "article", "submission_file", "license", + "submission_type", ) search_fields = ( "pk", @@ -335,6 +342,36 @@ 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") + + +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), @@ -352,6 +389,8 @@ class ReviewRecommendationAdmin(admin.ModelAdmin): (models.VersionQueue, VersionQueueAdmin), (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/decorators.py b/src/repository/decorators.py new file mode 100644 index 0000000000..b9aa05e40c --- /dev/null +++ b/src/repository/decorators.py @@ -0,0 +1,26 @@ +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 e38fcee06b..05485c603e 100755 --- a/src/repository/forms.py +++ b/src/repository/forms.py @@ -12,17 +12,73 @@ 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(), @@ -49,22 +105,27 @@ class Meta: def __init__(self, *args, **kwargs): self.request = kwargs.pop("request") self.admin = kwargs.pop("admin", False) - elements = self.request.repository.additional_submission_fields() + self.submission_type_slug = kwargs.pop("submission_type_slug", None) super(PreprintInfo, self).__init__(*args, **kwargs) + if ( + not self.submission_type_slug + and self.instance + and self.instance.submission_type + ): + self.submission_type_slug = self.instance.submission_type.slug + + elements = self.request.repository.type_additional_submission_fields( + submission_type_slug=self.submission_type_slug, + ) if self.admin: - self.fields.pop("submission_agreement") self.fields.pop("comments_editor") - # 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 - self.fields["subject"].queryset = models.Subject.objects.filter( enabled=True, repository=self.request.repository, ) + if self.admin: self.fields["license"].queryset = submission_models.Licence.objects.filter( journal__isnull=True, @@ -79,7 +140,8 @@ def __init__(self, *args, **kwargs): for element in elements: 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": self.fields[element.name] = forms.CharField( @@ -88,11 +150,7 @@ def __init__(self, *args, **kwargs): ) elif element.input_type == "date": self.fields[element.name] = forms.CharField( - widget=forms.DateInput( - attrs={ - "class": "datepicker", - } - ), + widget=forms.DateInput(attrs={"class": "datepicker"}), required=element.required, ) elif element.input_type == "select": @@ -125,8 +183,8 @@ def __init__(self, *args, **kwargs): if element.input_type == "date": self.fields[ element.name - ].help_text = "Use ISO 8601 Date Format YYYY-MM-DD. {}".format( - element.help_text + ].help_text = ( + f"Use ISO 8601 Date Format YYYY-MM-DD. {element.help_text}" ) else: self.fields[element.name].help_text = element.help_text @@ -146,7 +204,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 @@ -158,7 +215,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( @@ -463,18 +519,6 @@ def __init__(self, *args, **kwargs): 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: @@ -488,8 +532,6 @@ class Meta: "theme", "display_public_metrics", "publisher", - "enable_comments", - "enable_invited_comments", ) help_texts = { "domain": "Using a custom domain requires configuring DNS. " @@ -510,6 +552,7 @@ class RepositorySite(RepositoryBase): class Meta: model = models.Repository fields = ( + "headless_mode", "about", "logo", "hero_background", @@ -562,6 +605,7 @@ class RepositoryEmails(RepositoryBase): class Meta: model = models.Repository fields = ( + "submission_notification_recipients", "submission", "publication", "decline", @@ -571,7 +615,7 @@ class Meta: "review_invitation", "manager_review_status_change", "reviewer_review_status_change", - "submission_notification_recipients", + "new_version_submitted", ) widgets = { @@ -584,6 +628,7 @@ class Meta: "review_invitation": TinyMCE, "manager_review_status_change": TinyMCE, "reviewer_review_status_change": TinyMCE, + "new_version_submitted": TinyMCE, "submission_notification_recipients": TableMultiSelectUser(), } @@ -607,6 +652,7 @@ class Meta: model = models.RepositoryField fields = ( "name", + "submission_type", "input_type", "choices", "required", @@ -761,3 +807,65 @@ 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"}), + } diff --git a/src/repository/logic.py b/src/repository/logic.py index cd0d4b22d4..d7c9e8088d 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 diff --git a/src/repository/migrations/0042_auto_20231107_1750.py b/src/repository/migrations/0042_auto_20231107_1750.py new file mode 100644 index 0000000000..7c2b651776 --- /dev/null +++ b/src/repository/migrations/0042_auto_20231107_1750.py @@ -0,0 +1,125 @@ +# Generated by Django 3.2.20 on 2023-11-07 17:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("repository", "0041_auto_20231207_1658"), + ] + + operations = [ + migrations.AddField( + model_name="historicalrepository", + name="crossref_depositor_email", + field=models.EmailField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="historicalrepository", + name="crossref_depositor_name", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="historicalrepository", + name="crossref_enable", + field=models.BooleanField( + default=False, + help_text="Enable to use crossref. All other fields must be complete.", + ), + ), + migrations.AddField( + model_name="historicalrepository", + name="crossref_password", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="historicalrepository", + name="crossref_prefix", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="historicalrepository", + name="crossref_registrant", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="historicalrepository", + name="crossref_test_mode", + field=models.BooleanField( + default=False, help_text="Enable to use Crossref test." + ), + ), + migrations.AddField( + model_name="historicalrepository", + name="crossref_username", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="historicalrepository", + name="headless_mode", + field=models.BooleanField( + default=False, + help_text="Enable this feature to make this repository run in headless mode, with no front end.", + ), + ), + migrations.AddField( + model_name="repository", + name="crossref_depositor_email", + field=models.EmailField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="repository", + name="crossref_depositor_name", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="repository", + name="crossref_enable", + field=models.BooleanField( + default=False, + help_text="Enable to use crossref. All other fields must be complete.", + ), + ), + migrations.AddField( + model_name="repository", + name="crossref_password", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="repository", + name="crossref_prefix", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="repository", + name="crossref_registrant", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="repository", + name="crossref_test_mode", + field=models.BooleanField( + default=False, help_text="Enable to use Crossref test." + ), + ), + migrations.AddField( + model_name="repository", + name="crossref_username", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="repository", + name="headless_mode", + field=models.BooleanField( + default=False, + help_text="Enable this feature to make this repository run in headless mode, with no front end.", + ), + ), + migrations.AlterField( + model_name="preprintversion", + name="title", + field=models.CharField( + blank=True, help_text="Your article title", max_length=300 + ), + ), + ] diff --git a/src/repository/migrations/0043_alter_preprintversion_file.py b/src/repository/migrations/0043_alter_preprintversion_file.py new file mode 100644 index 0000000000..67bb37c7b1 --- /dev/null +++ b/src/repository/migrations/0043_alter_preprintversion_file.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.20 on 2023-11-08 14:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("repository", "0042_auto_20231107_1750"), + ] + + operations = [ + migrations.AlterField( + model_name="preprintversion", + name="file", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="repository.preprintfile", + ), + ), + ] 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..5b9845637a --- /dev/null +++ b/src/repository/migrations/0044_historicalrepository_new_version_submitted_and_more.py @@ -0,0 +1,35 @@ +# 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/migrations/0046_repositoryorganisationunit.py b/src/repository/migrations/0046_repositoryorganisationunit.py new file mode 100644 index 0000000000..a16a0b1e42 --- /dev/null +++ b/src/repository/migrations/0046_repositoryorganisationunit.py @@ -0,0 +1,60 @@ +# 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( + help_text="The name of the unit, eg. 'Research' or 'Publications'.", + max_length=255, + ), + ), + ( + "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..8fdb0f504c --- /dev/null +++ b/src/repository/migrations/0047_repositoryorganisationunit_parent_and_more.py @@ -0,0 +1,25 @@ +# 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", + ), + ), + ] 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..9936b835e8 --- /dev/null +++ b/src/repository/migrations/0048_remove_repositoryorganisationunit_preprints_and_more.py @@ -0,0 +1,28 @@ +# 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..787432bfe7 --- /dev/null +++ b/src/repository/migrations/0049_remove_preprint_organisation_unit_and_more.py @@ -0,0 +1,26 @@ +# 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..95361eaf09 --- /dev/null +++ b/src/repository/migrations/0050_historicalrepository_rou_default_name_and_more.py @@ -0,0 +1,49 @@ +# 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.", + max_length=255, + ), + ), + 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.", + max_length=255, + ), + ), + 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/migrations/0051_repositorysubmissiontype_preprint_submission_type.py b/src/repository/migrations/0051_repositorysubmissiontype_preprint_submission_type.py new file mode 100644 index 0000000000..b6bf52353a --- /dev/null +++ b/src/repository/migrations/0051_repositorysubmissiontype_preprint_submission_type.py @@ -0,0 +1,96 @@ +# 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.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/migrations/0052_merge_20260226_1524.py b/src/repository/migrations/0052_merge_20260226_1524.py new file mode 100644 index 0000000000..3e277af053 --- /dev/null +++ b/src/repository/migrations/0052_merge_20260226_1524.py @@ -0,0 +1,13 @@ +# 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..76eeea9ec1 --- /dev/null +++ b/src/repository/migrations/0053_alter_historicalrepository_new_version_submitted_and_more.py @@ -0,0 +1,44 @@ +# 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/repository/models.py b/src/repository/models.py index 5d70a1f272..19cab7a925 100755 --- a/src/repository/models.py +++ b/src/repository/models.py @@ -6,7 +6,7 @@ import os import uuid import json -from dateutil import parser as dateparser +import csv from django.db import models from django.db.models import Q @@ -16,9 +16,13 @@ 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 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 @@ -27,6 +31,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" @@ -205,6 +210,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, @@ -268,6 +277,61 @@ 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( + max_length=255, + 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.", + ) + 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" @@ -298,8 +362,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=""): @@ -334,6 +409,61 @@ 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( + Repository, + on_delete=models.CASCADE, + ) + 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.", + ) + 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") + + 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( @@ -362,6 +492,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, @@ -426,6 +564,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, @@ -514,6 +657,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( @@ -742,6 +891,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 ): @@ -783,6 +940,34 @@ 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", @@ -1041,6 +1226,7 @@ class PreprintVersion(models.Model): file = models.ForeignKey( PreprintFile, on_delete=models.CASCADE, + null=True, ) version = models.IntegerField(default=1) date_time = models.DateTimeField(default=timezone.now) @@ -1070,6 +1256,185 @@ 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() @@ -1086,6 +1451,42 @@ 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.version}" + + 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 + + def public_download_url(self): + 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): author = models.ForeignKey( diff --git a/src/repository/urls.py b/src/repository/urls.py index f8251dd254..84cf094865 100755 --- a/src/repository/urls.py +++ b/src/repository/urls.py @@ -48,11 +48,12 @@ ), 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"), 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, @@ -114,6 +115,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/$", @@ -275,4 +296,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 82c7015219..6b1cee79b2 100644 --- a/src/repository/views.py +++ b/src/repository/views.py @@ -3,32 +3,39 @@ __license__ = "AGPL v3" __maintainer__ = "Birkbeck Centre for Technology and Publishing" -import operator from datetime import datetime from dateutil import tz 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 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 + -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, @@ -48,31 +55,68 @@ 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, +@decorators.headless_mode_check +def repository_home( + request, + rou_code=None, +): + repository = request.repository + selected_rou = None + rous = [] + + if rou_code: + # Get the selected ROU + selected_rou = get_object_or_404( + models.RepositoryOrganisationUnit, + repository=repository, + code=rou_code, + ) + # 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 + rous = models.RepositoryOrganisationUnit.objects.filter( + 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, - ).order_by("-date_published")[:6] - subjects = models.Subject.objects.filter( - repository=request.repository, - ).prefetch_related( - "preprint_set", ) + if relevant_rous: + # Filter preprints that belong to the selected ROU or its sub-units + preprints_query = preprints_query.filter( + 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, + enabled=True, + ).prefetch_related("preprint_set") + template = "repository/home.html" 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): @@ -214,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", @@ -267,6 +320,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 @@ -277,6 +331,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. @@ -296,106 +351,59 @@ 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) + return redirect( + f"{reverse('repository_list')}?subject={subject_id}", + permanent=True, + ) - 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), - } +def repository_list(request): + form = forms.PreprintFilterForm(request.GET, repository=request.repository) - return render(request, template, context) + 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() -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}, - ) - ) + if subject: + preprints = preprints.filter(subject=subject) - # 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, - ) + if submission_type: + preprints = preprints.filter(submission_type=submission_type) - 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 search_term: + split_terms = [term for term in search_term.split() if term] - 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 - ) + keyword_filter = Q(keywords__word__in=split_terms) + title_abstract_filter = Q(title__icontains=search_term) | Q( + abstract__icontains=search_term ) - & (Q(preprint__repository=request.repository)) - ) - preprints_from_author = [ - pa.preprint - for pa in models.PreprintAuthor.objects.filter( - pk__in=from_author, - preprint__date_published__lte=timezone.now(), + 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 = list(set(list(preprint_search) + preprints_from_author)) - preprints.sort(key=operator.attrgetter("date_published"), reverse=True) + preprints = preprints.filter( + title_abstract_filter | keyword_filter | author_filter + ).distinct() - paginator = Paginator(preprints, 15) + paginator = Paginator(preprints.order_by("-date_published"), 15) page = request.GET.get("page", 1) try: @@ -405,15 +413,37 @@ 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) + +@decorators.headless_mode_check +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)}") +@decorators.headless_mode_check def repository_preprint(request, preprint_id): """ Fetches a single article and displays its metadata @@ -535,7 +565,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. @@ -556,13 +586,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, @@ -576,6 +642,7 @@ def repository_submit(request, preprint_id=None): form = forms.PreprintInfo( instance=preprint, request=request, + submission_type_slug=submission_type.slug, ) if request.POST: @@ -583,10 +650,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", @@ -594,11 +668,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) @@ -1076,7 +1152,11 @@ 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) @@ -1655,7 +1735,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) @@ -2539,3 +2619,178 @@ def manage_review_recommendation(request, recommendation_id=None): template, context, ) + + +def preprints_by_rou(request, rou_code): + # Get the selected ROU + rou = get_object_or_404( + models.RepositoryOrganisationUnit, + repository=request.repository, + code=rou_code, + ) + + # 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 + 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) + 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, + }, + ) + + +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), + }, + ) + + +@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/review/forms.py b/src/review/forms.py index 99835891f5..4dac3d4600 100755 --- a/src/review/forms.py +++ b/src/review/forms.py @@ -430,20 +430,32 @@ 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): super(ReviewVisibilityForm, self).__init__(*args, **kwargs) if not self.instance.review_file: self.fields.pop("display_review_file") + if self.instance: + open_review_enabled = self.instance.article.journal.get_setting( + "general", "open_peer_review" + ) + if not open_review_enabled or not self.instance.permission_to_make_public: + self.fields.pop("display_public") class AnswerVisibilityForm(forms.Form): diff --git a/src/review/models.py b/src/review/models.py index 56a0ca3110..61f36d1419 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,45 @@ 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): + article_pattern = self.article.doi_pattern_preview + return f"{article_pattern}.r{self.pk}" + + 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() diff --git a/src/security/decorators.py b/src/security/decorators.py index a45b181948..7783471d09 100755 --- a/src/security/decorators.py +++ b/src/security/decorators.py @@ -746,6 +746,11 @@ 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/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/submission/forms.py b/src/submission/forms.py index 7c3fa26b13..ab92f53b5b 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..28fe9a44d4 --- /dev/null +++ b/src/submission/migrations/0074_submissionconfiguration_open_peer_review_license.py @@ -0,0 +1,25 @@ +# 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/migrations/0089_merge_20260226_1524.py b/src/submission/migrations/0089_merge_20260226_1524.py new file mode 100644 index 0000000000..977d6712c4 --- /dev/null +++ b/src/submission/migrations/0089_merge_20260226_1524.py @@ -0,0 +1,12 @@ +# 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 = [] diff --git a/src/submission/models.py b/src/submission/models.py index 583d6bc87a..80392bfa59 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -1677,6 +1677,10 @@ 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, @@ -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" @@ -3304,6 +3316,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/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/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 %} 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/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/identifiers/article_identifiers.html b/src/templates/admin/identifiers/identifiers.html similarity index 67% rename from src/templates/admin/identifiers/article_identifiers.html rename to src/templates/admin/identifiers/identifiers.html index df4ce08a60..c30e7d6feb 100644 --- a/src/templates/admin/identifiers/article_identifiers.html +++ b/src/templates/admin/identifiers/identifiers.html @@ -1,25 +1,29 @@ -{% 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
    + {% 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 %} @@ -37,12 +41,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..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 %} @@ -20,7 +19,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 }}

    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..994c298aa0 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

    - + + - + + @@ -180,6 +182,10 @@

    Controls

     Send to Journal +
  • +  Manage Identifiers + +
  • 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

    + @@ -33,7 +34,8 @@

    Submitted Preprints

    - + + 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/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

    + @@ -34,6 +35,7 @@

    Unpublished Preprints

    + @@ -98,6 +100,7 @@

    Published Preprints

    + @@ -109,6 +112,7 @@

    Published Preprints

    + 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 + . +

    +
    Type
    {{ 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 +70,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 +88,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 %} - {{ preprint.title }}
    OwnerOwner LicenceType
    {{ preprint.owner.full_name }}{{ preprint.owner.full_name }} {{ preprint.license.short_name }}{{ preprint.submission_type.name|default:"No submission type set" }}
    Preprint DOI
    ID TitleType Date Submitted Decision Date Published
    {{ 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 }}
    ID TitleType First Author Date Submitted
    {{ preprint.title|safe }} {{ preprint.submission_type.name|default:"No submission type" }} {{ preprint.author_full_name }} {{ preprint.date_submitted }}
    ID TitleType First Author Date Published
    {{ preprint.title|safe }} {{ preprint.submission_type.name|default:"No submission type" }} {{ preprint.author_full_name }} {{ preprint.date_published }}
    + + + + + + + + + + + {% 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/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/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..d21a424278 100644 --- a/src/templates/admin/repository/submit/start.html +++ b/src/templates/admin/repository/submit/start.html @@ -2,123 +2,77 @@ {% load static %} {% load i18n %} {% load foundation %} -{% load field %} +{% load field dict %} {% block title-section %} - Submit {{ 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 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.abstract|foundation }} +
    +
    + + {% csrf_token %} +
    +
    +

    {% trans "Submission Agreement" %}

    +
    -
    -
    - {{ form.license|foundation }} -
    -
    - {{ form.doi|foundation }} -
    + {% include "elements/forms/errors.html" with form=form %} +
    + {{ request.repository.submission_agreement|safe }} + {{ form.submission_agreement }} + +
    -
    -
    -
    - -
      - {% include "admin/elements/repository/tree.html" with subjects=request.repository.top_level_subjects %} -
    -

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

    -
    -
    -
    +
    +

    {% trans "Submission Type" %}

    +
    -
    -
    -
    - - -

    {% 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.submission_type.label }}

    -
    - {{ form.comments_editor|foundation }} -
    +
    + {% for radio in form.submission_type %} + + {% endfor %} +
    -
    -
    -
    - -
    -
    -
    -
    -
    - -
    -
    -

    Information

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

    {{ 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 %} -{% block js %} - - - +
    - - + + +
    + +
    +
    {% endblock %} 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/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 %}
    diff --git a/src/templates/common/identifiers/crossref_preprint.xml b/src/templates/common/identifiers/crossref_preprint.xml new file mode 100644 index 0000000000..4cc091730a --- /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/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 b5d5a40618..d9172d5d54 100755 --- a/src/themes/OLH/assets/scss/app.scss +++ b/src/themes/OLH/assets/scss/app.scss @@ -1652,4 +1652,177 @@ 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; +} + +.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; +} + +/* Organisational Unit hierarchy */ +.ou-container { + .ou-block { + padding: 1.5rem; + margin-bottom: 1.5rem; + height: 100%; + } + + .ou-info-box { + background-color: #EFF6FF; + border-left: 4px solid #3B82F6; + padding: 1rem; + border-radius: 0.375rem; + margin-bottom: 2rem; + } + + .ou-info-box p { + margin-bottom: 0; + } + + .ou-tree { + list-style: none; + padding: 0; + margin: 0; + } + + .ou-tree ul { + list-style: none; + padding-left: 0rem; + margin-top: 0.25rem; + } + + .ou-tree-item { + display: flex; + align-items: center; + padding: 0.5rem; + border-radius: 0.375rem; + } + + .ou-tree-first-list { + margin-left: 0; + } + + .ou-tree-item.selected { + background-color: #DBEAFE; + color: #1E40AF; + font-weight: 500; + } + + .ou-tree-item-icon { + width: 16px; + height: 16px; + margin-right: 0.25rem; + color: #6B7280; + } + + .ou-tree-item-link { + flex: 1; + text-decoration: none; + color: inherit; + } + + .ou-tree-item-count { + font-size: 0.875rem; + color: #6B7280; + } + + .ou-selected-unit { + border-bottom: 1px solid #E5E7EB; + padding-bottom: 0.75rem; + margin-bottom: 1.5rem; + } + + .ou-selected-unit-count { + font-size: 0.875rem; + font-weight: normal; + color: #6B7280; + margin-left: 0.5rem; + } + + .ou-preprints-header { + display: flex; + align-items: center; + margin-bottom: 1rem; + } + + .ou-preprints-header-title { + flex: 1; + } + + .ou-preprint-list { + border: 1px solid #E5E7EB; + border-radius: 0.5rem; + overflow: hidden; + } + + .ou-preprint-item { + padding: 1rem; + border-bottom: 1px solid #E5E7EB; + } + + .ou-preprint-item:last-child { + border-bottom: none; + } + + .ou-preprint-item:hover { + background-color: #F9FAFB; + } + + .ou-preprint-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + font-size: 0.875rem; + color: #6B7280; + margin-bottom: 0.5rem; + } + + .ou-preprint-meta-separator { + margin: 0 0.5rem; + } + + .ou-preprint-abstract { + font-size: 0.9rem; + } + + .flex-between { + display: flex; + justify-content: space-between; + align-items: center; + } + + .flex-between .title-wrap { + flex-grow: 1; + margin-right: 1rem; + min-width: 0; + } + + .flex-between .pill-wrap { + flex-shrink: 0; + } +} 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..22e24fddac 100644 --- a/src/themes/OLH/templates/elements/public_reviews.html +++ b/src/themes/OLH/templates/elements/public_reviews.html @@ -7,16 +7,27 @@

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

    +

    + Reviewer: {{ review.reviewer.full_name }}
    + {% 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 %} -

    - {{ answer.element.name }} +

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

    + {% 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.

    diff --git a/src/themes/OLH/templates/journal/article.html b/src/themes/OLH/templates/journal/article.html index bbdd15e95b..463884d33b 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|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 %} @@ -272,6 +277,8 @@

    {% trans 'Files' %}:

    {% endif %} {% if article.peer_reviewed %}

    {% trans "Peer Reviewed" %}

    + {% elif journal_settings.general.uses_isolinear_plugin and not article.is_published %} +
    Preprint Under Review
    {% endif %}
    {% if article.date_published or article.date_accepted or proofing %} @@ -292,12 +299,14 @@

    {% trans 'Files' %}:

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

    {% trans "Files" %}

    {% else %}

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

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

    {% trans "Download" %}

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

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

    {% endif %} + {% if article.is_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 +469,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 +477,7 @@

    {{ article.preprint.repository.object_name }}

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

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

    {% if galleys %} @@ -477,6 +492,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/OLH/templates/repository/elements/hierarchy_list.html b/src/themes/OLH/templates/repository/elements/hierarchy_list.html new file mode 100644 index 0000000000..5a68e41262 --- /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 }} submission{% 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/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 new file mode 100644 index 0000000000..363e715c7e --- /dev/null +++ b/src/themes/OLH/templates/repository/hierarchy.html @@ -0,0 +1,127 @@ +{% extends "core/base.html" %} +{% load static %} +{% load i18n %} + +{% block title %} + {% if rou %} + {{ rou.name }} Hierarchy - {{ request.repository.name }} + {% else %} + {{ request.repository.rou_default_name|safe }}s - {{ request.repository.name }} + {% endif %} +{% 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 }}s

    + +
    + {{ page_text|safe }} +
    + +
    +
    +
    + +
    +
    + +
    + {% 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 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" }} +
    + {% if preprint.abstract %} +

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

    + {% endif %} +
    + {% endfor %} +
    + + {% endif %} +
    + {% 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 %} +
    +
    + {% 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 7c542e038e..cd31c73407 100644 --- a/src/themes/OLH/templates/repository/home.html +++ b/src/themes/OLH/templates/repository/home.html @@ -1,83 +1,150 @@ {% extends "core/base.html" %} {% load i18n %} -{% block title %}{{ request.repository.name }}{% endblock %} +{% block title %} + {% if selected_rou %} + {{ selected_rou.name }} - {{ request.repository.name }} + {% else %} + {{ 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 %} {% block body %} +
    +
    + {% if not selected_rou %} +
    +

    + {{ request.repository.name }} +

    +
    + {% endif %} -
    -
    + {% if selected_rou %}
    -

    {{ request.repository.name }}

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

    {% trans "Latest Preprints" %}

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

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

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

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

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

    + {% if selected_rou %} + {% 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' %} {{ request.repository.rou_default_name }} + {% 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 %} -
    -

    {% trans 'Filter by Subject' %}

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

    {% 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 %} diff --git a/src/themes/OLH/templates/repository/list.html b/src/themes/OLH/templates/repository/list.html index 6abf2a711d..819dcff42a 100644 --- a/src/themes/OLH/templates/repository/list.html +++ b/src/themes/OLH/templates/repository/list.html @@ -2,131 +2,141 @@ {% load static %} {% load i18n %} {% load truncate %} -{% load dates %} +{% block css %} + {% include "common/repository/style/submission_type_pills.html" %} +{% endblock css %} -{% 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|capfirst }} + {% 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 }}

    +

    + + {% 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 %} + {% endfor %} + +

    +

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

    - {% endfor %} -
    -
    -
    -
    -
    +
    +
    + {% endfor %} +
    +
      + {% if preprints.has_previous %} +
    • «
    • + {% endif %} + {% for page in preprints.paginator.page_range %} +
    • + {{ page }} +
    • + {% endfor %} + {% if preprints.has_next %} +
    • »
    • + {% endif %} +
    - -
    + {{ 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 body %} + + +{% endblock %} diff --git a/src/themes/OLH/templates/repository/preprint.html b/src/themes/OLH/templates/repository/preprint.html index 90bf51cc16..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 %}


    + {% include "repository/elements/submission_type_pill.html" %}

    {{ preprint.title|safe }}

    - This is a {{ request.repository.object_name }} and has not been + This is a {{ preprint.submission_type.name }} and has not been peer reviewed. {% if preprint.doi %} The published version of this - {{ request.repository.object_name }} is available: - {% include "elements/doi_display.html" with doi=preprint.doi title=preprint.title %} + {{ preprint.submission_type.name }} is available: + {{ preprint.doi }}. {% elif preprint.article %} A published version of this - {{ request.repository.object_name }} is available on + {{ preprint.submission_type.name }} is available on {{ preprint.article.journal.name }} . {% endif %} This is version {{ preprint.current_version.version }} of - this {{ request.repository.object_name }}. + this {{ preprint.submission_type.name }}.

    @@ -117,12 +122,32 @@

    {% 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 %} + {% with preprint.organisation_units.all as rous %} + {% if rous %} +

    + {% if rous.count > 1 %} + {{ request.repository.rou_default_name }}s + {% else %} + {{ request.repository.rou_default_name }} + {% endif %} +

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

    {% trans "Metadata" %}

    • {% trans "Published" %}: {{ preprint.date_published|date_human }}
    • @@ -176,10 +201,11 @@

      {% trans "Metrics" %}

    • {% trans "Downloads" %}: {{ preprint.downloads.count }}
    {% endif %} - - - {% trans "All Preprints" %} - + + {% hook 'preprint_sidebar' %} + + + {% trans "All submissions" %}
    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 %}