diff --git a/.env.example b/.env.example index aba5109..bb38f4f 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,24 @@ -SECRET_KEY=ccscscsc +ALLOWED_HOSTS=127.0.0.1 +ALTCHA_HMAC_KEY=secret_key +BLUESKY_JDH_ACCOUNT=****.bsky.social +BLUESKY_JDH_PASSWORD=secret_key +CORS_ALLOW_CREDENTIALS=True +CORS_ALLOWED_ORIGINS=http://127.0.0.1 +CSRF_TRUSTED_ORIGINS=http://127.0.0.1 DATABASE_ENGINE=django.db.backends.sqlite3 DATABASE_NAME=db.sqlite3 DATABASE_USER=db DATABASE_PASSWORD=pwd -ALLOWED_HOSTS=127.0.0.1 -CSRF_TRUSTED_ORIGINS=http://127.0.0.1 -EMAIL_HOST=smtp -EMAIL_PORT=poort DRF_RECAPTCHA_SECRET_KEY=secret_key -NUM_CHARS_FINGERPRINT=10 -JDH_ORCID_API_TOKEN=secret_key +SECRET_KEY=ccscscsc +EMAIL_HOST=smtp +EMAIL_PORT=port +FACEBOOK_JDH_PAGE_ID=secret_key +FACEBOOK_JDH_ACCESS_TOKEN=secret_key GITHUB_ACCESS_TOKEN=secret_key -CORS_ALLOW_CREDENTIALS=True -CORS_ALLOWED_ORIGINS=http://127.0.0.1 +JDH_ORCID_API_TOKEN=secret_key +NUM_CHARS_FINGERPRINT=10 SESSION_COOKIE_SAMESITE=None SESSION_COOKIE_SECURE=True -FACEBOOK_JDH_PAGE_ID=secret_key -FACEBOOK_JDH_ACCESS_TOKEN=secret_key -BLUESKY_JDH_ACCOUNT=****.bsky.social -BLUESKY_JDH_PASSWORD=secret_key \ No newline at end of file +OJS_API_KEY_TOKEN=secret_key +OJS_API_URL=http://ojs.journalofdigitalhistory.org \ No newline at end of file diff --git a/.flake8 b/.flake8 deleted file mode 100644 index c6a6d95..0000000 --- a/.flake8 +++ /dev/null @@ -1,5 +0,0 @@ -[flake8] -max-line-length = 120 -per-file-ignores = - jdhapi/views/submit_abstract.py: E501 -ignore = E402, F401, F403 \ No newline at end of file diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index d0f263c..e20ef07 100644 --- a/.github/workflows/run_unit_tests.yml +++ b/.github/workflows/run_unit_tests.yml @@ -1,4 +1,4 @@ -name: Django CI +name: Unit Tests Django CI on: pull_request: @@ -45,6 +45,7 @@ jobs: - name: Run tests env: + ALTCHA_HMAC_KEY: ${{ secrets.ALTCHA_HMAC_KEY }} SECRET_KEY: default-secret-key-for-testing DATABASE_ENGINE: django.db.backends.postgresql_psycopg2 DATABASE_NAME: test_db @@ -53,6 +54,7 @@ jobs: DATABASE_HOST: localhost DATABASE_PORT: 5432 ALLOWED_HOSTS: localhost + CORS_ALLOWED_ORIGINS: http://127.0.0.1 CSRF_TRUSTED_ORIGINS: http://127.0.0.1 DRF_RECAPTCHA_SECRET_KEY: default-recaptcha-secret-key EMAIL_HOST: smtp.example.com @@ -66,6 +68,9 @@ jobs: BLUESKY_JDH_PASSWORD: ${{ secrets.BLUESKY_JDH_PASSWORD }} FACEBOOK_JDH_PAGE_ID: ${{ secrets.FACEBOOK_JDH_PAGE_ID }} FACEBOOK_JDH_ACCESS_TOKEN: ${{ secrets.FACEBOOK_JDH_ACCESS_TOKEN }} + OJS_API_KEY_TOKEN: default-token + OJS_API_URL: https://ojs-api-url-example.com + COPY_EDITOR_ADDRESS: copy-editor-address@mail.com run: | pipenv run ./manage.py test diff --git a/Pipfile b/Pipfile index 9536a8f..ff9cd81 100644 --- a/Pipfile +++ b/Pipfile @@ -154,6 +154,8 @@ webcolors = "==24.11.1" webencodings = "==0.5.1" websocket-client = "==1.8.0" zopfli = "==0.2.3.post1" +altcha = "*" +django-cors-headers = "*" [requires] python_version = "3.12" diff --git a/Pipfile.lock b/Pipfile.lock index 0302432..31f6f6d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "955697c9c254a0c1e32cba98800eb4d4b397021361b18578efa94c4610b6bb25" + "sha256": "27653bc2a1a2d0d77c5f0f91d29d7b517feed4009acd0ffb0ce8e00063c5eb06" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,15 @@ ] }, "default": { + "altcha": { + "hashes": [ + "sha256:33056826a60efdc3d9651d5a13d72d7c8ee1c7f4456b9c9db65838f47fac9082", + "sha256:78cc0fd3ba907d069252e4ea47e0dd981d4d9bd41f360333cc76ca2ce2d2a46c" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==1.0.0" + }, "amqp": { "hashes": [ "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", @@ -703,6 +712,15 @@ "markers": "python_version >= '3.10'", "version": "==5.1.6" }, + "django-cors-headers": { + "hashes": [ + "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", + "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==4.9.0" + }, "django-countries": { "hashes": [ "sha256:1ed20842fe0f6194f91faca21076649513846a8787c9eb5aeec3cbe1656b8acc", diff --git a/jdh/settings.py b/jdh/settings.py index a6e9bb4..00366aa 100644 --- a/jdh/settings.py +++ b/jdh/settings.py @@ -10,9 +10,10 @@ https://docs.djangoproject.com/en/3.1/ref/settings/ """ -from pathlib import Path import os import sys +from pathlib import Path + from .base import get_env_variable # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -30,18 +31,23 @@ DJANGO_LOG_LEVEL = get_env_variable("DJANGO_LOG_LEVEL", "DEBUG") +CORS_ALLOWED_ORIGINS = get_env_variable("CORS_ALLOWED_ORIGINS", "").split(",") + CSRF_TRUSTED_ORIGINS = get_env_variable("CSRF_TRUSTED_ORIGINS", "").split(",") ALLOWED_HOSTS = get_env_variable("ALLOWED_HOSTS", "").split(",") +ALTCHA_HMAC_KEY = get_env_variable("ALTCHA_HMAC_KEY", "").split(",") + DRF_RECAPTCHA_SECRET_KEY = get_env_variable( "DRF_RECAPTCHA_SECRET_KEY", "default-recaptacha-secret-key" -) +).split(",") # Application definition INSTALLED_APPS = [ + "corsheaders", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -79,6 +85,7 @@ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + 'corsheaders.middleware.CorsMiddleware', "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", @@ -278,7 +285,6 @@ DEFAULT_TO_EMAIL = get_env_variable("DEFAULT_TO_EMAIL", "jdh.admin@uni.lu") # Github API - GITHUB_ACCESS_TOKEN = get_env_variable("GITHUB_ACCESS_TOKEN") # Social network @@ -286,3 +292,9 @@ BLUESKY_JDH_PASSWORD = get_env_variable("BLUESKY_JDH_PASSWORD") FACEBOOK_JDH_PAGE_ID = get_env_variable("FACEBOOK_JDH_PAGE_ID") FACEBOOK_JDH_ACCESS_TOKEN = get_env_variable("FACEBOOK_JDH_ACCESS_TOKEN") + +# OJS API +OJS_API_KEY_TOKEN = get_env_variable("OJS_API_KEY_TOKEN", "default") +OJS_API_URL = get_env_variable("OJS_API_URL", "http://ojs.journalofdigitalhistory.org") + +COPY_EDITOR_ADDRESS = get_env_variable("COPY_EDITOR_ADDRESS", "") diff --git a/jdhapi/admin.py b/jdhapi/admin.py index 5ceae1e..f7f6a2b 100644 --- a/jdhapi/admin.py +++ b/jdhapi/admin.py @@ -11,10 +11,10 @@ from .tasks import ( save_article_fingerprint, save_article_specific_content, - save_citation, save_libraries, save_references, ) +from .utils.articles import save_citation from import_export.admin import ExportActionMixin from django.utils.html import format_html @@ -37,7 +37,7 @@ def save_notebook_specific_cell(modeladmin, request, queryset): def save_article_citation(modeladmin, request, queryset): for article in queryset: - save_citation.delay(article_id=article.pk) + save_citation(article_id=article.pk) save_article_citation.short_description = "3: Generate citation" diff --git a/jdhapi/forms/articleForm.py b/jdhapi/forms/articleForm.py index 694123b..133054a 100644 --- a/jdhapi/forms/articleForm.py +++ b/jdhapi/forms/articleForm.py @@ -1,7 +1,7 @@ # import marko from django import forms from jdhapi.models import Article, Abstract -from jdhapi.utils.gitup_repository import is_socialmediacover_exist +from jdhapi.utils.github_repository import is_socialmediacover_exist import logging import datetime from django.http import Http404 diff --git a/jdhapi/migrations/0052_article_ojs_submission_id.py b/jdhapi/migrations/0052_article_ojs_submission_id.py new file mode 100644 index 0000000..2900882 --- /dev/null +++ b/jdhapi/migrations/0052_article_ojs_submission_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2026-02-09 16:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('jdhapi', '0051_remove_abstract_issue'), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='ojs_submission_id', + field=models.IntegerField(blank=True, default=None, null=True), + ), + ] diff --git a/jdhapi/migrations/0053_author_linkedin_id_alter_article_status.py b/jdhapi/migrations/0053_author_linkedin_id_alter_article_status.py new file mode 100644 index 0000000..d1a6b10 --- /dev/null +++ b/jdhapi/migrations/0053_author_linkedin_id_alter_article_status.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2026-03-02 11:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('jdhapi', '0052_article_ojs_submission_id'), + ] + + operations = [ + migrations.AddField( + model_name='author', + name='linkedin_id', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AlterField( + model_name='article', + name='status', + field=models.CharField(choices=[('DRAFT', 'Draft'), ('TECHNICAL_REVIEW', 'Technical review'), ('PEER_REVIEW', 'Peer review'), ('DESIGN_REVIEW', 'Design review'), ('COPY_EDITING', 'Copy editing'), ('PUBLISHED', 'Published')], default='DRAFT', max_length=25), + ), + ] diff --git a/jdhapi/models/article.py b/jdhapi/models/article.py index 9f7f22a..1a4ec33 100644 --- a/jdhapi/models/article.py +++ b/jdhapi/models/article.py @@ -1,15 +1,7 @@ import logging -import marko -from django.conf import settings - -from django.core.mail import EmailMessage from django.db import models -from django.template.loader import render_to_string - -from lxml import html from model_utils import FieldTracker -from weasyprint import HTML logger = logging.getLogger(__name__) @@ -43,7 +35,6 @@ class Article(models.Model): Methods: get_kernel_language(): Returns the kernel language based on the 'tool' tag. __str__(): Returns the title of the abstract. - send_email_if_peer_review(): Sends an email with a PDF attachment if the article is in peer review status. """ class Status(models.TextChoices): @@ -63,7 +54,14 @@ class Status(models.TextChoices): "DESIGN_REVIEW", "Design review", ) - PUBLISHED = "PUBLISHED", "Published" + COPY_EDITING = ( + "COPY_EDITING", + "Copy editing", + ) + PUBLISHED = ( + "PUBLISHED", + "Published", + ) class CopyrightType(models.TextChoices): DRAFT = ( @@ -138,7 +136,7 @@ class RepositoryType(models.TextChoices): blank=True, null=True, help_text="Url to find here https://data.journalofdigitalhistory.org/", - ) # New field for Dataverse URL + ) publication_date = models.DateTimeField(blank=True, null=True) copyright_type = models.CharField( max_length=15, @@ -162,6 +160,7 @@ class RepositoryType(models.TextChoices): ) tags = models.ManyToManyField("jdhapi.Tag", blank=True) authors = models.ManyToManyField("jdhapi.Author", through="Role") + ojs_submission_id = models.IntegerField(null=True, blank=True, default=None) def get_kernel_language(self): tool_tags = self.tags.filter(category="tool") @@ -174,37 +173,3 @@ def get_kernel_language(self): def __str__(self): return self.abstract.title - - def send_email_if_peer_review(self): - if self.status == self.Status.PEER_REVIEW: - # Render the PDF template - template = "jdhseo/peer_review.html" - if "title" in self.data: - articleTitle = html.fromstring( - marko.convert(self.data["title"][0]) - ).text_content() - context = {"article": self, "articleTitle": articleTitle} - html_string = render_to_string(template, context) - - # Generate the PDF - pdf_file = HTML(string=html_string).write_pdf() - logger.info("Pdf generated") - filename = "peer_review_" + self.abstract.pid + ".pdf" - # Save the PDF to a file - # with open(filename, 'wb') as f: - # f.write(pdf_file) - # logger.info("Pdf saved") - # Create an email message with the PDF attachment - subject = f"{articleTitle} can been sent to peer review!" - body = "Please find attached the links useful for the peer review." - from_email = settings.DEFAULT_FROM_EMAIL - to_email = settings.DEFAULT_TO_EMAIL - email = EmailMessage(subject, body, from_email, [to_email]) - email.attach(filename, pdf_file, "application/pdf") - - # Send the email - try: - email.send() - logger.info("Email sent") - except Exception as e: - print(f"Error sending email: {str(e)}") diff --git a/jdhapi/models/author.py b/jdhapi/models/author.py index de4c137..44bf652 100644 --- a/jdhapi/models/author.py +++ b/jdhapi/models/author.py @@ -12,6 +12,7 @@ class Author(models.Model): github_id = models.CharField(max_length=39, null=True, blank=True) bluesky_id = models.CharField(max_length=255, null=True, blank=True) facebook_id = models.CharField(max_length=50, null=True, blank=True) + linkedin_id = models.CharField(max_length=100, null=True, blank=True) city = models.CharField(max_length=100, null=True, blank=True) country = CountryField(blank=True, null=True) diff --git a/jdhapi/serializers/article.py b/jdhapi/serializers/article.py index 92507d1..a8f8b93 100644 --- a/jdhapi/serializers/article.py +++ b/jdhapi/serializers/article.py @@ -38,5 +38,6 @@ class Meta: "tags", "issue", "authors", + "ojs_submission_id", "fingerprint", ] diff --git a/jdhapi/serializers/author.py b/jdhapi/serializers/author.py index 695aa83..e814beb 100644 --- a/jdhapi/serializers/author.py +++ b/jdhapi/serializers/author.py @@ -1,7 +1,9 @@ -from jdhapi.serializers.abstract import AbstractSlimSerializer +from django_countries.serializer_fields import CountryField from rest_framework import serializers + +from jdhapi.serializers.abstract import AbstractSlimSerializer + from ..models.author import Author -from django_countries.serializer_fields import CountryField class CountrySerializer(serializers.Serializer): @@ -23,12 +25,12 @@ class Meta: "github_id", "bluesky_id", "facebook_id", - "abstracts", + "linkedin_id", + "abstracts" ] class AuthorSlimSerializer(serializers.ModelSerializer): - country = serializers.SerializerMethodField() def get_country(self, obj): @@ -48,4 +50,5 @@ class Meta: "github_id", "bluesky_id", "facebook_id", + "linkedin_id", ] diff --git a/jdhapi/signals.py b/jdhapi/signals.py index b5fd2fe..4135c1f 100644 --- a/jdhapi/signals.py +++ b/jdhapi/signals.py @@ -1,23 +1,12 @@ import requests - from django.core.exceptions import ValidationError -from django.db.models.signals import post_save, pre_save +from django.db.models.signals import pre_save from django.dispatch import receiver from jdhapi.models import Article from jdhapi.utils.articles import convert_string_to_base64 -@receiver(post_save, sender=Article) -def send_email_for_peer_review_article(sender, instance, created, **kwargs): - if ( - not created - and instance.tracker.has_changed("status") - and instance.status == Article.Status.PEER_REVIEW - ): - instance.send_email_if_peer_review() - - @receiver(pre_save, sender=Article) def validate_urls_for_article_submission(sender, instance, **kwargs): def check_github_url(url): @@ -47,5 +36,4 @@ def check_notebook_url(notebook_url, repository_url): if instance.notebook_url and check_notebook_url( instance.notebook_url, instance.repository_url ): - raise ValidationError("Notebook url is not correct") diff --git a/jdhapi/tasks.py b/jdhapi/tasks.py index ee2f907..825ff93 100644 --- a/jdhapi/tasks.py +++ b/jdhapi/tasks.py @@ -8,7 +8,6 @@ from jdhapi.utils.articles import ( get_notebook_stats, get_notebook_specifics_tags, - get_citation, generate_tags, generate_narrative_tags, get_notebook_references_fulltext, @@ -75,18 +74,6 @@ def save_article_specific_content(article_id): article.save() -@shared_task -def save_citation(article_id): - logger.info(f"save_article_citation:{article_id}") - try: - article = Article.objects.get(pk=article_id) - except Article.DoesNotExist: - logger.error(f"save_article_citation:{article_id} not found") - citation = get_citation(raw_url=article.notebook_ipython_url, article=article) - article.citation = citation - article.save() - - @shared_task def save_libraries(article_id): logger.info(f"save_article_libraries:{article_id}") diff --git a/jdhapi/urls.py b/jdhapi/urls.py index 56759ee..5e0c838 100644 --- a/jdhapi/urls.py +++ b/jdhapi/urls.py @@ -5,6 +5,7 @@ urlpatterns = [ path("api/", views.api_root), path("api/me", views.api_me, name="me"), + path("api/captcha", views.get_captcha_challenge, name="captcha"), path("api-auth/", include("rest_framework.urls")), path("api/abstracts/", views.AbstractList.as_view(), name="abstract-list"), path( @@ -24,16 +25,17 @@ ), path("api/abstracts/submit", views.submit_abstract, name="submit-abstract"), path("api/articles/", views.ArticleList.as_view(), name="article-list"), - path( - "api/articles/status", - views.update_article_status, - name="article-change-status", - ), + path("api/articles//status", views.ArticleStatus.as_view(), name='article-status'), path( "api/articles//", views.ArticleDetail.as_view(), name="article-detail", ), + path( + "api/articles/status", + views.update_article_status, + name="article-change-status", + ), path("api/articles/advance", views.AdvanceArticleList.as_view(), name="advance-article-list"), path( "api/articles/bluesky", @@ -46,7 +48,11 @@ name="articles-facebook", ), path("api/articles/cover", views.get_social_cover_image, name="articles-social-media-cover"), + path("api/articles/ojs/submissions", views.get_count_submission_from_ojs, name="count-submission-from-ojs"), + path("api/articles/ojs/submission", views.send_article_to_ojs, name="articles-send-to-ojs"), path("api/articles/tweet", views.get_tweet_md_file, name="articles-tweet"), + path("api/articles/docx", views.get_docx, name="article-docx"), + path("api/articles/docx/email", views.send_docx_email, name="article-docx-email"), path("api/authors/", views.AuthorList.as_view(), name="author-list"), path("api/authors//", views.AuthorDetail.as_view(), name="author-detail"), path( diff --git a/jdhapi/utils/affiliation.py b/jdhapi/utils/affiliation.py index 01fae4f..b3957de 100644 --- a/jdhapi/utils/affiliation.py +++ b/jdhapi/utils/affiliation.py @@ -1,5 +1,4 @@ import logging -from re import I import pycountry from jdhseo.utils import get_affiliation from jdhapi.models import Author diff --git a/jdhapi/utils/altcha.py b/jdhapi/utils/altcha.py new file mode 100644 index 0000000..1c57588 --- /dev/null +++ b/jdhapi/utils/altcha.py @@ -0,0 +1,51 @@ +import datetime +import logging +from altcha import ChallengeOptions, create_challenge, verify_solution +from django.conf import settings + +logger = logging.getLogger(__name__) + +hmac_key = settings.ALTCHA_HMAC_KEY[0] + +def create_captcha_challenge(): + """ + Create a challenge for the captcha. + + :return: The created challenge. + :rtype: Challenge + """ + logger.info("[create_challenge] Starting create challenge for captcha") + + # Create a new challenge + options = ChallengeOptions( + expires=datetime.datetime.now() + datetime.timedelta(hours=1), + max_number=100000, # The maximum random number + hmac_key=hmac_key, + ) + challenge = create_challenge(options) + logger.info(f"Challenge created:", challenge) + + challenge_dict = { + 'algorithm': challenge.algorithm, + 'challenge': challenge.challenge, + 'maxnumber': challenge.max_number, + 'salt': challenge.salt, + 'signature': challenge.signature, + } + logger.info("Challenge converted to a dictionary object") + + return challenge_dict + +def verify_challenge_solution(payload: dict) -> bool : + """ + Verify if the payload contains a valid solution for the captcha challenge. + + :return: True or false. + :rtype: Boolean. + :param payload: The payload to verify, it should contain the following keys: algorithm, challenge, number, salt and signature. + """ + logger.info("[verify_challenge_solution] Verifying challenge for captcha") + + ok, err = verify_solution(payload, hmac_key, check_expires=True) + + return ok, err \ No newline at end of file diff --git a/jdhapi/utils/articles.py b/jdhapi/utils/articles.py index f8ecef1..c12fda5 100644 --- a/jdhapi/utils/articles.py +++ b/jdhapi/utils/articles.py @@ -13,7 +13,7 @@ from django.utils.html import strip_tags from jdhapi.utils.doi import get_doi_url_formatted_jdh -from jdhapi.models import Author, Tag +from jdhapi.models import Article, Author, Tag from jdhseo.utils import getReferencesFromJupyterNotebook from requests.exceptions import HTTPError @@ -217,6 +217,18 @@ def get_citation(raw_url, article): } +def save_citation(article_id): + logger.info(f"save_article_citation:{article_id}") + try: + article = Article.objects.get(pk=article_id) + except Article.DoesNotExist: + logger.error(f"save_article_citation:{article_id} not found") + return + citation = get_citation(raw_url=article.notebook_ipython_url, article=article) + article.citation = citation + article.save() + + def get_raw_from_github( repository_url, file_type, host="https://raw.githubusercontent.com" ): diff --git a/jdhapi/utils/github_action.py b/jdhapi/utils/github_action.py new file mode 100644 index 0000000..367d3d0 --- /dev/null +++ b/jdhapi/utils/github_action.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +import logging +import time +from datetime import datetime, timezone +from urllib.parse import urlparse + +import requests +from jdh.settings import GITHUB_ACCESS_TOKEN + +logger = logging.getLogger(__name__) + + +def trigger_workflow(repo_url, workflow_filename, token=None, ref="main"): + """ + :param repo_url: GitHub repository link + :param workflow_filename: Filename of the workflow in .github/workflows (e.g. "hello-world.yml") + :param token: GitHub access token with repo permissions (optional, will use env variable if not provided) + :param ref: Git ref (branch or tag) to run the workflow on + """ + token = _get_github_token(token) + owner, repo = _parse_owner_repo(repo_url) + + logger.info( + "[trigger_workflow] - Trigger workflow '%s' on ref '%s' for %s/%s", + workflow_filename, + ref, + owner, + repo, + ) + + url = f"https://api.github.com/repos/{owner}/{repo}/actions/workflows/{workflow_filename}/dispatches" + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + } + payload = {"ref": ref} + try: + res = requests.post(url, json=payload, headers=headers, timeout=10) + if res.status_code == 204: + logger.info( + "Workflow '%s' dispatched on ref '%s' for %s/%s.", + workflow_filename, + ref, + owner, + repo, + ) + else: + logger.error( + "Failed to dispatch workflow '%s' (%s): %s", + workflow_filename, + res.status_code, + res.text, + ) + res.raise_for_status() + except requests.RequestException as e: + logger.error("Workflow dispatch failed: %s", e) + raise requests.RequestException(f"Workflow dispatch failed: {e}") from e + + +def trigger_workflow_and_wait( + repo_url, + workflow_filename, + token=None, + ref="main", + timeout_seconds=600, + poll_interval_seconds=5, +): + """ + :param repo_url: GitHub repository link + :param workflow_filename: Filename of the workflow in .github/workflows (e.g. "hello-world.yml") + :param token: GitHub access token with repo permissions (optional, will use env variable if not provided) + :param ref: Git ref (branch or tag) to run the workflow on + :param timeout_seconds: Maximum time to wait for workflow completion + :param poll_interval_seconds: Time to wait between polling for workflow status + """ + + token = _get_github_token(token) + owner, repo = _parse_owner_repo(repo_url) + started_at = datetime.now(timezone.utc) + + logger.info( + "[trigger_workflow_and_wait] - Trigger workflow and wait for '%s' on ref '%s' for %s/%s", + workflow_filename, + ref, + owner, + repo, + ) + + trigger_workflow(repo_url, workflow_filename, token=token, ref=ref) + + runs_url = ( + f"https://api.github.com/repos/{owner}/{repo}/actions/workflows/" + f"{workflow_filename}/runs" + ) + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + } + + deadline = time.time() + timeout_seconds + run_id = None + + while time.time() < deadline: + try: + res = requests.get(runs_url, headers=headers, timeout=10) + res.raise_for_status() + data = res.json() + except requests.RequestException as e: + logger.error("Failed to list workflow runs: %s", e) + raise requests.RequestException(f"Workflow dispatch failed: {e}") from e + + for run in data.get("workflow_runs", []): + created_at = _parse_github_datetime(run.get("created_at")) + if not created_at: + continue + if ( + run.get("event") == "workflow_dispatch" + and run.get("head_branch") == ref + and created_at >= started_at + ): + run_id = run.get("id") + status = run.get("status") + conclusion = run.get("conclusion") + + if status == "completed": + if conclusion == "success": + logger.info( + "Workflow '%s' completed successfully for %s/%s.", + workflow_filename, + owner, + repo, + ) + return + raise RuntimeError( + f"Workflow '{workflow_filename}' interrupted: {conclusion}" + ) + break + + if run_id is None: + logger.info("Waiting for workflow run to start...") + + time.sleep(poll_interval_seconds) + + raise TimeoutError( + f"Workflow '{workflow_filename}' did not complete within {timeout_seconds}s" + ) + + +def _parse_owner_repo(repo_url): + """ + Retrieve owner and repository name from a github repository url + + :param repo_url: Description + :return: Return a tuple of (owner name, repository name) + """ + logger.info( + "[_parse_owner_repo] - Retrieve owner and repository name from a github repository url" + ) + + parsed = urlparse(repo_url) + path = parsed.path.lstrip("/") + + if path.endswith(".git"): + path = path[:-4] + + parts = path.split("/") + + owner = parts[0] + repo = parts[1] + + if len(parts) >= 2: + return owner, repo + else: + raise ValueError(f"Invalid repository URL: '{repo_url}'") + + +def _get_github_token(token): + """ + Return the provided GitHub access token or fall back to the environment variable. + + :param token: GitHub access token (optional) + :return: GitHub access token + :raises ValueError: If no token is provided and none is set in the environment + """ + logger.info("[_get_github_token] - Retrieve github access token") + + resolved = token or GITHUB_ACCESS_TOKEN + if not resolved: + raise ValueError( + "No GitHub access token provided and GITHUB_ACCESS_TOKEN is not set." + ) + return resolved + + +def _parse_github_datetime(value): + """ + Parse a GitHub datetime string into a timezone-aware datetime object. + GitHub datetime strings are in ISO 8601 format: "YYYY-MM-DDTHH:MM:SSZ" + + :param value: GitHub datetime string + :return: Timezone-aware datetime object or None if parsing fails + """ + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except (ValueError, AttributeError): + logger.error("Failed to parse GitHub datetime value: '%s'", value) + return None diff --git a/jdhapi/utils/gitup_repository.py b/jdhapi/utils/github_repository.py similarity index 100% rename from jdhapi/utils/gitup_repository.py rename to jdhapi/utils/github_repository.py diff --git a/jdhapi/views/logger.py b/jdhapi/utils/logger.py similarity index 100% rename from jdhapi/views/logger.py rename to jdhapi/utils/logger.py diff --git a/jdhapi/utils/ojs.py b/jdhapi/utils/ojs.py new file mode 100644 index 0000000..cf451c1 --- /dev/null +++ b/jdhapi/utils/ojs.py @@ -0,0 +1,166 @@ +import marko +import requests +from django.conf import settings +from django.template.loader import render_to_string +from jdh.validation import JSONSchema +from jdhapi.models import Article +from lxml import html +from weasyprint import HTML + +from .logger import logger as get_logger + +logger = get_logger() +article_to_ojs_schema = JSONSchema(filepath="article_to_ojs.json") +headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {settings.OJS_API_KEY_TOKEN}", +} +OJS_API_URL = settings.OJS_API_URL + + +def create_blank_submission(): + """ + Create a blank submission in OJS to get the submission_id for the article + + Returns: Response object from the OJS API with the submission_id in the response body + """ + logger.info("creating a blank submission in OJS") + + url = f"{OJS_API_URL}/submissions" + payload = {"commentsForTheEditors": "none", "locale": "en", "sectionId": 1} + res = requests.post(url=url, headers=headers, json=payload) + + return res + + +def upload_manuscript_to_ojs(pid, submission_id, pdf_bytes): + """ + Upload the pdf with the list of links to give access to the article (GitHub repository, Binder and JDH viewer) + + :param pid: The article pid + :param submission_id: The OJS submission id to which the manuscript will be uploaded + :param pdf_bytes: The pdf file in bytes to be uploaded to OJS + """ + logger.info("uploading manuscript to OJS") + + url = f"{OJS_API_URL}/submissions/{submission_id}/files" + headers_form_data = {"Authorization": f"Bearer {settings.OJS_API_KEY_TOKEN}"} + files = { + "file": (f"peer_review_{pid}.pdf", pdf_bytes, "application/pdf"), + } + data = { + "fileStage": 2, # 2 is for stage SUBMISSION_FILE_SUBMISSION + "genreId": 1, # 1 is for manuscript + } + + res = requests.post(url=url, headers=headers_form_data, files=files, data=data) + + return res + + +def create_contributor_in_ojs(submission_id, publication_id, article: Article): + """ + Create a contributor in OJS + + :param submission_id: The OJS submission id to which the contributor will be added + :param publication_id: The OJS publication id to which the contributor will be added + :param article: The article object + """ + logger.info("creating the article contributor in OJS") + + primary_contact_id = 0 + + url = f"{settings.OJS_API_URL}/submissions/{submission_id}/publications/{publication_id}/contributors" + + for author in article.abstract.authors.all(): + logger.info(f"Contributor {author.firstname} {author.lastname} creation in OJS") + payload = { + "affiliation": {"en": author.affiliation}, + "country": str(author.country), + "email": author.email, + "familyName": {"en": author.lastname}, + "fullName": f"{author.firstname} {author.lastname}", + "givenName": {"en": author.firstname}, + "includeInBrowse": True, + "locale": "en", + "orcid": author.orcid, + "preferredPublicName": {"en": ""}, + "publicationId": publication_id, + "seq": 0, + "userGroupId": 14, + "userGroupName": {"en": "Author"}, + } + res = requests.post(url=url, headers=headers, json=payload) + logger.info( + f"Contributor {author.firstname} {author.lastname} created with response: {res.json()}" + ) + + if article.abstract.contact_email == author.email: + primary_contact_id = res.json().get("id", 0) + + return primary_contact_id + + +def assign_primary_contact_and_metadata( + submission_id, publication_id, contributor_id, article +): + """ + Assign the primary contact to the submission and add title, abstract and competing interests + + :param submission_id: The OJS submission id to which the contributor will be added + :param publication_id: The OJS publication id to which the contributor will be added + :param contributor_id: The OJS contributor id to be assigned as primary contact + :param article: The article object + """ + logger.info( + "Assign the author as primary contact to the submission and add title, abstract and competingInterests" + ) + + url = f"{OJS_API_URL}/submissions/{submission_id}/publications/{publication_id}" + payload = { + "primaryContactId": contributor_id, + "title": {"en": article.abstract.title}, + "abstract": {"en": article.abstract.abstract}, + "competingInterests": {"en": "I declare that I have no competing interests"}, + } + + res = requests.put(url=url, headers=headers, json=payload) + + return res + + +def submit_submission_to_ojs(submission_id): + """ + Finalize the submission in OJS to move it from Incomplete stage to Submission stage + + :param submission_id: The OJS submission id to which the contributor will be added + """ + logger.info("Submit the article to OJS") + + url = f"{OJS_API_URL}/submissions/{submission_id}/submit" + payload = {"confirmCopyright": "true"} + + res = requests.put(url, headers=headers, json=payload) + + return res + + +def generate_pdf_for_submission(article): + """ + Generate a PDF file with a list of links to give access to the article (GitHub repository, Binder and JDH viewer) + + :param article: The article object + """ + template = "jdhseo/peer_review.html" + if "title" in article.data: + articleTitle = html.fromstring( + marko.convert(article.abstract.title) + ).text_content() + context = {"article": article, "articleTitle": articleTitle} + html_string = render_to_string(template, context) + + # Generate the PDF + pdf_file = HTML(string=html_string).write_pdf() + + logger.info("Pdf generated") + return pdf_file diff --git a/jdhapi/views/__init__.py b/jdhapi/views/__init__.py index 3764a6c..c2e1e26 100644 --- a/jdhapi/views/__init__.py +++ b/jdhapi/views/__init__.py @@ -1,4 +1,5 @@ from .abstracts import * +from .altcha import * from .articles import * from .authors import * from .callforpapers import * @@ -9,7 +10,6 @@ from .check_github_id import check_github_id from .api_root import api_root from .api_me import api_me -from .logger import logger from .login import CustomLoginView from .logout import custom_logout from .csrf_token import get_csrf diff --git a/jdhapi/views/abstracts/submit_abstract.py b/jdhapi/views/abstracts/submit_abstract.py index 3f2c933..320d71b 100644 --- a/jdhapi/views/abstracts/submit_abstract.py +++ b/jdhapi/views/abstracts/submit_abstract.py @@ -1,21 +1,22 @@ +from textwrap import dedent + from django.core.mail import send_mail from django.db import transaction from jdh.validation import JSONSchema -from jdhapi.models import Abstract, Author, Dataset, CallForPaper -from jdhapi.serializers import AbstractSlimSerializer -from jsonschema.exceptions import ValidationError, SchemaError +from jsonschema.exceptions import SchemaError, ValidationError +from rest_framework import status from rest_framework.decorators import ( api_view, - permission_classes, authentication_classes, + permission_classes, ) -from rest_framework.response import Response -from rest_framework import status from rest_framework.permissions import AllowAny -from textwrap import dedent - -from ..logger import logger as get_logger +from rest_framework.response import Response +from jdhapi.models import Abstract, Author, CallForPaper, Dataset +from jdhapi.serializers import AbstractSlimSerializer +from jdhapi.utils.altcha import verify_challenge_solution +from jdhapi.utils.logger import logger as get_logger logger = get_logger() @@ -28,10 +29,16 @@ def get_default_body(id, title, firstname, lastname): Dear {firstname} {lastname}, Thank you for submitting your abstract {title} (ID: {id}) to the Journal of Digital History (JDH). - The JDH publishes data-driven research articles, and we require authors to adhere to specific writing guidelines. These include collaboration via GitHub, the use of Jupyter Notebooks, and the writing of code using R or Python. Please refer to the following link for detailed instructions on our submission guidelines and for setting up the required writing environment on your machine: https://journalofdigitalhistory.org/en/guidelines. - If you require assistance with installing the necessary software or encounter any questions about the writing process, please do not hesitate to contact us at jdh.admin@uni.lu. We will be happy to support you. + The JDH publishes data-driven research articles, and we require authors to adhere to specific writing + guidelines. These include collaboration via GitHub, the use of Jupyter Notebooks, and the writing of code + using R or Python. Please refer to the following link for detailed instructions on our submission guidelines + and for setting up the required writing environment on your machine: + https://journalofdigitalhistory.org/en/guidelines. + If you require assistance with installing the necessary software or encounter any questions about the writing + process, please do not hesitate to contact us at jdh.admin@uni.lu. We will be happy to support you. - Regarding the next steps, we will contact you to propose a few dates to discuss the principle of multilayered articles. + Regarding the next steps, we will contact you to propose a few dates to discuss the principle of multilayered + articles. Kind regards, The JDH Team @@ -59,9 +66,18 @@ def send_mail_abstract_received(pid, subject, sent_to, firstname, lastname): @authentication_classes([]) @permission_classes([AllowAny]) def submit_abstract(request): + """ + POST /api/abstracts/submit + + Create an abstract submission. + The endpoint is public. + Requires a valid captcha solution only. + """ + try: data = validate_and_submit_abstract(request) return Response(data, status=status.HTTP_201_CREATED) + except ValidationError as e: logger.exception("Validation error occurred.") response = Response( @@ -97,6 +113,21 @@ def submit_abstract(request): def validate_and_submit_abstract(request): + logger.info("Start captcha validation") + payload = request.data.get("altcha") + + if not payload: + raise ValidationError("Altcha payload missing") + + try: + verified, err = verify_challenge_solution(payload) + + if not verified: + raise ValidationError(f"Invalid Altcha payload: {err}") + + except ValidationError as e: + raise ValidationError(f"Failed to process Altcha payload: {str(e)}") + logger.info("Start JSON validation") with transaction.atomic(): # Single transaction block for the entire function @@ -173,6 +204,7 @@ def validate_and_submit_abstract(request): "github_id": author.get("githubId", ""), "bluesky_id": author.get("blueskyId", ""), "facebook_id": author.get("facebookId", ""), + "linkedin_id": author.get("linkedinId", ""), }, ) abstract.authors.add(author_instance) diff --git a/jdhapi/views/abstracts/update_abstract.py b/jdhapi/views/abstracts/update_abstract.py index b398b9b..a767be3 100644 --- a/jdhapi/views/abstracts/update_abstract.py +++ b/jdhapi/views/abstracts/update_abstract.py @@ -3,6 +3,7 @@ from django.shortcuts import get_object_or_404 from jdhapi.models import Abstract from jdhapi.forms import EmailConfigurationForm +from jdhapi.utils.logger import logger as get_logger from jdh.validation import JSONSchema from jsonschema.exceptions import ValidationError, SchemaError from rest_framework.decorators import ( @@ -12,7 +13,6 @@ from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework import status -from ..logger import logger as get_logger logger = get_logger() diff --git a/jdhapi/views/altcha.py b/jdhapi/views/altcha.py new file mode 100644 index 0000000..085d507 --- /dev/null +++ b/jdhapi/views/altcha.py @@ -0,0 +1,37 @@ +from jdhapi.utils.altcha import create_captcha_challenge +from jdhapi.utils.logger import logger as get_logger +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.response import Response + + +logger=get_logger() + +@api_view(["GET"]) +def get_captcha_challenge(request): + + """ + GET /api/captcha + + Endpoint to send a captcha challenge. + """ + + try: + challenge = create_captcha_challenge() + logger.info(f"GET /api/captcha Captcha send") + return Response(challenge, status=status.HTTP_200_OK) + + + except Exception as e : + logger.error(f"Unexpected error when sending captcha challenge:{e}") + return Response( + { + "error": "InternalError", + "message": "An unexpected error occurred. Please try again later.", + "details": str(e), + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + + diff --git a/jdhapi/views/api_root.py b/jdhapi/views/api_root.py index e2b68ef..de7cc77 100644 --- a/jdhapi/views/api_root.py +++ b/jdhapi/views/api_root.py @@ -15,6 +15,7 @@ def api_root(request, format=None): "abstract-detail": reverse("abstract-detail", args=["example-pid"], request=request, format=format), "articles": reverse("article-list", request=request, format=format), "article-detail": reverse("article-detail", args=["example-pid"], request=request, format=format), + "article-status": reverse("article-status", args=["example-pid"], request=request, format=format), "issues": reverse("issue-list", request=request, format=format), "issue-detail": reverse("issue-detail", args=["example-pid"], request=request, format=format), "issue-articles": reverse("issue-articles-list", args=["example-pid"], request=request, format=format), diff --git a/jdhapi/views/articles/__init__.py b/jdhapi/views/articles/__init__.py index aad7891..6a28d02 100644 --- a/jdhapi/views/articles/__init__.py +++ b/jdhapi/views/articles/__init__.py @@ -1,4 +1,6 @@ +from .advance_article import * from .articles import * +from .ojs import * from .social_media import * from .update_article import * -from .advance_article import * +from .copy_editing import * diff --git a/jdhapi/views/articles/articles.py b/jdhapi/views/articles/articles.py index 1c1f495..f21e74e 100644 --- a/jdhapi/views/articles/articles.py +++ b/jdhapi/views/articles/articles.py @@ -3,7 +3,12 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import generics, filters from rest_framework.permissions import BasePermission - +from django.shortcuts import get_object_or_404 +from jdhapi.views.articles.status_handlers import * +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.permissions import IsAdminUser class IsOwnerFilterBackend(filters.BaseFilterBackend): """ @@ -82,3 +87,23 @@ class ArticleDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Article.objects.all() serializer_class = ArticleSerializer lookup_field = "abstract__pid" + + + +class ArticleStatus(APIView): + permission_classes = [IsAdminUser] + STATUS_HANDLERS = { + 'TECHNICAL_REVIEW': TechnicalReviewHandler(), + 'COPY_EDITING': CopyEditingHandler(), + 'PEER_REVIEW': PeerReviewHandler(), + 'PUBLISHED' : PublishedHandler() + } + + def patch(self, request, abstract__pid): + article = get_object_or_404(Article, abstract__pid=abstract__pid) + new_status = request.data.get('status') + + handler = self.STATUS_HANDLERS.get(new_status) + if handler: + return handler.handle(article, request) + return Response({"error": "Invalid status"}, status=400) \ No newline at end of file diff --git a/jdhapi/views/articles/copy_editing.py b/jdhapi/views/articles/copy_editing.py new file mode 100644 index 0000000..e9dff18 --- /dev/null +++ b/jdhapi/views/articles/copy_editing.py @@ -0,0 +1,209 @@ +import logging +import requests +from django.conf import settings +from django.core.mail import EmailMessage +from django.http import HttpResponse +from jdhapi.models import Article +from jdhapi.utils.github_action import trigger_workflow_and_wait +from rest_framework.decorators import ( + api_view, + permission_classes, +) +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response + +logger = logging.getLogger(__name__) + +COPY_EDITOR_ADDRESS = settings.COPY_EDITOR_ADDRESS + +@api_view(["GET"]) +@permission_classes([IsAdminUser]) +def get_docx(request): + """ + GET api/articles/docx + + Helper function to get the docx file from the request. + Needs a pid in the request query parameters. + """ + logger.info("GET api/articles/docx") + + branch_name = "pandoc" + pid = request.GET.get("pid") + + if not pid: + return Response({"error": "Article PID is required."}, status=400) + + try: + workflow_error = ensure_pandoc_workflow(pid) + if workflow_error: + return workflow_error + + docx_bytes = fetch_docx_bytes(pid, branch_name) + return HttpResponse( + docx_bytes, + content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + headers={"Content-Disposition": f'attachment; filename="article_{pid}.docx"'}, + status=200 + ) + except FileNotFoundError as e: + return Response({"error": str(e)}, status=404) + except ValueError as e: + return Response({"error": str(e)}, status=502) + except requests.exceptions.RequestException as e: + return Response( + {"error": "Failed to get article.docx", "details": str(e)}, status=500 + ) + + +@api_view(["POST"]) +@permission_classes([IsAdminUser]) +def send_docx_email(request): + """ + POST api/articles/docx/email + + Send the docx as an email attachment. + :params pid: the article PID + :params body: the email body to send to copy editor + :params branch_name: the branch name where the docx file is located, by default "pandoc" + """ + logger.info("POST api/articles/docx/email") + + branch_name = "pandoc" + pid = request.data.get("pid") + body= request.data.get("body") + + if not pid: + return Response({"error": "Article PID is required."}, status=400) + + try: + workflow_error = ensure_pandoc_workflow(pid) + if workflow_error: + return workflow_error + + docx_bytes = fetch_docx_bytes(pid, branch_name) + send_email_copy_editor(pid, docx_bytes, body) + return Response({"message": f"Docx sent succesfully by email for article : {pid}"}, status=200) + + except FileNotFoundError as e: + return Response({"error": str(e)}, status=404) + except ValueError as e: + return Response({"error": str(e)}, status=502) + except requests.exceptions.RequestException as e: + return Response( + {"error": "Failed to get article.docx", "details": str(e)}, status=500 + ) + except Exception as e: + return Response({"error": "Failed to send email", "details": str(e)}, status=502) + + +def fetch_docx_bytes(pid, branch_name): + """ + Helper function to fetch the docx + :params pid: the article PID + :params branch_name: the branch name where the docx file is located + """ + logger.info("[fetch_docx_bytes] Fetch the docx document for the article with PID '%s'", pid) + + url = f"https://api.github.com/repos/jdh-observer/{pid}/contents/article.docx?ref={branch_name}" + headers = {"Authorization": f"Bearer {settings.GITHUB_ACCESS_TOKEN}"} + + response = requests.get(url, headers=headers) + + if response.status_code == 200: + data = response.json() + download_url = data.get("download_url") + + if not download_url: + raise ValueError("Download URL not available for the file.") + + file_response = requests.get(download_url) + file_response.raise_for_status() + + return file_response.content + + if response.status_code == 404: + raise FileNotFoundError(f"article.docx file not found for article ID '{pid}'.") + + raise ValueError("Unexpected error occurred while contacting GitHub API.") + + +def send_email_copy_editor(pid, docx_bytes, body): + """ + Helper function to send the email to copy editing editor + :params pid: the article PID + :params docx_bytes: the content of the docx file in bytes + """ + logger.info("[run_pandoc_workflow] Running pandoc workflow for PID '%s'", pid) + + filename = f"article_{pid}.docx" + message = EmailMessage( + subject="Article to review for copy editing", + body=body, + from_email="jdh.admin@uni.lu", + to=[COPY_EDITOR_ADDRESS], + ) + message.attach( + filename, + docx_bytes, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + message.send(fail_silently=False) + + +def run_pandoc_workflow(repository_url): + """ + Helper function to run the pandoc workflow will be executed + :params repository_url: the article GitHub repository URL + """ + logger.info("[run_pandoc_workflow] Running pandoc workflow for this repository: '%s'", repository_url) + + try: + logger.debug( + "run_pandoc_workflow wait repo=%s", + repository_url, + ) + trigger_workflow_and_wait( + repository_url, + workflow_filename="pandoc.yml", + ) + logger.debug("Pandoc workflow completed repo=%s", repository_url) + except Exception as e: + logger.error("run_pandoc_workflow failed: %s", e) + return Response( + {"error": "Failed to run pandoc workflow", "details": str(e)}, + status=502, + ) + + +def ensure_pandoc_workflow(pid): + """ + Helper function to ensure the pandoc workflow will be executed + :params pid: the article PID + """ + logger.info("[ensure_pandoc_workflow] Starting pandoc workflow for arrticle with PID : '%s'", pid) + + try: + article = Article.objects.get(abstract__pid=pid) + except Article.DoesNotExist: + return Response( + {"error": f"Article not found for PID '{pid}'."}, status=404 + ) + + if not article.repository_url: + return Response( + {"error": f"repository_url is missing for PID '{pid}'."}, + status=400, + ) + try: + logger.debug( + "Run pandoc workflow and wait for completion pid=%s, repo=%s", + pid, + article.repository_url, + ) + run_pandoc_workflow(article.repository_url) + logger.debug("Pandoc workflow completed for pid=%s", pid) + except Exception as e: + return Response( + {"error": "Failed to run pandoc workflow", "details": str(e)}, + status=502, + ) \ No newline at end of file diff --git a/jdhapi/views/articles/ojs.py b/jdhapi/views/articles/ojs.py new file mode 100644 index 0000000..8849175 --- /dev/null +++ b/jdhapi/views/articles/ojs.py @@ -0,0 +1,218 @@ +import requests +from django.conf import settings +from django.db import transaction +from jdh.validation import JSONSchema +from jdhseo.utils import get_country_with_ROR +from jsonschema.exceptions import ValidationError +from rest_framework import status +from rest_framework.decorators import ( + api_view, + permission_classes, +) +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response + +from jdhapi.models import Article +from jdhapi.utils.logger import logger as get_logger +from jdhapi.utils.ojs import ( + assign_primary_contact_and_metadata, + create_blank_submission, + create_contributor_in_ojs, + generate_pdf_for_submission, + upload_manuscript_to_ojs, +) + +logger = get_logger() +article_to_ojs_schema = JSONSchema(filepath="article_to_ojs.json") +headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {settings.OJS_API_KEY_TOKEN}' +} +OJS_API_URL = settings.OJS_API_URL + +@api_view(["GET"]) +@permission_classes([IsAdminUser]) +def get_count_submission_from_ojs(_): + """ + GET /api/articles/ojs/submissions + + Get the list of all abstracts submitted to OJS ans being either in 'Incomplete' submission stage or 'Submission' stage. + Requires admin permissions. + """ + + logger.info("GET /api/articles/ojs/submissions") + + url = f"{OJS_API_URL}/submissions?stageIds=1" + + try: + response = requests.get(url, headers=headers) + if response.status_code == 200: + return Response({"count": response.json().get("itemsMax", 0)}, status=200) + else: + return Response( + { + "error": "Unexpected error occurred while contacting OJS API.", + "status_code": response.status_code, + }, + status=response.status_code, + ) + except requests.exceptions.RequestException as e: + return Response( + {"error": "Failed to connect to OJS API.", "details": str(e)}, status=500 + ) + + +@api_view(["POST"]) +@permission_classes([IsAdminUser]) +def send_article_to_ojs(request): + """ + POST /api/articles/ojs/submission + + Endpoint to create an article submission ready for peer review to OJS. + Requires admin permissions. + """ + + logger.info("POST /api/articles/ojs/submission") + + try: + res = submit_to_ojs(request) + return Response( + {"message": "Article send successfully to OJS.", "data": res}, + status=status.HTTP_200_OK, + ) + except ValidationError as e: + logger.error(f"JSON schema validation failed: {str(e)}") + return Response( + {"error": "Invalid data format", "details": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + return Response( + { + "error": "InternalError", + "message": "An unexpected error occurred. Please try again later.", + "details": str(e), + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + content_type="application/json", + ) + + +def submit_to_ojs(request): + + logger.info('Submitting article to OJS') + + try: + + with transaction.atomic(): + + article_to_ojs_schema.validate(instance=request.data) + + pid = request.data.get("pid", None) + + logger.info("Retrieve article according to the PID.") + + if not pid: + logger.error("No PID provided in request data.") + raise ValidationError("One article PID is required.") + + article = Article.objects.get(abstract__pid=pid) + + if article is None: + logger.error(f"No article found for PID : {pid}.") + raise Exception("Article not found.") + + logger.info("Send article to OJS.") + + submission_id = 0 + publication_id = 0 + contributor_id = 0 + is_one_contributor_primary_contact = False + + required_fields = { + 'affiliation': 'affiliation', + 'country': 'country', + 'email': 'email', + 'lastname': 'lastname', + 'firstname': 'firstname', + 'orcid': 'orcid', + } + + for author in article.abstract.authors.all(): + for field, fieldname in required_fields.items(): + if not getattr(author, field): + author_name = f"{author.firstname} {author.lastname}" + error_msg = f"Author {fieldname} is missing. Author concerned : {author_name}" + logger.error(error_msg) + if fieldname == 'country': + country = get_country_with_ROR(affiliation_name=author.affiliation) + if not country: + raise ValidationError(error_msg) + else: + author.country = country + author.save() + + for author in article.abstract.authors.all(): + if article.abstract.contact_email == author.email: + is_one_contributor_primary_contact = True + break + + if not is_one_contributor_primary_contact: + error_msg = "No primary contact identified among authors." + logger.error(error_msg) + raise ValidationError(error_msg) + + if not article.data.get('title'): + error_msg = "Field 'title' is missing in the 'data' field of the article." + logger.error(error_msg) + raise ValidationError(error_msg) + + try: + # 1. create a blank submission in OJS + res = create_blank_submission() + logger.info(f"Blank submission created with response: {res.json()}") + + submission_id = res.json().get('id', 0) + publication_id = res.json().get('currentPublicationId', 0) + + article.ojs_submission_id = submission_id + article.save() + + # 2. upload the pdf file to OJS + pdf_file = generate_pdf_for_submission(article) + res = upload_manuscript_to_ojs(article.abstract.pid, submission_id, pdf_file) + + if res.status_code not in [200, 201]: + error_msg = f"Failed to upload manuscript to OJS. Status: {res.status_code}, Response: {res.text}" + logger.error(error_msg) + raise Exception(error_msg) + + logger.info(f"Manuscript uploaded with response: {res.json()}") + + # 3. Create the article contributor in OJS + primary_contact_id = create_contributor_in_ojs(submission_id, publication_id, article) + + if not primary_contact_id: + raise Exception("Failed to create contributor or retrieve primary contact ID") + + contributor_id = primary_contact_id + + # 4. Assign the author as primary Contact to the submission + title + abstract + competingInterests + res = assign_primary_contact_and_metadata(submission_id, publication_id, contributor_id, article) + + if res.status_code not in [200, 201]: + error_msg = f"Failed to assign metadata. Status: {res.status_code}, Response: {res.text}" + logger.error(error_msg) + raise Exception(error_msg) + + # 5. Submit the submission to OJS + # uncomment if we do not need human check on OJS dashboard anymore + # submit_to_ojs(submission_id) + + except Exception as e: + logger.error(f"Error during OJS submission process: {e}") + raise e + + except Exception as e: + logger.error(f"Transaction failed: {e}") + raise e diff --git a/jdhapi/views/articles/status_handlers.py b/jdhapi/views/articles/status_handlers.py new file mode 100644 index 0000000..58f572b --- /dev/null +++ b/jdhapi/views/articles/status_handlers.py @@ -0,0 +1,71 @@ +import logging +from django.utils import timezone +from jdhapi.views.articles.copy_editing import send_docx_email +from jdhapi.utils.articles import save_citation +from rest_framework import status +from rest_framework.response import Response + +logger = logging.getLogger(__name__) + +class StatusHandler: + def handle(self, article, request): + raise NotImplementedError + +class TechnicalReviewHandler(StatusHandler): + def handle(self, article, request): + logger.info("Setting status TECHNICAL_REVIEW pid=%s", article.abstract.pid) + article.status = article.Status.TECHNICAL_REVIEW + article.save() + return Response({"status": "TECHNICAL_REVIEW set", "article pid": article.abstract.pid}) + +class CopyEditingHandler(StatusHandler): + def handle(self, article, request): + logger.info("Starting COPY_EDITING flow pid=%s", article.abstract.pid) + + article.status = article.Status.COPY_EDITING + article.save() + logger.info("Set status COPY_EDITING pid=%s", article.abstract.pid) + return Response({"status": "COPY_EDITING set", "article pid": article.abstract.pid}) + + +class PeerReviewHandler(StatusHandler): + def handle(self, article, request): + logger.info("Setting status PEER_REVIEW pid=%s", article.abstract.pid) + article.status = article.Status.PEER_REVIEW + article.save() + return Response({"status": "PEER_REVIEW set", "article pid": article.abstract.pid}) + + +class PublishedHandler(StatusHandler): + def handle(self, article, request): + logger.info("Setting status PUBLISHED pid=%s", article.abstract.pid) + # control on the DOI field mandatory + if not article.doi: + return Response( + {"error": "Doi is mandatory if published"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # quick synchronous validation before scheduling + article_data = article.data if isinstance(article.data, dict) else {} + if not article_data.get("title"): + return Response( + {"error": "Article data title is mandatory if published"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # run save_citation synchronously; publish only on success + try: + save_citation(article_id=article.pk) + except Exception as exc: + logger.exception("save_citation failed pid=%s", article.abstract.pid) + return Response( + {"error": "save_citation failed", "details": str(exc)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # set the publication_date to now + article.publication_date = timezone.now() + article.status = article.Status.PUBLISHED + article.save() + return Response({"status": "PUBLISHED set", "article pid": article.abstract.pid}) + \ No newline at end of file diff --git a/jdhapi/views/articles/update_article.py b/jdhapi/views/articles/update_article.py index 68af720..5f1850b 100644 --- a/jdhapi/views/articles/update_article.py +++ b/jdhapi/views/articles/update_article.py @@ -1,7 +1,8 @@ +from django.db import transaction from jdh.validation import JSONSchema from jdhapi.models import Article -from django.db import transaction -from jsonschema.exceptions import ValidationError, SchemaError +from jdhapi.utils.logger import logger as get_logger +from jsonschema.exceptions import ValidationError from rest_framework.decorators import ( api_view, permission_classes, @@ -10,8 +11,6 @@ from rest_framework.response import Response from rest_framework import status -from ..logger import logger as get_logger - logger = get_logger() article_status_schema = JSONSchema(filepath="article_status.json") diff --git a/jdhseo/utils.py b/jdhseo/utils.py index 4efee52..57a9158 100644 --- a/jdhseo/utils.py +++ b/jdhseo/utils.py @@ -287,6 +287,63 @@ def get_affiliation(orcid): logger.error(f"Other error occurred: {err}") +def get_country_with_ROR(affiliation_name: str) -> str: + """ + Get country code from affiliation name using ROR API search. + + Args: + affiliation_name (str): The name of the institution (e.g., "MIT", "Harvard University") + + Returns: + str: Two-letter country code (e.g., 'US', 'FR', 'GB') or None if not found + """ + logger.debug(f"START get_country_with_ROR - searching for: {affiliation_name}") + ROR_API_SEARCH_URL = "https://api.ror.org/organizations" + + if not affiliation_name or affiliation_name.strip() == "": + logger.warning("Empty affiliation name provided") + return None + + try: + params = { + "query": affiliation_name.strip() + } + resp = requests.get(ROR_API_SEARCH_URL, params=params, timeout=10) + resp.raise_for_status() + json_response = resp.json() + items = json_response.get('items', []) + + if not items: + logger.warning(f"No results found for affiliation: {affiliation_name}") + return None + + # Take the first (most relevant) result + first_result = items[0] + + # Extract country code + country_code = first_result.get('locations', {})[0].get('geonames_details',{}).get('country_code', None) + + if country_code: + org_name = first_result.get('names', 'Unknown')[0].get('value', 'Unknown') + logger.debug(f"Country code found for '{org_name}': {country_code}") + return country_code + else: + logger.warning(f"No country code found in ROR result for: {affiliation_name}") + return None + + except HTTPError as http_err: + logger.error(f"HTTP error occurred while searching ROR: {http_err}") + return None + except requests.exceptions.Timeout: + logger.error(f"Timeout while searching ROR for: {affiliation_name}") + return None + except Exception as err: + logger.error(f"Error occurred while searching ROR: {err}") + return None + finally: + logger.debug("END get_country_with_ROR") + + def get_employment_affiliation(orcid, api_url, headers): url = f"{api_url}/{orcid}/employments" resp = requests.get(url, headers=headers) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d40d8b4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[tool.ruff] +line-length = 88 +select = ["F", "E", "I", "RUF"] +fix = true diff --git a/requirements.txt b/requirements.txt index 0fc7880..2201a18 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,10 @@ -i https://pypi.org/simple +altcha==1.0.0 amqp==5.3.1 +annotated-types==0.7.0 anyio==4.8.0 appnope==0.1.4 -APScheduler==3.11.0 +apscheduler==3.11.0 argon2-cffi==23.1.0 argon2-cffi-bindings==21.2.0 arrow==1.3.0 @@ -28,12 +30,14 @@ click-plugins==1.1.1 click-repl==0.3.0 comm==0.2.2 crispy-bootstrap4==2024.10 +cryptography==45.0.5 cssselect2==0.7.0 debugpy==1.8.12 decorator==5.1.1 defusedxml==0.7.1 diff-match-patch==20241021 django==5.1.6 +django-cors-headers==4.9.0 django-countries==7.6.1 django-crispy-forms==2.3 django-filter==24.3 @@ -41,6 +45,7 @@ django-import-export==4.3.5 django-ipware==7.0.1 django-model-utils==5.0.0 djangorestframework==3.15.2 +dnspython==2.7.0 drf-recaptcha==4.0.2 executing==2.2.0 fastjsonschema==2.21.1 @@ -76,6 +81,7 @@ jupyterlab==4.3.5 jupyterlab-pygments==0.3.0 jupyterlab-server==2.27.3 kombu==5.4.2 +libipld==3.1.1 lxml==5.3.1 marko==2.1.2 markupsafe==3.0.2 @@ -104,6 +110,8 @@ pure-eval==0.2.3 pycodestyle==2.13.0 pycountry==24.6.1 pycparser==2.22 +pydantic==2.11.7 +pydantic-core==2.33.2 pydyf==0.11.0 pyflakes==3.3.2 pygments==2.19.1 @@ -138,7 +146,9 @@ tornado==6.4.2 traitlets==5.14.3 types-python-dateutil==2.9.0.20241206 typing-extensions==4.12.2 +typing-inspection==0.4.1 tzdata==2025.1 +tzlocal==5.3.1 uri-template==1.3.0 urllib3==2.3.0 vine==5.1.0 @@ -147,4 +157,5 @@ weasyprint==64.0 webcolors==24.11.1 webencodings==0.5.1 websocket-client==1.8.0 +websockets==13.1 zopfli==0.2.3.post1 diff --git a/schema/article_to_ojs.json b/schema/article_to_ojs.json new file mode 100644 index 0000000..0fc477c --- /dev/null +++ b/schema/article_to_ojs.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://journaldigitalhistory.eu/schemas/article-to-ojs.json", + "type": "object", + "properties": { + "pid": { + "type": "string" + } + }, + "required": ["pid"], + "additionalProperties": false +} diff --git a/schema/submit_abstract.json b/schema/submit_abstract.json index d2b9299..6a009a5 100644 --- a/schema/submit_abstract.json +++ b/schema/submit_abstract.json @@ -68,6 +68,18 @@ { "type": "string", "maxLength": 0 } ] }, + "linkedinId": { + "anyOf": [ + { + "type": "string", + "minLength": 3, + "maxLength": 100, + "pattern": "^[a-zA-Z0-9-]{3,100}$" + }, + { "type": "null" }, + { "type": "string", "maxLength": 0 } + ] + }, "primaryContact": { "type": "boolean", "enum": [true, false] } }, "required": [ diff --git a/tests/__init__.py b/tests/__init__.py index ee821d6..52e5910 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -3,11 +3,12 @@ from .jdhapi.test_signals import * from .jdhapi.test_admin import * from .jdhapi.models.test_author import * -from .jdhapi.views.test_submit_abstract import * -from .jdhapi.views.test_check_github_id import * -from .jdhapi.views.test_search_abstract import * +from .jdhapi.views.abstracts.test_submit_abstract import * +from .jdhapi.views.abstracts.test_search_abstract import * from .jdhapi.views.abstracts.test_update_abstract import * from .jdhapi.views.articles.test_update_article import * from .jdhapi.views.articles.test_social_media import * +from .jdhapi.views.articles.test_ojs import * +from .jdhapi.views.test_check_github_id import * from .jdhapi.utils.test_bluesky import * -from .jdhapi.utils.test_facebook import * +from .jdhapi.utils.test_facebook import * \ No newline at end of file diff --git a/tests/jdhapi/views/test_search_abstract.py b/tests/jdhapi/views/abstracts/test_search_abstract.py similarity index 100% rename from tests/jdhapi/views/test_search_abstract.py rename to tests/jdhapi/views/abstracts/test_search_abstract.py diff --git a/tests/jdhapi/views/test_submit_abstract.py b/tests/jdhapi/views/abstracts/test_submit_abstract.py similarity index 74% rename from tests/jdhapi/views/test_submit_abstract.py rename to tests/jdhapi/views/abstracts/test_submit_abstract.py index f1867d7..6add007 100644 --- a/tests/jdhapi/views/test_submit_abstract.py +++ b/tests/jdhapi/views/abstracts/test_submit_abstract.py @@ -1,9 +1,11 @@ -from rest_framework.test import APITestCase -from rest_framework import status +from datetime import date +from unittest.mock import patch + from django.test import Client -from jdhapi.models import Abstract, Author, Dataset, CallForPaper from django.urls import reverse -from datetime import date +from jdhapi.models import Abstract, Author, CallForPaper, Dataset +from rest_framework import status +from rest_framework.test import APITestCase class SubmitAbstractTestCase(APITestCase): @@ -33,6 +35,7 @@ def setUp(self): "githubId": "janesmith", "blueskyId": "jane.bsky.social", "facebookId": "jane.smith", + "linkedinId": "jane-smith", "primaryContact": True, } ], @@ -46,6 +49,7 @@ def setUp(self): "dateLastModified": date.today(), "languagePreference": "Default", "termsAccepted": True, + "altcha": "fake-altcha-payload" } self.invalid_payload_missing_github_id = { @@ -66,6 +70,7 @@ def setUp(self): "githubId": "", "blueskyId": "jane.bsky.social", "facebookId": "jane.smith", + "linkedinId": "jane-smith", "primaryContact": True, } ], @@ -79,6 +84,7 @@ def setUp(self): "dateLastModified": date.today(), "languagePreference": "Default", "termsAccepted": True, + "altcha": "fake-altcha-payload" } self.invalid_payload = { @@ -87,8 +93,13 @@ def setUp(self): "contact": [], } - def test_submit_abstract_valid_payload(self): + @patch('jdhapi.views.abstracts.submit_abstract.verify_challenge_solution') + def test_submit_abstract_valid_payload(self, mock_captcha): """Test submitting an abstract with a valid payload.""" + + # Mock the captcha verification to return true + mock_captcha.return_value = True, None + url = reverse("submit-abstract") c = Client() response = c.post( @@ -102,8 +113,13 @@ def test_submit_abstract_valid_payload(self): self.assertEqual(Dataset.objects.count(), 1) self.assertEqual(response.data["title"], self.valid_payload["title"]) - def test_submit_abstract_invalid_payload(self): + @patch('jdhapi.views.abstracts.submit_abstract.verify_challenge_solution') + def test_submit_abstract_invalid_payload(self, mock_captcha): """Test submitting an abstract with an invalid payload.""" + + # Mock the captcha verification to return true + mock_captcha.return_value = True, None + url = reverse("submit-abstract") c = Client() response = c.post( @@ -117,15 +133,20 @@ def test_submit_abstract_invalid_payload(self): self.assertEqual(Author.objects.count(), 0) response = c.post( url, - self.invalid_payload_missing_github_id, + self.invalid_payload, content_type="application/json", ) - def test_submit_abstract_invalid_payload_missing_github_id(self): + @patch('jdhapi.views.abstracts.submit_abstract.verify_challenge_solution') + def test_submit_abstract_invalid_payload_missing_github_id(self, mock_captcha): """ Test submitting an abstract with an invalid payload (missing GitHub ID). """ + + # Mock the captcha verification to return true + mock_captcha.return_value = True, None + url = reverse("submit-abstract") c = Client() response = c.post( @@ -133,6 +154,7 @@ def test_submit_abstract_invalid_payload_missing_github_id(self): self.invalid_payload_missing_github_id, content_type="application/json", ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("error", response.data) self.assertEqual( @@ -143,8 +165,13 @@ def test_submit_abstract_invalid_payload_missing_github_id(self): self.assertEqual(Author.objects.count(), 0) self.assertEqual(Dataset.objects.count(), 0) - def test_update_existing_author(self): + @patch('jdhapi.views.abstracts.submit_abstract.verify_challenge_solution') + def test_update_existing_author(self, mock_captcha): """Test that an existing author's information is updated.""" + + # Mock the captcha verification to return true + mock_captcha.return_value = True, None + self.existing_author = Author.objects.create( orcid="https://orcid.org/0000-0000-0000-0000", lastname="Existing", @@ -154,6 +181,7 @@ def test_update_existing_author(self): github_id="existinggithub", bluesky_id="existing.bsky.social", facebook_id="existing.author", + linkedin_id="existing.author.linkedin", ) url = reverse("submit-abstract") @@ -173,3 +201,28 @@ def test_update_existing_author(self): self.assertEqual(updated_author.github_id, "janesmith") self.assertEqual(updated_author.bluesky_id, "jane.bsky.social") self.assertEqual(updated_author.facebook_id, "jane.smith") + self.assertEqual(updated_author.linkedin_id, "jane-smith") + + @patch('jdhapi.views.abstracts.submit_abstract.verify_challenge_solution') + def test_failed_to_solve_the_captcha(self, mock_captcha): + """Test that the user did not solve the captcha correctly.""" + + # Mock the captcha verification to fail + mock_captcha.return_value = False, None + + url = reverse("submit-abstract") + c = Client() + response = c.post( + url, + self.valid_payload, + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("error", response.data) + self.assertEqual(Abstract.objects.count(), 0) + self.assertEqual(Author.objects.count(), 0) + response = c.post( + url, + "Invalid Altcha payload", + content_type="application/json", + ) \ No newline at end of file diff --git a/tests/jdhapi/views/articles/test_ojs.py b/tests/jdhapi/views/articles/test_ojs.py new file mode 100644 index 0000000..38dc26f --- /dev/null +++ b/tests/jdhapi/views/articles/test_ojs.py @@ -0,0 +1,288 @@ +from unittest.mock import Mock, patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from jdhapi.models import Abstract, Article, Author, Issue +from rest_framework import status +from rest_framework.test import APIClient + +User = get_user_model() + + +class SendArticleToOJSTestCase(TestCase): + def setUp(self): + """Set up test fixtures""" + self.client = APIClient() + + # Create admin user + self.admin_user = User.objects.create_superuser( + username="admin", email="admin@test.com", password="testpass123" + ) + + # Create test article with abstract and authors + self.abstract = Abstract.objects.create( + pid="test-article-001", + title="Test Article Title", + abstract="Test article abstract", + contact_email="author@test.com", + contact_lastname="Doe", + ) + + self.author = Author.objects.create( + firstname="John", + lastname="Doe", + email="author@test.com", + affiliation="Test University", + country="US", + orcid="0000-0001-2345-6789", + ) + + self.abstract.authors.add(self.author) + + self.issue = Issue.objects.create( + id=0, + pid="jdh000", + name="Issue 0", + volume=1, + issue=1, + status=Issue.Status.PUBLISHED, + ) + + self.article = Article.objects.create( + abstract=self.abstract, + data={"title": "Test Article Title"}, + issue=self.issue, + ) + + self.url = "/api/articles/ojs/submission" + self.valid_payload = {"pid": "test-article-001"} + + def test_send_article_to_ojs_not_authenticated(self): + """Test that non-authenticated users cannot access the endpoint""" + response = self.client.post( + self.url, self.valid_payload, headers="", format="json" + ) + # IsAdminUser returns 403, not 401, when not authenticated + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_send_article_to_ojs_not_admin(self): + """Test that non-admin users cannot access the endpoint""" + regular_user = User.objects.create_user( + username="regular", email="regular@test.com", password="testpass123" + ) + self.client.force_authenticate(user=regular_user) + + response = self.client.post(self.url, self.valid_payload, format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @patch("jdhapi.views.articles.ojs.generate_pdf_for_submission") + @patch("jdhapi.views.articles.ojs.requests.post") + @patch("jdhapi.views.articles.ojs.requests.put") + def test_send_article_to_ojs_success(self, mock_put, mock_post, mock_pdf): + """Test successful article submission to OJS""" + self.client.force_authenticate(user=self.admin_user) + + # Mock PDF generation + mock_pdf.return_value = b"fake_pdf_content" + + # Mock blank submission creation response + mock_blank_submission_response = Mock() + mock_blank_submission_response.status_code = 200 + mock_blank_submission_response.json.return_value = { + "id": 123, + "currentPublicationId": 456, + } + + # Mock file upload response + mock_upload_response = Mock() + mock_upload_response.status_code = 200 + mock_upload_response.json.return_value = {"id": 789} + + # Mock contributor creation response + mock_contributor_response = Mock() + mock_contributor_response.status_code = 201 + mock_contributor_response.json.return_value = {"id": 999} + + # Mock metadata assignment response + mock_metadata_response = Mock() + mock_metadata_response.status_code = 200 + mock_metadata_response.json.return_value = {"success": True} + + # Set up mock post to return different responses based on call order + mock_post.side_effect = [ + mock_blank_submission_response, # create_blank_submission + mock_upload_response, # upload_manuscript_to_ojs + mock_contributor_response, # create_contributor_in_ojs + ] + + mock_put.return_value = mock_metadata_response + + response = self.client.post(self.url, self.valid_payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("message", response.data) + self.assertEqual( + response.data["message"], "Article send successfully to OJS." + ) + + # Verify that the mocks were called + self.assertEqual(mock_post.call_count, 3) + self.assertEqual(mock_put.call_count, 1) + mock_pdf.assert_called_once() + + @patch("jdhapi.views.articles.ojs.article_to_ojs_schema") + def test_send_article_to_ojs_missing_pid(self, mock_schema): + """Test that missing PID returns validation error""" + self.client.force_authenticate(user=self.admin_user) + + # Mock schema validation to pass + mock_schema.validate.return_value = None + + invalid_payload = {} + response = self.client.post(self.url, invalid_payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @patch("jdhapi.views.articles.ojs.article_to_ojs_schema") + def test_send_article_to_ojs_article_not_found(self, mock_schema): + """Test that non-existent article returns error""" + self.client.force_authenticate(user=self.admin_user) + + # Mock schema validation to pass + mock_schema.validate.return_value = None + + invalid_payload = {"pid": "non-existent-article"} + response = self.client.post(self.url, invalid_payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + + @patch("jdhapi.views.articles.ojs.generate_pdf_for_submission") + @patch("jdhapi.views.articles.ojs.requests.post") + @patch("jdhapi.views.articles.ojs.article_to_ojs_schema") + def test_send_article_to_ojs_missing_author_fields( + self, mock_schema, mock_post, mock_pdf + ): + """Test that missing required author fields returns validation error""" + self.client.force_authenticate(user=self.admin_user) + + # Mock PDF generation + mock_pdf.return_value = b"fake_pdf_content" + + # Mock blank submission creation response + mock_blank_submission_response = Mock() + mock_blank_submission_response.status_code = 200 + mock_blank_submission_response.json.return_value = { + "id": 123, + "currentPublicationId": 456, + } + + # Mock file upload response + mock_upload_response = Mock() + mock_upload_response.status_code = 200 + mock_upload_response.json.return_value = {"id": 789} + + # Mock schema validation to pass + mock_schema.validate.return_value = None + + # Create author with missing required field + incomplete_author = Author.objects.create( + firstname="Jane", + lastname="Smith", + email="", # Missing required field + affiliation="Test University", + country="US", + orcid="0000-0001-2345-6789", + ) + + incomplete_abstract = Abstract.objects.create( + pid="test-article-002", + title="Test Article 2", + abstract="Test abstract 2", + contact_email="jane@test.com", + contact_lastname="Smith", + ) + incomplete_abstract.authors.add(incomplete_author) + + Article.objects.create( + abstract=incomplete_abstract, + data={"title": "Test Article 2"}, + issue=self.issue, + ) + + mock_post.side_effect = [ + mock_blank_submission_response, # create_blank_submission + mock_upload_response, # upload_manuscript_to_ojs + ] + + payload = {"pid": "test-article-002"} + response = self.client.post(self.url, payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @patch("jdhapi.views.articles.ojs.generate_pdf_for_submission") + @patch("jdhapi.views.articles.ojs.requests.post") + def test_send_article_to_ojs_upload_fails(self, mock_post, mock_pdf): + """Test that failed manuscript upload returns error""" + self.client.force_authenticate(user=self.admin_user) + + mock_pdf.return_value = b"fake_pdf_content" + + # Mock successful blank submission + mock_blank_submission = Mock() + mock_blank_submission.status_code = 201 + mock_blank_submission.json.return_value = { + "id": 123, + "currentPublicationId": 456, + } + + # Mock failed upload + mock_upload_fail = Mock() + mock_upload_fail.status_code = 400 + mock_upload_fail.text = "Upload failed" + + mock_post.side_effect = [mock_blank_submission, mock_upload_fail] + + response = self.client.post(self.url, self.valid_payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertIn("error", response.data) + + @patch("jdhapi.views.articles.ojs.generate_pdf_for_submission") + @patch("jdhapi.views.articles.ojs.requests.post") + @patch("jdhapi.views.articles.ojs.requests.put") + def test_send_article_to_ojs_metadata_assignment_fails( + self, mock_put, mock_post, mock_pdf + ): + """Test that failed metadata assignment returns error""" + self.client.force_authenticate(user=self.admin_user) + + mock_pdf.return_value = b"fake_pdf_content" + + # Mock successful responses for initial calls + mock_blank_submission = Mock() + mock_blank_submission.status_code = 201 + mock_blank_submission.json.return_value = { + "id": 123, + "currentPublicationId": 456, + } + + mock_upload = Mock() + mock_upload.status_code = 200 + mock_upload.json.return_value = {"id": 789} + + mock_contributor = Mock() + mock_contributor.status_code = 201 + mock_contributor.json.return_value = {"id": 999} + + mock_post.side_effect = [mock_blank_submission, mock_upload, mock_contributor] + + # Mock failed metadata assignment + mock_metadata_fail = Mock() + mock_metadata_fail.status_code = 400 + mock_metadata_fail.text = "Metadata assignment failed" + + mock_put.return_value = mock_metadata_fail + + response = self.client.post(self.url, self.valid_payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)