From 0b659edeba112b5b2358b740c47da369f75b6b65 Mon Sep 17 00:00:00 2001 From: eliselavy Date: Mon, 13 Oct 2025 16:21:19 +0200 Subject: [PATCH 01/45] addition patch method api/articles//status/ --- jdhapi/urls.py | 1 + jdhapi/views/api_root.py | 1 + jdhapi/views/articles/articles.py | 24 +++++++++++++++++++++++- jdhapi/views/articles/status_handlers.py | 13 +++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 jdhapi/views/articles/status_handlers.py diff --git a/jdhapi/urls.py b/jdhapi/urls.py index cf49075..88e3f13 100644 --- a/jdhapi/urls.py +++ b/jdhapi/urls.py @@ -24,6 +24,7 @@ ), 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.ArticleStatus.as_view(), name='article-status'), path( "api/articles/status", views.update_article_status, 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/articles.py b/jdhapi/views/articles/articles.py index 1c1f495..81c6837 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 TechnicalReviewHandler +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,20 @@ 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(), + } + + 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/status_handlers.py b/jdhapi/views/articles/status_handlers.py new file mode 100644 index 0000000..cd998cb --- /dev/null +++ b/jdhapi/views/articles/status_handlers.py @@ -0,0 +1,13 @@ +from rest_framework.response import Response +from jdhapi.models import Article + +class StatusHandler: + def handle(self, article, request): + raise NotImplementedError + +class TechnicalReviewHandler(StatusHandler): + def handle(self, article, request): + article.status = article.Status.TECHNICAL_REVIEW + article.save() + return Response({"status": "TECHNICAL_REVIEW set", "article pid": article.abstract.pid}) + From fd7d43d58c35ad5810263d7110f078b4a4439287 Mon Sep 17 00:00:00 2001 From: eliselavy Date: Tue, 14 Oct 2025 15:53:26 +0200 Subject: [PATCH 02/45] integration run preflight action - post save --- jdhapi/signals.py | 23 +++++++++++++--- jdhapi/utils/run_github_action.py | 44 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 jdhapi/utils/run_github_action.py diff --git a/jdhapi/signals.py b/jdhapi/signals.py index b5fd2fe..c657adf 100644 --- a/jdhapi/signals.py +++ b/jdhapi/signals.py @@ -1,13 +1,15 @@ import requests - +import logging from django.core.exceptions import ValidationError from django.db.models.signals import post_save, pre_save from django.dispatch import receiver - from jdhapi.models import Article from jdhapi.utils.articles import convert_string_to_base64 +from jdhapi.utils.run_github_action import trigger_workflow +logger = logging.getLogger(__name__) + @receiver(post_save, sender=Article) def send_email_for_peer_review_article(sender, instance, created, **kwargs): if ( @@ -16,7 +18,22 @@ def send_email_for_peer_review_article(sender, instance, created, **kwargs): and instance.status == Article.Status.PEER_REVIEW ): instance.send_email_if_peer_review() - + elif ( + not created + and instance.tracker.has_changed("status") + and instance.status == Article.Status.TECHNICAL_REVIEW + ): + try: + trigger_workflow( + repo_url=instance.repository_url, + workflow_filename="github-actions-preflight.yml", + ) + logger.info(f"Successfully triggered workflow for article {instance.abstract.pid}") + except requests.exceptions.HTTPError as e: + logger.error(f"Failed to trigger workflow for article {instance.abstract.pid}: HTTP {e.response.status_code} - {e.response.text}") + except Exception as e: + logger.error(f"Unexpected error triggering workflow for article {instance.abstract.pid}: {str(e)}") + @receiver(pre_save, sender=Article) def validate_urls_for_article_submission(sender, instance, **kwargs): diff --git a/jdhapi/utils/run_github_action.py b/jdhapi/utils/run_github_action.py new file mode 100644 index 0000000..5dc1ea4 --- /dev/null +++ b/jdhapi/utils/run_github_action.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +import os +import sys +import requests +from pathlib import Path +from urllib.parse import urlparse + + +def trigger_workflow(repo_url, workflow_filename, token=None, ref="main"): + """ + :param owner: GitHub username or organization + :param repo: Repository name + :param workflow_filename: Filename of the workflow in .github/workflows (e.g. "hello-world.yml") + :param ref: Git ref (branch or tag) to run the workflow on + """ + if not token: + from jdh.settings import GITHUB_ACCESS_TOKEN + + token = GITHUB_ACCESS_TOKEN + + parsed = urlparse(repo_url) + path = parsed.path.lstrip("/") + + if path.endswith(".git"): + path = path[:-4] + + parts = path.split("/") + if len(parts) >= 2: + owner = parts[0] + repo = parts[1] + + 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} + resp = requests.post(url, json=payload, headers=headers) + if resp.status_code == 204: + print(f"Workflow '{workflow_filename}' dispatched on ref '{ref}'.") + else: + print(f"Failed to dispatch workflow: {resp.status_code}") + print(resp.json()) + resp.raise_for_status() \ No newline at end of file From 3a4e5c29fe86fee39141a5df9366816fe695563a Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Wed, 21 Jan 2026 17:58:07 +0100 Subject: [PATCH 03/45] (feat) send_to_ojs endpoint started + OJS_API_KEY and OJS_URL added to .env --- .env.example | 28 ++--- jdh/settings.py | 5 +- jdhapi/views/articles/__init__.py | 3 +- jdhapi/views/articles/send_to_ojs.py | 141 ++++++++++++++++++++++++ jdhapi/views/articles/update_article.py | 4 +- schema/article_to_ojs.json | 12 ++ 6 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 jdhapi/views/articles/send_to_ojs.py create mode 100644 schema/article_to_ojs.json diff --git a/.env.example b/.env.example index aba5109..7db1bf7 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,23 @@ -SECRET_KEY=ccscscsc +ALLOWED_HOSTS=127.0.0.1 +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/jdh/settings.py b/jdh/settings.py index a6e9bb4..f234ca1 100644 --- a/jdh/settings.py +++ b/jdh/settings.py @@ -278,7 +278,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 +285,7 @@ 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") diff --git a/jdhapi/views/articles/__init__.py b/jdhapi/views/articles/__init__.py index aad7891..a4de807 100644 --- a/jdhapi/views/articles/__init__.py +++ b/jdhapi/views/articles/__init__.py @@ -1,4 +1,5 @@ +from .advance_article import * from .articles import * +from .send_to_ojs import * from .social_media import * from .update_article import * -from .advance_article import * diff --git a/jdhapi/views/articles/send_to_ojs.py b/jdhapi/views/articles/send_to_ojs.py new file mode 100644 index 0000000..72d0ba1 --- /dev/null +++ b/jdhapi/views/articles/send_to_ojs.py @@ -0,0 +1,141 @@ +import requests +from django.db import transaction +from jdhapi.models import Article +from jdh.validation import JSONSchema +from jsonschema.exceptions import ValidationError +from rest_framework.decorators import ( + api_view, + permission_classes, +) +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response +from rest_framework import status +from settings import OJS_API_KEY_TOKEN as bearer_token, OJS_API_URL + +from ..logger import logger as get_logger + +logger = get_logger() +article_to_ojs_schema = JSONSchema(filepath="article_to_ojs.json") +headers='application/json' + +@api_view(["POST"]) +@permission_classes([IsAdminUser]) +def send_article_to_ojs(request): + """ + POST /api/articles/ojs + + Endpoint to send an article ready for peer review to OJS. + Requires admin permissions. + """ + + try: + res = submit_to_ojs(request) + return Response( + {"message": "Article(s) send successfully to OJS.", "data": res}, + status=status.HTTP_200_OK, + ) + except ValidationError as e: + logger.error(f"JSON schema validation failed: {e}") + return Response( + {"error": "Invalid data format", "details": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) + except (KeyError, IndexError) as e: + logger.exception("Data invalid after validation") + return Response( + {"error": "KeyError", "message": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + content_type="application/json", + ) + except Exception as e: + logger.exception("An unexpected error occurred.") + 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') + + 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({"error": "One article PID is required."}) + + article = Article.objects.filter(abstract__pid__in=pid) + + if not article.exists(): + logger.error(f"No article found for PID : {pid}.") + raise Exception({"error": "Article not found."}) + + logger.info("Send article to OJS.") + + submission_id = 0 + publication_id = 0 + file_id = 0 + contributor_id = 0 + + # Here in the middle we need to write the different steps which are : + + + # 3. Create the article contributor in OJS + # 4. Assign the author as primary Contact to the submission + title + abstract + competingInterests + # 5. Submit the submission to OJS + + # 1. create a blank submission in OJS + res = create_blank_submission() + + submission_id = res.json().get('id',0) + publication_id = res.json().get('currentPublicationId', 0) + + # 2. upload the pdf file to OJS + + res = upload_manuscript_to_ojs(submission_id, "TO_DO_TO_MODIFY_HERE" ) + + file_id = res.json().get('fileId',0) + + + + article.status = 'PEER_REVIEW' + article.save() + + +def create_blank_submission(): + 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, authentication=bearer_token, headers=headers, json=payload ) + + return res + +def upload_manuscript_to_ojs(submission_id, file_path): + url=f"{OJS_API_URL}/submission/{submission_id}/files" + payload={ + "file": open(file_path, 'rb'), + "fileStage": 1, # 1 is for stage SUBMISSION_FILE_SUBMISSION + "genreId": 1, # 1 is for manuscript + } + + res=requests.post(url=url, authentication=bearer_token, headers=headers, files=payload) + + return res diff --git a/jdhapi/views/articles/update_article.py b/jdhapi/views/articles/update_article.py index 68af720..ba71110 100644 --- a/jdhapi/views/articles/update_article.py +++ b/jdhapi/views/articles/update_article.py @@ -1,7 +1,7 @@ +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 jsonschema.exceptions import ValidationError from rest_framework.decorators import ( api_view, permission_classes, diff --git a/schema/article_to_ojs.json b/schema/article_to_ojs.json new file mode 100644 index 0000000..127d9b5 --- /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": { + "pids": { + "type": "string" + } + }, + "required": ["pids"], + "additionalProperties": false +} From eadeac60df3cf98dac194103f4ee17886bc0491c Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Wed, 21 Jan 2026 19:13:05 +0100 Subject: [PATCH 04/45] (feat) different methods created for the different stage of OJS submission --- jdhapi/views/articles/send_to_ojs.py | 114 +++++++++++++++++++++++---- 1 file changed, 97 insertions(+), 17 deletions(-) diff --git a/jdhapi/views/articles/send_to_ojs.py b/jdhapi/views/articles/send_to_ojs.py index 72d0ba1..90dd7c7 100644 --- a/jdhapi/views/articles/send_to_ojs.py +++ b/jdhapi/views/articles/send_to_ojs.py @@ -87,32 +87,41 @@ def submit_to_ojs(request): submission_id = 0 publication_id = 0 - file_id = 0 contributor_id = 0 - # Here in the middle we need to write the different steps which are : + try: + # 1. create a blank submission in OJS + res = create_blank_submission() - - # 3. Create the article contributor in OJS - # 4. Assign the author as primary Contact to the submission + title + abstract + competingInterests - # 5. Submit the submission to OJS + submission_id = res.json().get('id',0) + publication_id = res.json().get('currentPublicationId', 0) + + # 2. upload the pdf file to OJS + res = upload_manuscript_to_ojs(submission_id, "TO_DO_TO_MODIFY_HERE") - # 1. create a blank submission in OJS - res = create_blank_submission() + # 3. Create the article contributor in OJS + res=create_contributor_in_ojs(submission_id, publication_id, article) - submission_id = res.json().get('id',0) - publication_id = res.json().get('currentPublicationId', 0) - - # 2. upload the pdf file to OJS + contributor_id=res.json().get('contributor_id',0) - res = upload_manuscript_to_ojs(submission_id, "TO_DO_TO_MODIFY_HERE" ) + # 4. Assign the author as primary Contact to the submission + title + abstract + competingInterests + assign_primary_contact_and_metadata(submission_id, publication_id, contributor_id, article) - file_id = res.json().get('fileId',0) + # 5. Submit the submission to OJS - + # TOD_DO uncomment once everything else is checked + # submit_to_ojs(submission_id) + except Exception as e: + logger.error(f"Error during OJS submission process: {e}") + raise e - article.status = 'PEER_REVIEW' - article.save() + try: + logger.info("Update article status to PEER_REVIEW") + article.status = 'PEER_REVIEW' + article.save() + except Exception as e: + logger.error(f"Failed to update article status: {e}") + raise e def create_blank_submission(): @@ -129,6 +138,8 @@ def create_blank_submission(): return res def upload_manuscript_to_ojs(submission_id, file_path): + logger.info("creating a blank submission in OJS") + url=f"{OJS_API_URL}/submission/{submission_id}/files" payload={ "file": open(file_path, 'rb'), @@ -139,3 +150,72 @@ def upload_manuscript_to_ojs(submission_id, file_path): res=requests.post(url=url, authentication=bearer_token, headers=headers, files=payload) return res + +def create_contributor_in_ojs(submission_id, publication_id, article): + logger.info("creating the article contributor in OJS") + + url=f"{OJS_API_URL}/submission/{submission_id}/publications/{publication_id}/contributors" + payload = { + "affiliation": { + "en": article.authors.first().affiliation + }, + "country": "TO_DO_TO_IMPLEMENT_COUNTRY_CODE", + "email": article.authors.first().email, + "familyName": { + "en": article.authors.first().last_name + }, + "fullName": f"{article.authors.first().first_name} {article.authors.first().last_name}", + "givenName": { + "en": article.authors.first().first_name + }, + "includeInBrowse": True, + "locale": "en", + "orcid": article.authors.first().orcid, + "preferredPublicName": { + "en": "" + }, + "publicationId": publication_id, + "seq": 0, + "userGroupId": 14, + "userGroupName": { + "en": "Author" + } + } + + res = requests.post(url=url, authentication=bearer_token, headers=headers, json=payload) + + return res + +def assign_primary_contact_and_metadata(submission_id, publication_id, contributor_id, article): + logger.info("Assign the author as primary contact to the submission and add title, abstract and competingInterests") + + url=f"{OJS_API_URL}/submission/{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, authentication=bearer_token, headers=headers, json=payload) + + return res + +def submit_submission_to_ojs(submission_id): + logger.info("Submit the article to OJS") + + url=f"{OJS_API_URL}/submissions/{submission_id}/submit" + payload= { + "confirmCopyright": "true" + } + + res=requests.put(url, authentication=bearer_token, headers=headers, json=payload) + + return res + From 1e23e3bbfd436b22614f82381979c648163ada5f Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Mon, 26 Jan 2026 15:40:42 +0100 Subject: [PATCH 05/45] (fix) schema change from pids to pid and url /api/articles/ojs listed --- jdhapi/urls.py | 1 + schema/article_to_ojs.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/jdhapi/urls.py b/jdhapi/urls.py index 56759ee..644c0d7 100644 --- a/jdhapi/urls.py +++ b/jdhapi/urls.py @@ -46,6 +46,7 @@ name="articles-facebook", ), path("api/articles/cover", views.get_social_cover_image, name="articles-social-media-cover"), + path("api/articles/ojs", views.send_article_to_ojs, name="articles-send-to-ojs"), path("api/articles/tweet", views.get_tweet_md_file, name="articles-tweet"), path("api/authors/", views.AuthorList.as_view(), name="author-list"), path("api/authors//", views.AuthorDetail.as_view(), name="author-detail"), diff --git a/schema/article_to_ojs.json b/schema/article_to_ojs.json index 127d9b5..0fc477c 100644 --- a/schema/article_to_ojs.json +++ b/schema/article_to_ojs.json @@ -3,10 +3,10 @@ "$id": "https://journaldigitalhistory.eu/schemas/article-to-ojs.json", "type": "object", "properties": { - "pids": { + "pid": { "type": "string" } }, - "required": ["pids"], + "required": ["pid"], "additionalProperties": false } From 2cff6d3de942093d838a6ededb5c277d09a5ea96 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Mon, 26 Jan 2026 15:41:06 +0100 Subject: [PATCH 06/45] (feat) seapration of the process for each author + generation of the pdf --- jdhapi/views/articles/send_to_ojs.py | 147 +++++++++++++++++---------- 1 file changed, 93 insertions(+), 54 deletions(-) diff --git a/jdhapi/views/articles/send_to_ojs.py b/jdhapi/views/articles/send_to_ojs.py index 90dd7c7..52e51c1 100644 --- a/jdhapi/views/articles/send_to_ojs.py +++ b/jdhapi/views/articles/send_to_ojs.py @@ -1,8 +1,12 @@ +import marko import requests +from django.conf import settings from django.db import transaction +from django.template.loader import render_to_string from jdhapi.models import Article from jdh.validation import JSONSchema from jsonschema.exceptions import ValidationError +from lxml import html from rest_framework.decorators import ( api_view, permission_classes, @@ -10,13 +14,17 @@ from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework import status -from settings import OJS_API_KEY_TOKEN as bearer_token, OJS_API_URL +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='application/json' +headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {settings.OJS_API_KEY_TOKEN}' +} +OJS_API_URL = settings.OJS_API_URL @api_view(["POST"]) @permission_classes([IsAdminUser]) @@ -28,6 +36,8 @@ def send_article_to_ojs(request): Requires admin permissions. """ + logger.info("POST /api/articles/ojs") + try: res = submit_to_ojs(request) return Response( @@ -57,8 +67,7 @@ def send_article_to_ojs(request): }, status=status.HTTP_500_INTERNAL_SERVER_ERROR, content_type="application/json", - ) - + ) def submit_to_ojs(request): @@ -77,7 +86,8 @@ def submit_to_ojs(request): logger.error("No PID provided in request data.") raise ValidationError({"error": "One article PID is required."}) - article = Article.objects.filter(abstract__pid__in=pid) + article = Article.objects.filter(abstract__pid=pid) + print("🚀 ~ file: send_to_ojs.py:92 ~ article:", article.abstract) if not article.exists(): logger.error(f"No article found for PID : {pid}.") @@ -89,6 +99,23 @@ def submit_to_ojs(request): publication_id = 0 contributor_id = 0 + required_fields = { + 'affiliation': 'affiliation', + 'country': 'country', + 'email': 'email', + 'lastname': 'lastname', + 'firstname': 'firstname', + 'orcid': 'orcid', + } + + for author in article.abstract.authors: + 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) + raise ValidationError({"error": error_msg}) + try: # 1. create a blank submission in OJS res = create_blank_submission() @@ -97,32 +124,24 @@ def submit_to_ojs(request): publication_id = res.json().get('currentPublicationId', 0) # 2. upload the pdf file to OJS - res = upload_manuscript_to_ojs(submission_id, "TO_DO_TO_MODIFY_HERE") + pdf_file = generate_pdf_for_submission(article) + res = upload_manuscript_to_ojs(article.abstract.pid, submission_id, pdf_file) # 3. Create the article contributor in OJS - res=create_contributor_in_ojs(submission_id, publication_id, article) + primary_contact_id=create_contributor_in_ojs(submission_id, publication_id, article) - contributor_id=res.json().get('contributor_id',0) + contributor_id=primary_contact_id # 4. Assign the author as primary Contact to the submission + title + abstract + competingInterests assign_primary_contact_and_metadata(submission_id, publication_id, contributor_id, article) # 5. Submit the submission to OJS - - # TOD_DO uncomment once everything else is checked + # 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 - try: - logger.info("Update article status to PEER_REVIEW") - article.status = 'PEER_REVIEW' - article.save() - except Exception as e: - logger.error(f"Failed to update article status: {e}") - raise e - def create_blank_submission(): logger.info("creating a blank submission in OJS") @@ -133,58 +152,63 @@ def create_blank_submission(): "locale":"en", "sectionId":1 } - res = requests.post(url=url, authentication=bearer_token, headers=headers, json=payload ) + res = requests.post(url=url, headers=headers, json=payload ) return res -def upload_manuscript_to_ojs(submission_id, file_path): - logger.info("creating a blank submission in OJS") +def upload_manuscript_to_ojs(pid, submission_id, pdf_bytes): + logger.info("uploading manuscript to OJS") url=f"{OJS_API_URL}/submission/{submission_id}/files" payload={ - "file": open(file_path, 'rb'), + "file": (f"peer_review_{pid}.pdf", pdf_bytes, "application/pdf"), "fileStage": 1, # 1 is for stage SUBMISSION_FILE_SUBMISSION "genreId": 1, # 1 is for manuscript } - res=requests.post(url=url, authentication=bearer_token, headers=headers, files=payload) + res=requests.post(url=url, headers=headers, files=payload) return res def create_contributor_in_ojs(submission_id, publication_id, article): logger.info("creating the article contributor in OJS") - url=f"{OJS_API_URL}/submission/{submission_id}/publications/{publication_id}/contributors" - payload = { - "affiliation": { - "en": article.authors.first().affiliation - }, - "country": "TO_DO_TO_IMPLEMENT_COUNTRY_CODE", - "email": article.authors.first().email, - "familyName": { - "en": article.authors.first().last_name - }, - "fullName": f"{article.authors.first().first_name} {article.authors.first().last_name}", - "givenName": { - "en": article.authors.first().first_name - }, - "includeInBrowse": True, - "locale": "en", - "orcid": article.authors.first().orcid, - "preferredPublicName": { - "en": "" - }, - "publicationId": publication_id, - "seq": 0, - "userGroupId": 14, - "userGroupName": { - "en": "Author" - } - } + primary_contact_id = 0 - res = requests.post(url=url, authentication=bearer_token, headers=headers, json=payload) + url=f"{settings.OJS_API_URL}/submission/{submission_id}/publications/{publication_id}/contributors" - return res + for author in article.authors.all(): + payload = { + "affiliation": { + "en": author.affiliation + }, + "country": 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) + if article.abstract.contact_orcid == author.orcid : + primary_contact_id = res.json().get('contributor_id',0) + + return primary_contact_id def assign_primary_contact_and_metadata(submission_id, publication_id, contributor_id, article): logger.info("Assign the author as primary contact to the submission and add title, abstract and competingInterests") @@ -203,7 +227,7 @@ def assign_primary_contact_and_metadata(submission_id, publication_id, contribut } } - res=requests.put(url=url, authentication=bearer_token, headers=headers, json=payload) + res=requests.put(url=url, headers=headers, json=payload) return res @@ -215,7 +239,22 @@ def submit_submission_to_ojs(submission_id): "confirmCopyright": "true" } - res=requests.put(url, authentication=bearer_token, headers=headers, json=payload) + res=requests.put(url, headers=headers, json=payload) return res +def generate_pdf_for_submission(article): + 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 + From da6ca96039acd0be9c1cdff9ae933580454331f4 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Fri, 30 Jan 2026 15:43:30 +0100 Subject: [PATCH 07/45] (fix) /api/article/ojs works locally --- jdhapi/views/articles/send_to_ojs.py | 59 ++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/jdhapi/views/articles/send_to_ojs.py b/jdhapi/views/articles/send_to_ojs.py index 52e51c1..c558848 100644 --- a/jdhapi/views/articles/send_to_ojs.py +++ b/jdhapi/views/articles/send_to_ojs.py @@ -86,10 +86,9 @@ def submit_to_ojs(request): logger.error("No PID provided in request data.") raise ValidationError({"error": "One article PID is required."}) - article = Article.objects.filter(abstract__pid=pid) - print("🚀 ~ file: send_to_ojs.py:92 ~ article:", article.abstract) + article = Article.objects.get(abstract__pid=pid) - if not article.exists(): + if article is None: logger.error(f"No article found for PID : {pid}.") raise Exception({"error": "Article not found."}) @@ -108,7 +107,7 @@ def submit_to_ojs(request): 'orcid': 'orcid', } - for author in article.abstract.authors: + 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}" @@ -118,26 +117,44 @@ def submit_to_ojs(request): 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) - + # 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 - assign_primary_contact_and_metadata(submission_id, publication_id, contributor_id, article) + 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 @@ -159,30 +176,36 @@ def create_blank_submission(): def upload_manuscript_to_ojs(pid, submission_id, pdf_bytes): logger.info("uploading manuscript to OJS") - url=f"{OJS_API_URL}/submission/{submission_id}/files" - payload={ + 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"), - "fileStage": 1, # 1 is for stage SUBMISSION_FILE_SUBMISSION + } + data={ + "fileStage": 2, # 2 is for stage SUBMISSION_FILE_SUBMISSION "genreId": 1, # 1 is for manuscript } - res=requests.post(url=url, headers=headers, files=payload) + 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): +def create_contributor_in_ojs(submission_id, publication_id, article: Article): logger.info("creating the article contributor in OJS") primary_contact_id = 0 - url=f"{settings.OJS_API_URL}/submission/{submission_id}/publications/{publication_id}/contributors" + url=f"{settings.OJS_API_URL}/submissions/{submission_id}/publications/{publication_id}/contributors" - for author in article.authors.all(): + for author in article.abstract.authors.all(): + logger.info(f"Contributor {author.firstname} {author.lastname} creation in OJS") payload = { "affiliation": { "en": author.affiliation }, - "country": author.country, + "country": str(author.country), "email": author.email, "familyName": { "en": author.lastname @@ -205,15 +228,17 @@ def create_contributor_in_ojs(submission_id, publication_id, article): } } res = requests.post(url=url, headers=headers, json=payload) - if article.abstract.contact_orcid == author.orcid : - primary_contact_id = res.json().get('contributor_id',0) + logger.info(f"Contributor {author.firstname} {author.lastname} created with response: {res.json()}") + + if article.abstract.contact_email == author.email and article.abstract.contact_lastname == author.lastname : + 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): logger.info("Assign the author as primary contact to the submission and add title, abstract and competingInterests") - url=f"{OJS_API_URL}/submission/{submission_id}/publications/{publication_id}" + url=f"{OJS_API_URL}/submissions/{submission_id}/publications/{publication_id}" payload={ "primaryContactId": contributor_id, "title": { From 24ec9f4bde6148a1dfd64642206a9972b3d53f71 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Fri, 30 Jan 2026 16:21:55 +0100 Subject: [PATCH 08/45] (remove) code to generate peer review pdf and email --- jdhapi/models/article.py | 34 ---------------------------------- jdhapi/signals.py | 10 ---------- 2 files changed, 44 deletions(-) diff --git a/jdhapi/models/article.py b/jdhapi/models/article.py index 9f7f22a..c8c8837 100644 --- a/jdhapi/models/article.py +++ b/jdhapi/models/article.py @@ -174,37 +174,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/signals.py b/jdhapi/signals.py index b5fd2fe..ea31898 100644 --- a/jdhapi/signals.py +++ b/jdhapi/signals.py @@ -8,16 +8,6 @@ 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): From a5537061d9f5fbaea925cb04cae85bb4faa8c95a Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Wed, 4 Feb 2026 13:10:06 +0100 Subject: [PATCH 09/45] (test) add unit test for send_to_ojs --- jdhapi/views/articles/send_to_ojs.py | 10 +- tests/__init__.py | 1 + .../jdhapi/views/articles/test_send_to_ojs.py | 259 ++++++++++++++++++ 3 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 tests/jdhapi/views/articles/test_send_to_ojs.py diff --git a/jdhapi/views/articles/send_to_ojs.py b/jdhapi/views/articles/send_to_ojs.py index c558848..36a906a 100644 --- a/jdhapi/views/articles/send_to_ojs.py +++ b/jdhapi/views/articles/send_to_ojs.py @@ -45,13 +45,13 @@ def send_article_to_ojs(request): status=status.HTTP_200_OK, ) except ValidationError as e: - logger.error(f"JSON schema validation failed: {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 (KeyError, IndexError) as e: - logger.exception("Data invalid after validation") + logger.exception(f"Data invalid after validation: {str(e)}") return Response( {"error": "KeyError", "message": str(e)}, status=status.HTTP_400_BAD_REQUEST, @@ -84,13 +84,13 @@ def submit_to_ojs(request): if not pid: logger.error("No PID provided in request data.") - raise ValidationError({"error": "One article PID is required."}) + 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({"error": "Article not found."}) + raise Exception( "Article not found.") logger.info("Send article to OJS.") @@ -113,7 +113,7 @@ def submit_to_ojs(request): author_name=f"{author.firstname} {author.lastname}" error_msg = f"Author {fieldname} is missing. Author concerned : {author_name}" logger.error(error_msg) - raise ValidationError({"error": error_msg}) + raise ValidationError(error_msg) try: # 1. create a blank submission in OJS diff --git a/tests/__init__.py b/tests/__init__.py index ee821d6..6ee282c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -9,5 +9,6 @@ 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_send_to_ojs import * from .jdhapi.utils.test_bluesky import * from .jdhapi.utils.test_facebook import * diff --git a/tests/jdhapi/views/articles/test_send_to_ojs.py b/tests/jdhapi/views/articles/test_send_to_ojs.py new file mode 100644 index 0000000..f951b15 --- /dev/null +++ b/tests/jdhapi/views/articles/test_send_to_ojs.py @@ -0,0 +1,259 @@ +import json +from unittest.mock import Mock, patch, MagicMock +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status +from jdhapi.models import Article, Abstract, Author, Issue +from jsonschema.exceptions import ValidationError as JsonSchemaValidationError + +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' + 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.send_to_ojs.generate_pdf_for_submission') + @patch('jdhapi.views.articles.send_to_ojs.requests.post') + @patch('jdhapi.views.articles.send_to_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 = 201 + 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(s) 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.send_to_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.send_to_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.send_to_ojs.article_to_ojs_schema') + def test_send_article_to_ojs_missing_author_fields(self, mock_schema): + """Test that missing required author fields returns validation error""" + self.client.force_authenticate(user=self.admin_user) + + # 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 + ) + + 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.send_to_ojs.generate_pdf_for_submission') + @patch('jdhapi.views.articles.send_to_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.send_to_ojs.generate_pdf_for_submission') + @patch('jdhapi.views.articles.send_to_ojs.requests.post') + @patch('jdhapi.views.articles.send_to_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) \ No newline at end of file From 4fb7edfd31b7011549d937cf15a5f563ef4b5796 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Wed, 4 Feb 2026 22:44:59 +0100 Subject: [PATCH 10/45] (feat) get country code with ROR API --- jdhapi/utils/affiliation.py | 1 - jdhapi/views/articles/send_to_ojs.py | 11 +++++- jdhseo/utils.py | 57 ++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) 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/views/articles/send_to_ojs.py b/jdhapi/views/articles/send_to_ojs.py index 36a906a..3b3c450 100644 --- a/jdhapi/views/articles/send_to_ojs.py +++ b/jdhapi/views/articles/send_to_ojs.py @@ -5,6 +5,7 @@ from django.template.loader import render_to_string from jdhapi.models import Article from jdh.validation import JSONSchema +from jdhseo.utils import get_country_with_ROR from jsonschema.exceptions import ValidationError from lxml import html from rest_framework.decorators import ( @@ -113,11 +114,19 @@ def submit_to_ojs(request): 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() + return + 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()}") diff --git a/jdhseo/utils.py b/jdhseo/utils.py index 4efee52..f031cce 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_from_affiliation_name - 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('name', '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_from_affiliation_name") + + def get_employment_affiliation(orcid, api_url, headers): url = f"{api_url}/{orcid}/employments" resp = requests.get(url, headers=headers) From 64a79d7c149e867762c498989c91c8256ce4a437 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Mon, 9 Feb 2026 14:02:07 +0100 Subject: [PATCH 11/45] (fix) mispelling githup to github --- jdhapi/forms/articleForm.py | 2 +- jdhapi/utils/{gitup_repository.py => github_repository.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename jdhapi/utils/{gitup_repository.py => github_repository.py} (100%) 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/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 From bcde30b5d5a9c00dab4977431495b7d049883a20 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Mon, 9 Feb 2026 14:03:17 +0100 Subject: [PATCH 12/45] (refactor) logger move to utils --- jdhapi/{views => utils}/logger.py | 0 jdhapi/views/__init__.py | 1 - jdhapi/views/abstracts/submit_abstract.py | 4 +--- jdhapi/views/abstracts/update_abstract.py | 2 +- jdhapi/views/articles/update_article.py | 3 +-- 5 files changed, 3 insertions(+), 7 deletions(-) rename jdhapi/{views => utils}/logger.py (100%) 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/views/__init__.py b/jdhapi/views/__init__.py index 3764a6c..d1afe68 100644 --- a/jdhapi/views/__init__.py +++ b/jdhapi/views/__init__.py @@ -9,7 +9,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..eeb400b 100644 --- a/jdhapi/views/abstracts/submit_abstract.py +++ b/jdhapi/views/abstracts/submit_abstract.py @@ -2,6 +2,7 @@ from django.db import transaction from jdh.validation import JSONSchema from jdhapi.models import Abstract, Author, Dataset, CallForPaper +from jdhapi.utils.logger import logger as get_logger from jdhapi.serializers import AbstractSlimSerializer from jsonschema.exceptions import ValidationError, SchemaError from rest_framework.decorators import ( @@ -14,9 +15,6 @@ from rest_framework.permissions import AllowAny from textwrap import dedent -from ..logger import logger as get_logger - - logger = get_logger() document_json_schema = JSONSchema(filepath="submit_abstract.json") 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/articles/update_article.py b/jdhapi/views/articles/update_article.py index ba71110..5f1850b 100644 --- a/jdhapi/views/articles/update_article.py +++ b/jdhapi/views/articles/update_article.py @@ -1,6 +1,7 @@ from django.db import transaction from jdh.validation import JSONSchema from jdhapi.models import Article +from jdhapi.utils.logger import logger as get_logger from jsonschema.exceptions import ValidationError from rest_framework.decorators import ( api_view, @@ -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") From a4b5dbfa221a37f6ca5927ef14ae60b62883732c Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Mon, 9 Feb 2026 14:04:21 +0100 Subject: [PATCH 13/45] (feat) get submission counter from ojs endpoint created --- jdhapi/urls.py | 1 + jdhapi/utils/ojs.py | 142 +++++++++++++++ jdhapi/views/articles/__init__.py | 2 +- .../views/articles/{send_to_ojs.py => ojs.py} | 161 ++++-------------- 4 files changed, 176 insertions(+), 130 deletions(-) create mode 100644 jdhapi/utils/ojs.py rename jdhapi/views/articles/{send_to_ojs.py => ojs.py} (59%) diff --git a/jdhapi/urls.py b/jdhapi/urls.py index 644c0d7..5d36227 100644 --- a/jdhapi/urls.py +++ b/jdhapi/urls.py @@ -46,6 +46,7 @@ name="articles-facebook", ), path("api/articles/cover", views.get_social_cover_image, name="articles-social-media-cover"), + path("api/articles/ojs", views.get_count_submission_from_ojs, name="count-submission-from-ojs"), path("api/articles/ojs", views.send_article_to_ojs, name="articles-send-to-ojs"), path("api/articles/tweet", views.get_tweet_md_file, name="articles-tweet"), path("api/authors/", views.AuthorList.as_view(), name="author-list"), diff --git a/jdhapi/utils/ojs.py b/jdhapi/utils/ojs.py new file mode 100644 index 0000000..b129929 --- /dev/null +++ b/jdhapi/utils/ojs.py @@ -0,0 +1,142 @@ +import marko +import requests +from django.conf import settings +from django.template.loader import render_to_string +from jdhapi.models import Article +from jdh.validation import JSONSchema +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(): + 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): + 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): + 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 and article.abstract.contact_lastname == author.lastname : + 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): + 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): + 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): + 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/articles/__init__.py b/jdhapi/views/articles/__init__.py index a4de807..532f04b 100644 --- a/jdhapi/views/articles/__init__.py +++ b/jdhapi/views/articles/__init__.py @@ -1,5 +1,5 @@ from .advance_article import * from .articles import * -from .send_to_ojs import * +from .ojs import * from .social_media import * from .update_article import * diff --git a/jdhapi/views/articles/send_to_ojs.py b/jdhapi/views/articles/ojs.py similarity index 59% rename from jdhapi/views/articles/send_to_ojs.py rename to jdhapi/views/articles/ojs.py index 3b3c450..1073ea5 100644 --- a/jdhapi/views/articles/send_to_ojs.py +++ b/jdhapi/views/articles/ojs.py @@ -1,13 +1,12 @@ -import marko import requests from django.conf import settings from django.db import transaction -from django.template.loader import render_to_string from jdhapi.models import Article +from jdhapi.utils.ojs import create_blank_submission, upload_manuscript_to_ojs, create_contributor_in_ojs, assign_primary_contact_and_metadata, generate_pdf_for_submission +from jdhapi.utils.logger import logger as get_logger from jdh.validation import JSONSchema from jdhseo.utils import get_country_with_ROR from jsonschema.exceptions import ValidationError -from lxml import html from rest_framework.decorators import ( api_view, permission_classes, @@ -15,9 +14,7 @@ from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework import status -from weasyprint import HTML -from ..logger import logger as get_logger logger = get_logger() article_to_ojs_schema = JSONSchema(filepath="article_to_ojs.json") @@ -27,6 +24,36 @@ } OJS_API_URL = settings.OJS_API_URL +@api_view(["GET"]) +@permission_classes([IsAdminUser]) +def get_count_submission_from_ojs(_): + """ + Get the list of all abstracts submitted to OJS ans being either in 'Incomplete' submission stage or 'Submission' stage. + This corresponds to the stageIds : 1 in OJS API v3.4. + """ + + logger.info("GET /api/articles/ojs") + + 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): @@ -168,127 +195,3 @@ def submit_to_ojs(request): logger.error(f"Error during OJS submission process: {e}") raise e - -def create_blank_submission(): - 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): - 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): - 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 and article.abstract.contact_lastname == author.lastname : - 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): - 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): - 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): - 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 - From 2e1d04b3527f9b7348a62672c9164221c5909d4a Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Tue, 10 Feb 2026 10:38:49 +0100 Subject: [PATCH 14/45] (test) separating clearly the 2 endpoints for ojs --- jdhapi/urls.py | 4 +-- jdhapi/views/articles/ojs.py | 15 ++++++---- tests/__init__.py | 4 +-- .../{test_send_to_ojs.py => test_ojs.py} | 29 +++++++++---------- 4 files changed, 27 insertions(+), 25 deletions(-) rename tests/jdhapi/views/articles/{test_send_to_ojs.py => test_ojs.py} (91%) diff --git a/jdhapi/urls.py b/jdhapi/urls.py index 5d36227..7646f45 100644 --- a/jdhapi/urls.py +++ b/jdhapi/urls.py @@ -46,8 +46,8 @@ name="articles-facebook", ), path("api/articles/cover", views.get_social_cover_image, name="articles-social-media-cover"), - path("api/articles/ojs", views.get_count_submission_from_ojs, name="count-submission-from-ojs"), - path("api/articles/ojs", views.send_article_to_ojs, name="articles-send-to-ojs"), + path("api/articles/submissions/ojs", views.get_count_submission_from_ojs, name="count-submission-from-ojs"), + path("api/articles/submission/ojs", views.send_article_to_ojs, name="articles-send-to-ojs"), path("api/articles/tweet", views.get_tweet_md_file, name="articles-tweet"), path("api/authors/", views.AuthorList.as_view(), name="author-list"), path("api/authors//", views.AuthorDetail.as_view(), name="author-detail"), diff --git a/jdhapi/views/articles/ojs.py b/jdhapi/views/articles/ojs.py index 1073ea5..3cf2881 100644 --- a/jdhapi/views/articles/ojs.py +++ b/jdhapi/views/articles/ojs.py @@ -28,11 +28,13 @@ @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. - This corresponds to the stageIds : 1 in OJS API v3.4. + Requires admin permissions. """ - logger.info("GET /api/articles/ojs") + logger.info("GET /api/articles/ojs/submissions") url=f"{OJS_API_URL}/submissions?stageIds=1" @@ -58,13 +60,13 @@ def get_count_submission_from_ojs(_): @permission_classes([IsAdminUser]) def send_article_to_ojs(request): """ - POST /api/articles/ojs + POST /api/articles/ojs/submission - Endpoint to send an article ready for peer review to OJS. + Endpoint to create an article submission ready for peer review to OJS. Requires admin permissions. """ - logger.info("POST /api/articles/ojs") + logger.info("POST /api/articles/ojs/submission") try: res = submit_to_ojs(request) @@ -159,6 +161,9 @@ def submit_to_ojs(request): 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) diff --git a/tests/__init__.py b/tests/__init__.py index 6ee282c..559e44f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -9,6 +9,6 @@ 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_send_to_ojs import * +from .jdhapi.views.articles.test_ojs 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/articles/test_send_to_ojs.py b/tests/jdhapi/views/articles/test_ojs.py similarity index 91% rename from tests/jdhapi/views/articles/test_send_to_ojs.py rename to tests/jdhapi/views/articles/test_ojs.py index f951b15..c541402 100644 --- a/tests/jdhapi/views/articles/test_send_to_ojs.py +++ b/tests/jdhapi/views/articles/test_ojs.py @@ -1,15 +1,12 @@ -import json -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import Mock, patch from django.test import TestCase from django.contrib.auth import get_user_model from rest_framework.test import APIClient from rest_framework import status from jdhapi.models import Article, Abstract, Author, Issue -from jsonschema.exceptions import ValidationError as JsonSchemaValidationError User = get_user_model() - class SendArticleToOJSTestCase(TestCase): def setUp(self): """Set up test fixtures""" @@ -57,7 +54,7 @@ def setUp(self): issue=self.issue ) - self.url = '/api/articles/ojs' + self.url = '/api/articles/submission/ojs' self.valid_payload = {'pid': 'test-article-001'} def test_send_article_to_ojs_not_authenticated(self): @@ -78,9 +75,9 @@ def test_send_article_to_ojs_not_admin(self): response = self.client.post(self.url, self.valid_payload, format='json') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - @patch('jdhapi.views.articles.send_to_ojs.generate_pdf_for_submission') - @patch('jdhapi.views.articles.send_to_ojs.requests.post') - @patch('jdhapi.views.articles.send_to_ojs.requests.put') + @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) @@ -131,7 +128,7 @@ def test_send_article_to_ojs_success(self, mock_put, mock_post, mock_pdf): self.assertEqual(mock_put.call_count, 1) mock_pdf.assert_called_once() - @patch('jdhapi.views.articles.send_to_ojs.article_to_ojs_schema') + @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) @@ -144,7 +141,7 @@ def test_send_article_to_ojs_missing_pid(self, mock_schema): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - @patch('jdhapi.views.articles.send_to_ojs.article_to_ojs_schema') + @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) @@ -157,7 +154,7 @@ def test_send_article_to_ojs_article_not_found(self, mock_schema): self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) - @patch('jdhapi.views.articles.send_to_ojs.article_to_ojs_schema') + @patch('jdhapi.views.articles.ojs.article_to_ojs_schema') def test_send_article_to_ojs_missing_author_fields(self, mock_schema): """Test that missing required author fields returns validation error""" self.client.force_authenticate(user=self.admin_user) @@ -195,8 +192,8 @@ def test_send_article_to_ojs_missing_author_fields(self, mock_schema): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - @patch('jdhapi.views.articles.send_to_ojs.generate_pdf_for_submission') - @patch('jdhapi.views.articles.send_to_ojs.requests.post') + @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) @@ -223,9 +220,9 @@ def test_send_article_to_ojs_upload_fails(self, mock_post, mock_pdf): self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) self.assertIn('error', response.data) - @patch('jdhapi.views.articles.send_to_ojs.generate_pdf_for_submission') - @patch('jdhapi.views.articles.send_to_ojs.requests.post') - @patch('jdhapi.views.articles.send_to_ojs.requests.put') + @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) From 2f5edbaf3c4178b00f6e24d0746d57f3b84de426 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Tue, 10 Feb 2026 10:39:03 +0100 Subject: [PATCH 15/45] (feat) add submission_id to Article model --- .../0052_article_ojs_submission_id.py | 18 ++++++++++++++++++ jdhapi/models/article.py | 11 ++--------- jdhapi/serializers/article.py | 1 + 3 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 jdhapi/migrations/0052_article_ojs_submission_id.py 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/models/article.py b/jdhapi/models/article.py index c8c8837..517a3ed 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__) @@ -162,6 +154,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") 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", ] From 4ccf7b6a28e4c4561a457613fdd00a0d138e9cdd Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Wed, 11 Feb 2026 16:19:03 +0100 Subject: [PATCH 16/45] (feat) safeguard for article.data.title --- jdhapi/urls.py | 4 ++-- jdhapi/views/articles/ojs.py | 8 +++++--- jdhseo/utils.py | 6 +++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/jdhapi/urls.py b/jdhapi/urls.py index 7646f45..421ddc9 100644 --- a/jdhapi/urls.py +++ b/jdhapi/urls.py @@ -46,8 +46,8 @@ name="articles-facebook", ), path("api/articles/cover", views.get_social_cover_image, name="articles-social-media-cover"), - path("api/articles/submissions/ojs", views.get_count_submission_from_ojs, name="count-submission-from-ojs"), - path("api/articles/submission/ojs", views.send_article_to_ojs, name="articles-send-to-ojs"), + 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/authors/", views.AuthorList.as_view(), name="author-list"), path("api/authors//", views.AuthorDetail.as_view(), name="author-detail"), diff --git a/jdhapi/views/articles/ojs.py b/jdhapi/views/articles/ojs.py index 3cf2881..4f5b829 100644 --- a/jdhapi/views/articles/ojs.py +++ b/jdhapi/views/articles/ojs.py @@ -150,9 +150,11 @@ def submit_to_ojs(request): else: author.country = country author.save() - return - - 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 diff --git a/jdhseo/utils.py b/jdhseo/utils.py index f031cce..57a9158 100644 --- a/jdhseo/utils.py +++ b/jdhseo/utils.py @@ -297,7 +297,7 @@ def get_country_with_ROR(affiliation_name: str) -> str: Returns: str: Two-letter country code (e.g., 'US', 'FR', 'GB') or None if not found """ - logger.debug(f"START get_country_from_affiliation_name - searching for: {affiliation_name}") + 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() == "": @@ -324,7 +324,7 @@ def get_country_with_ROR(affiliation_name: str) -> str: country_code = first_result.get('locations', {})[0].get('geonames_details',{}).get('country_code', None) if country_code: - org_name = first_result.get('name', 'Unknown') + 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: @@ -341,7 +341,7 @@ def get_country_with_ROR(affiliation_name: str) -> str: logger.error(f"Error occurred while searching ROR: {err}") return None finally: - logger.debug("END get_country_from_affiliation_name") + logger.debug("END get_country_with_ROR") def get_employment_affiliation(orcid, api_url, headers): From 0fac0c944ce8ae428e265c0c7fd6bab8a661def3 Mon Sep 17 00:00:00 2001 From: eliselavy Date: Fri, 13 Feb 2026 16:45:21 +0100 Subject: [PATCH 17/45] (feat) different methods created for the copy-editing step --- jdhapi/models/article.py | 1 - jdhapi/urls.py | 2 + jdhapi/utils/run_github_action.py | 148 +++++++++++++++++++--- jdhapi/views/articles/__init__.py | 1 + jdhapi/views/articles/copy_editing.py | 174 ++++++++++++++++++++++++++ 5 files changed, 307 insertions(+), 19 deletions(-) create mode 100644 jdhapi/views/articles/copy_editing.py diff --git a/jdhapi/models/article.py b/jdhapi/models/article.py index c8c8837..0120dbb 100644 --- a/jdhapi/models/article.py +++ b/jdhapi/models/article.py @@ -43,7 +43,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): diff --git a/jdhapi/urls.py b/jdhapi/urls.py index fe17228..503b8cc 100644 --- a/jdhapi/urls.py +++ b/jdhapi/urls.py @@ -49,6 +49,8 @@ path("api/articles/cover", views.get_social_cover_image, name="articles-social-media-cover"), path("api/articles/ojs", 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/run_github_action.py b/jdhapi/utils/run_github_action.py index 5dc1ea4..b806caf 100644 --- a/jdhapi/utils/run_github_action.py +++ b/jdhapi/utils/run_github_action.py @@ -1,10 +1,15 @@ #!/usr/bin/env python3 +import logging import os import sys +import time +from datetime import datetime, timezone import requests from pathlib import Path from urllib.parse import urlparse +logger = logging.getLogger(__name__) + def trigger_workflow(repo_url, workflow_filename, token=None, ref="main"): """ @@ -13,11 +18,112 @@ def trigger_workflow(repo_url, workflow_filename, token=None, ref="main"): :param workflow_filename: Filename of the workflow in .github/workflows (e.g. "hello-world.yml") :param ref: Git ref (branch or tag) to run the workflow on """ - if not token: - from jdh.settings import GITHUB_ACCESS_TOKEN + token = _get_github_token(token) + owner, repo = _parse_owner_repo(repo_url) + + 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: + resp = requests.post(url, json=payload, headers=headers, timeout=10) + if resp.status_code == 204: + logger.info( + "Workflow '%s' dispatched on ref '%s' for %s/%s.", + workflow_filename, + ref, + owner, + repo, + ) + return + + logger.error( + "Failed to dispatch workflow '%s' (%s): %s", + workflow_filename, + resp.status_code, + resp.text, + ) + resp.raise_for_status() + except requests.RequestException as e: + logger.error("Workflow dispatch failed: %s", e) + raise + + +def trigger_workflow_and_wait( + repo_url, + workflow_filename, + token=None, + ref="main", + timeout_seconds=600, + poll_interval_seconds=5, +): + token = _get_github_token(token) + owner, repo = _parse_owner_repo(repo_url) + started_at = datetime.now(timezone.utc) + + 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 - token = GITHUB_ACCESS_TOKEN + while time.time() < deadline: + try: + resp = requests.get(runs_url, headers=headers, timeout=10) + resp.raise_for_status() + data = resp.json() + except requests.RequestException as e: + logger.error("Failed to list workflow runs: %s", e) + raise + 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}' завершён: {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): parsed = urlparse(repo_url) path = parsed.path.lstrip("/") @@ -26,19 +132,25 @@ def trigger_workflow(repo_url, workflow_filename, token=None, ref="main"): parts = path.split("/") if len(parts) >= 2: - owner = parts[0] - repo = parts[1] + return parts[0], parts[1] - 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} - resp = requests.post(url, json=payload, headers=headers) - if resp.status_code == 204: - print(f"Workflow '{workflow_filename}' dispatched on ref '{ref}'.") - else: - print(f"Failed to dispatch workflow: {resp.status_code}") - print(resp.json()) - resp.raise_for_status() \ No newline at end of file + raise ValueError(f"Invalid repository URL: '{repo_url}'") + + +def _get_github_token(token): + if token: + return token + from jdh.settings import GITHUB_ACCESS_TOKEN + + return GITHUB_ACCESS_TOKEN + + +def _parse_github_datetime(value): + if not value: + return None + try: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=timezone.utc + ) + except ValueError: + return None \ No newline at end of file diff --git a/jdhapi/views/articles/__init__.py b/jdhapi/views/articles/__init__.py index a4de807..8cd28bc 100644 --- a/jdhapi/views/articles/__init__.py +++ b/jdhapi/views/articles/__init__.py @@ -3,3 +3,4 @@ from .send_to_ojs import * from .social_media import * from .update_article import * +from .copy_editing import * diff --git a/jdhapi/views/articles/copy_editing.py b/jdhapi/views/articles/copy_editing.py new file mode 100644 index 0000000..bd008c7 --- /dev/null +++ b/jdhapi/views/articles/copy_editing.py @@ -0,0 +1,174 @@ +import io +import logging +import threading +import requests +from django.conf import settings +from django.http import HttpResponse +from django.core.mail import EmailMessage +from jsonschema.exceptions import ValidationError +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.run_github_action import trigger_workflow + +logger = logging.getLogger(__name__) + + +@api_view(["GET"]) +@permission_classes([IsAdminUser]) +def get_docx(request): + """ + Helper function to get the docx file path from the request. + """ + branch_name = "pandoc" + pid = request.GET.get("pid") + + if not pid: + return Response({"error": "Article PID is required."}, status=400) + try: + 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( + "Trigger pandoc workflow for pid=%s, repo=%s", + pid, + article.repository_url, + ) + run_pandoc_workflow(article.repository_url, async_trigger=True) + logger.debug("Pandoc workflow trigger dispatched for pid=%s", pid) + except Exception as e: + return Response( + {"error": "Failed to trigger pandoc workflow", "details": str(e)}, + status=502, + ) + + 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"'}, + ) + 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(["GET"]) +@permission_classes([IsAdminUser]) +def send_docx_email(request): + """ + Send the docx as an email attachment. + """ + branch_name = "pandoc" + pid = request.GET.get("pid") + if not pid: + return Response({"error": "Article PID is required."}, status=400) + + try: + docx_bytes = fetch_docx_bytes(pid, branch_name) + send_email_copy_editor(pid, docx_bytes) + return Response({"status": "sent", "pid": pid}) + 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): + 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): + COPY_EDITOR_ADDRESS = "elisabeth.guerard@uni.lu" + body = "Dear Andy, find in attachment the docx to review for copy-editing" + 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, async_trigger=True): + logger.debug( + "run_pandoc_workflow(async_trigger=%s) repo=%s", + async_trigger, + repository_url, + ) + if async_trigger: + thread = threading.Thread( + target=_run_pandoc_workflow_safe, args=(repository_url,), daemon=True + ) + thread.start() + return + + _run_pandoc_workflow_safe(repository_url, raise_errors=True) + + +def _run_pandoc_workflow_safe(repository_url, raise_errors=False): + try: + logger.debug( + "_run_pandoc_workflow_safe(raise_errors=%s) repo=%s", + raise_errors, + repository_url, + ) + trigger_workflow( + repository_url, + workflow_filename="pandoc.yml", + ) + logger.debug("Pandoc workflow trigger completed repo=%s", repository_url) + except Exception as e: + logger.error("run_pandoc_workflow failed: %s", e) + if raise_errors: + raise \ No newline at end of file From 342188cef30724126caa43dfd4b7bfdcf574bf2f Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Mon, 16 Feb 2026 13:41:06 +0100 Subject: [PATCH 18/45] (test) fix the test for send_to_ojs --- jdhapi/views/articles/ojs.py | 7 ------- tests/jdhapi/views/articles/test_ojs.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/jdhapi/views/articles/ojs.py b/jdhapi/views/articles/ojs.py index 4f5b829..4c16929 100644 --- a/jdhapi/views/articles/ojs.py +++ b/jdhapi/views/articles/ojs.py @@ -80,13 +80,6 @@ def send_article_to_ojs(request): {"error": "Invalid data format", "details": str(e)}, status=status.HTTP_400_BAD_REQUEST, ) - except (KeyError, IndexError) as e: - logger.exception(f"Data invalid after validation: {str(e)}") - return Response( - {"error": "KeyError", "message": str(e)}, - status=status.HTTP_400_BAD_REQUEST, - content_type="application/json", - ) except Exception as e: logger.exception("An unexpected error occurred.") return Response( diff --git a/tests/jdhapi/views/articles/test_ojs.py b/tests/jdhapi/views/articles/test_ojs.py index c541402..fa02782 100644 --- a/tests/jdhapi/views/articles/test_ojs.py +++ b/tests/jdhapi/views/articles/test_ojs.py @@ -54,7 +54,7 @@ def setUp(self): issue=self.issue ) - self.url = '/api/articles/submission/ojs' + self.url = '/api/articles/ojs/submission' self.valid_payload = {'pid': 'test-article-001'} def test_send_article_to_ojs_not_authenticated(self): @@ -190,7 +190,7 @@ def test_send_article_to_ojs_missing_author_fields(self, mock_schema): payload = {'pid': 'test-article-002'} response = self.client.post(self.url, payload, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + 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') From 38add4094de66e8b3e3eaccf59ba86537a2cf632 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Mon, 16 Feb 2026 14:43:15 +0100 Subject: [PATCH 19/45] (test) add mock to prevent blank submission to be created to OJS --- tests/jdhapi/views/articles/test_ojs.py | 27 +++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/jdhapi/views/articles/test_ojs.py b/tests/jdhapi/views/articles/test_ojs.py index fa02782..024117c 100644 --- a/tests/jdhapi/views/articles/test_ojs.py +++ b/tests/jdhapi/views/articles/test_ojs.py @@ -87,7 +87,7 @@ def test_send_article_to_ojs_success(self, mock_put, mock_post, mock_pdf): # Mock blank submission creation response mock_blank_submission_response = Mock() - mock_blank_submission_response.status_code = 201 + mock_blank_submission_response.status_code = 200 mock_blank_submission_response.json.return_value = { 'id': 123, 'currentPublicationId': 456 @@ -154,11 +154,29 @@ def test_send_article_to_ojs_article_not_found(self, mock_schema): 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): + 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 @@ -186,6 +204,11 @@ def test_send_article_to_ojs_missing_author_fields(self, mock_schema): 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') From 4f6d801895d576eab7e41dc8a64b4ed259a455a7 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Mon, 16 Feb 2026 11:59:53 +0100 Subject: [PATCH 20/45] (feat) altcha implementation with create _challenge and GET route --- .env.example | 1 + Pipfile | 2 ++ Pipfile.lock | 20 +++++++++++++- jdhapi/urls.py | 1 + jdhapi/utils/altcha.py | 59 ++++++++++++++++++++++++++++++++++++++++ jdhapi/views/__init__.py | 1 + jdhapi/views/altcha.py | 38 ++++++++++++++++++++++++++ 7 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 jdhapi/utils/altcha.py create mode 100644 jdhapi/views/altcha.py diff --git a/.env.example b/.env.example index 7db1bf7..bb38f4f 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ 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 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/jdhapi/urls.py b/jdhapi/urls.py index 52fcc90..7f8cda6 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( diff --git a/jdhapi/utils/altcha.py b/jdhapi/utils/altcha.py new file mode 100644 index 0000000..2400147 --- /dev/null +++ b/jdhapi/utils/altcha.py @@ -0,0 +1,59 @@ +import datetime +import logging +import dataclasses +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) + + # Manually convert the Challenge object to a dictionary + challenge_dict = { + 'algorithm': challenge.algorithm, + 'challenge': challenge.challenge, + 'maxnumber': challenge.max_number, + 'salt': challenge.salt, + 'signature': challenge.signature, + } + + logger.info("Challenge converted to a dict") + + 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. + + """ + + ok, err = verify_solution(payload, hmac_key, check_expires=True) + if err: + logger.error("Captcha - Error:", err) + elif ok: + logger.info("Captcha - Solution verified!") + else: + logger.error("Captcha - Invalid solution.") \ No newline at end of file diff --git a/jdhapi/views/__init__.py b/jdhapi/views/__init__.py index d1afe68..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 * diff --git a/jdhapi/views/altcha.py b/jdhapi/views/altcha.py new file mode 100644 index 0000000..da8af8f --- /dev/null +++ b/jdhapi/views/altcha.py @@ -0,0 +1,38 @@ +from django.http import JsonResponse +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.response import Response +from jdhapi.utils.altcha import create_captcha_challenge + +from .logger import logger as get_logger + +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, + ) + + + From 8b3d051aea4cfd4fc02211d7d436d81d27545872 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Mon, 16 Feb 2026 12:00:17 +0100 Subject: [PATCH 21/45] (feat) add cors headers for the recaptcha --- jdh/settings.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/jdh/settings.py b/jdh/settings.py index f234ca1..618c657 100644 --- a/jdh/settings.py +++ b/jdh/settings.py @@ -30,18 +30,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 +84,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", @@ -288,4 +294,4 @@ #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") +OJS_API_URL = get_env_variable("OJS_API_URL", "http://ojs.journalofdigitalhistory.org") \ No newline at end of file From aaa475988ece2d2775e2c428f564d3aa22195fb4 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Mon, 16 Feb 2026 18:36:21 +0100 Subject: [PATCH 22/45] (feat) validation of the captcha before creating abstract submission in db --- jdhapi/utils/altcha.py | 9 ++----- jdhapi/views/abstracts/submit_abstract.py | 33 ++++++++++++++++++++--- jdhapi/views/altcha.py | 5 ++-- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/jdhapi/utils/altcha.py b/jdhapi/utils/altcha.py index 2400147..5a409aa 100644 --- a/jdhapi/utils/altcha.py +++ b/jdhapi/utils/altcha.py @@ -1,6 +1,5 @@ import datetime import logging -import dataclasses from altcha import ChallengeOptions, create_challenge, verify_solution from django.conf import settings @@ -51,9 +50,5 @@ def verify_challenge_solution(payload: dict) -> bool : """ ok, err = verify_solution(payload, hmac_key, check_expires=True) - if err: - logger.error("Captcha - Error:", err) - elif ok: - logger.info("Captcha - Solution verified!") - else: - logger.error("Captcha - Invalid solution.") \ No newline at end of file + + return ok, err \ No newline at end of file diff --git a/jdhapi/views/abstracts/submit_abstract.py b/jdhapi/views/abstracts/submit_abstract.py index eeb400b..086923d 100644 --- a/jdhapi/views/abstracts/submit_abstract.py +++ b/jdhapi/views/abstracts/submit_abstract.py @@ -1,7 +1,9 @@ +from django.conf import settings 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.utils.altcha import verify_challenge_solution from jdhapi.utils.logger import logger as get_logger from jdhapi.serializers import AbstractSlimSerializer from jsonschema.exceptions import ValidationError, SchemaError @@ -19,7 +21,6 @@ document_json_schema = JSONSchema(filepath="submit_abstract.json") - def get_default_body(id, title, firstname, lastname): default_body = dedent( f""" @@ -57,9 +58,34 @@ 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) + logger.info("Checking Captcha Solution") + + payload = request.data.get("altcha") + + if not payload: + return Response({"error": "Altcha payload missing"}, status=status.HTTP_400_BAD_REQUEST) + + try: + verified, err = verify_challenge_solution(payload) + + if not verified: + return Response({"error": f"Invalid Altcha payload: {err}"}, status=status.HTTP_400_BAD_REQUEST) + + data = validate_and_submit_abstract(request) + return Response(data, status=status.HTTP_201_CREATED) + + except Exception as e: + return Response({"error": f"Failed to process Altcha payload: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST) + except ValidationError as e: logger.exception("Validation error occurred.") response = Response( @@ -95,6 +121,7 @@ def submit_abstract(request): def validate_and_submit_abstract(request): + print("🚀 ~ file: submit_abstract.py:99 ~ request:", request) logger.info("Start JSON validation") with transaction.atomic(): # Single transaction block for the entire function diff --git a/jdhapi/views/altcha.py b/jdhapi/views/altcha.py index da8af8f..085d507 100644 --- a/jdhapi/views/altcha.py +++ b/jdhapi/views/altcha.py @@ -1,10 +1,9 @@ -from django.http import JsonResponse +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 -from jdhapi.utils.altcha import create_captcha_challenge -from .logger import logger as get_logger logger=get_logger() From 09b8589fc2bfb8364ab3d816d12a2d9461c3eaf7 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Tue, 17 Feb 2026 12:08:31 +0100 Subject: [PATCH 23/45] (test) test_abstract_submit adapted for captcha --- jdhapi/views/abstracts/submit_abstract.py | 38 +++++------ tests/__init__.py | 6 +- .../{ => abstracts}/test_search_abstract.py | 0 .../{ => abstracts}/test_submit_abstract.py | 68 ++++++++++++++++--- 4 files changed, 80 insertions(+), 32 deletions(-) rename tests/jdhapi/views/{ => abstracts}/test_search_abstract.py (100%) rename tests/jdhapi/views/{ => abstracts}/test_submit_abstract.py (76%) diff --git a/jdhapi/views/abstracts/submit_abstract.py b/jdhapi/views/abstracts/submit_abstract.py index 086923d..8f04edb 100644 --- a/jdhapi/views/abstracts/submit_abstract.py +++ b/jdhapi/views/abstracts/submit_abstract.py @@ -67,25 +67,9 @@ def submit_abstract(request): """ try: - logger.info("Checking Captcha Solution") - - payload = request.data.get("altcha") - - if not payload: - return Response({"error": "Altcha payload missing"}, status=status.HTTP_400_BAD_REQUEST) - - try: - verified, err = verify_challenge_solution(payload) - - if not verified: - return Response({"error": f"Invalid Altcha payload: {err}"}, status=status.HTTP_400_BAD_REQUEST) - - data = validate_and_submit_abstract(request) - return Response(data, status=status.HTTP_201_CREATED) - - except Exception as e: - return Response({"error": f"Failed to process Altcha payload: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST) - + 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( @@ -121,7 +105,21 @@ def submit_abstract(request): def validate_and_submit_abstract(request): - print("🚀 ~ file: submit_abstract.py:99 ~ request:", 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 diff --git a/tests/__init__.py b/tests/__init__.py index 559e44f..52e5910 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -3,12 +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 * \ 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 76% rename from tests/jdhapi/views/test_submit_abstract.py rename to tests/jdhapi/views/abstracts/test_submit_abstract.py index f1867d7..44fb15d 100644 --- a/tests/jdhapi/views/test_submit_abstract.py +++ b/tests/jdhapi/views/abstracts/test_submit_abstract.py @@ -1,9 +1,12 @@ -from rest_framework.test import APITestCase -from rest_framework import status +from datetime import date 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, Dataset, CallForPaper +from rest_framework.test import APITestCase +from rest_framework import status + + +from unittest.mock import Mock, patch class SubmitAbstractTestCase(APITestCase): @@ -46,6 +49,7 @@ def setUp(self): "dateLastModified": date.today(), "languagePreference": "Default", "termsAccepted": True, + "altcha": "fake-altcha-payload" } self.invalid_payload_missing_github_id = { @@ -79,6 +83,7 @@ def setUp(self): "dateLastModified": date.today(), "languagePreference": "Default", "termsAccepted": True, + "altcha": "fake-altcha-payload" } self.invalid_payload = { @@ -87,8 +92,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 +112,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 +132,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 +153,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 +164,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", @@ -173,3 +199,27 @@ 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") + + @patch('jdhapi.views.abstracts.submit_abstract.verify_challenge_solution') + def test_failed_to_solve_the_captcha(self, mock_captcha): + """Test that an existing author's information is updated.""" + + # 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 From c38a0125ba3d1d77b5265ce533fab8fa1815afbf Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Tue, 17 Feb 2026 12:15:29 +0100 Subject: [PATCH 24/45] (test) cors_allowed_origins added for django unit test --- .github/workflows/run_unit_tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index d0f263c..1f2b67f 100644 --- a/.github/workflows/run_unit_tests.yml +++ b/.github/workflows/run_unit_tests.yml @@ -53,6 +53,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 From 2b00158749fa903ca66479bb277a320d6b024136 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Tue, 17 Feb 2026 12:19:26 +0100 Subject: [PATCH 25/45] (test) add altcha hmac key --- .github/workflows/run_unit_tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index 1f2b67f..21584b1 100644 --- a/.github/workflows/run_unit_tests.yml +++ b/.github/workflows/run_unit_tests.yml @@ -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 From ce6dadd0c7ed8dbe7f4e95992a5017501a52a1b0 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Tue, 17 Feb 2026 14:10:07 +0100 Subject: [PATCH 26/45] (fix) fixing typos for test --- jdhapi/utils/altcha.py | 7 ++----- tests/jdhapi/views/abstracts/test_submit_abstract.py | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/jdhapi/utils/altcha.py b/jdhapi/utils/altcha.py index 5a409aa..1c57588 100644 --- a/jdhapi/utils/altcha.py +++ b/jdhapi/utils/altcha.py @@ -14,7 +14,6 @@ def create_captcha_challenge(): :return: The created challenge. :rtype: Challenge """ - logger.info("[create_challenge] Starting create challenge for captcha") # Create a new challenge @@ -26,7 +25,6 @@ def create_captcha_challenge(): challenge = create_challenge(options) logger.info(f"Challenge created:", challenge) - # Manually convert the Challenge object to a dictionary challenge_dict = { 'algorithm': challenge.algorithm, 'challenge': challenge.challenge, @@ -34,8 +32,7 @@ def create_captcha_challenge(): 'salt': challenge.salt, 'signature': challenge.signature, } - - logger.info("Challenge converted to a dict") + logger.info("Challenge converted to a dictionary object") return challenge_dict @@ -46,8 +43,8 @@ def verify_challenge_solution(payload: dict) -> bool : :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) diff --git a/tests/jdhapi/views/abstracts/test_submit_abstract.py b/tests/jdhapi/views/abstracts/test_submit_abstract.py index 44fb15d..0312f70 100644 --- a/tests/jdhapi/views/abstracts/test_submit_abstract.py +++ b/tests/jdhapi/views/abstracts/test_submit_abstract.py @@ -202,7 +202,7 @@ def test_update_existing_author(self, mock_captcha): @patch('jdhapi.views.abstracts.submit_abstract.verify_challenge_solution') def test_failed_to_solve_the_captcha(self, mock_captcha): - """Test that an existing author's information is updated.""" + """Test that the user did not solve the captcha correctly.""" # Mock the captcha verification to fail mock_captcha.return_value = False, None From 3eafd1d21490680e0a25a0b88a01aab25f362cd4 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Tue, 17 Feb 2026 15:16:06 +0100 Subject: [PATCH 27/45] (fix) update requirements.txt with altcha and django-cors-headers --- requirements.txt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 From d39d7a872321e9d255ab8a2b914f48045fd91512 Mon Sep 17 00:00:00 2001 From: eliselavy Date: Tue, 17 Feb 2026 17:24:10 +0100 Subject: [PATCH 28/45] [fix] trigger_workflow_and_wait --- jdhapi/signals.py | 2 +- jdhapi/views/articles/copy_editing.py | 39 +++++++-------------------- 2 files changed, 11 insertions(+), 30 deletions(-) diff --git a/jdhapi/signals.py b/jdhapi/signals.py index 067eab1..e286e1d 100644 --- a/jdhapi/signals.py +++ b/jdhapi/signals.py @@ -5,7 +5,7 @@ from django.dispatch import receiver from jdhapi.models import Article from jdhapi.utils.articles import convert_string_to_base64 -from jdhapi.utils.run_github_action import trigger_workflow + @receiver(pre_save, sender=Article) diff --git a/jdhapi/views/articles/copy_editing.py b/jdhapi/views/articles/copy_editing.py index bd008c7..e948886 100644 --- a/jdhapi/views/articles/copy_editing.py +++ b/jdhapi/views/articles/copy_editing.py @@ -1,6 +1,5 @@ import io import logging -import threading import requests from django.conf import settings from django.http import HttpResponse @@ -13,7 +12,7 @@ from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from jdhapi.models import Article -from jdhapi.utils.run_github_action import trigger_workflow +from jdhapi.utils.run_github_action import trigger_workflow_and_wait logger = logging.getLogger(__name__) @@ -45,15 +44,15 @@ def get_docx(request): try: logger.debug( - "Trigger pandoc workflow for pid=%s, repo=%s", + "Run pandoc workflow and wait for completion pid=%s, repo=%s", pid, article.repository_url, ) - run_pandoc_workflow(article.repository_url, async_trigger=True) - logger.debug("Pandoc workflow trigger dispatched for pid=%s", pid) + run_pandoc_workflow(article.repository_url) + logger.debug("Pandoc workflow completed for pid=%s", pid) except Exception as e: return Response( - {"error": "Failed to trigger pandoc workflow", "details": str(e)}, + {"error": "Failed to run pandoc workflow", "details": str(e)}, status=502, ) @@ -140,35 +139,17 @@ def send_email_copy_editor(pid, docx_bytes): message.send(fail_silently=False) -def run_pandoc_workflow(repository_url, async_trigger=True): - logger.debug( - "run_pandoc_workflow(async_trigger=%s) repo=%s", - async_trigger, - repository_url, - ) - if async_trigger: - thread = threading.Thread( - target=_run_pandoc_workflow_safe, args=(repository_url,), daemon=True - ) - thread.start() - return - - _run_pandoc_workflow_safe(repository_url, raise_errors=True) - - -def _run_pandoc_workflow_safe(repository_url, raise_errors=False): +def run_pandoc_workflow(repository_url): try: logger.debug( - "_run_pandoc_workflow_safe(raise_errors=%s) repo=%s", - raise_errors, + "run_pandoc_workflow wait repo=%s", repository_url, ) - trigger_workflow( + trigger_workflow_and_wait( repository_url, workflow_filename="pandoc.yml", ) - logger.debug("Pandoc workflow trigger completed repo=%s", repository_url) + logger.debug("Pandoc workflow completed repo=%s", repository_url) except Exception as e: logger.error("run_pandoc_workflow failed: %s", e) - if raise_errors: - raise \ No newline at end of file + raise \ No newline at end of file From 4ca1b563c0f2f563410ccc66e4428ee4f65c2b74 Mon Sep 17 00:00:00 2001 From: eliselavy Date: Wed, 18 Feb 2026 10:44:43 +0100 Subject: [PATCH 29/45] [fix] ensure_pandoc_workflow --- jdhapi/views/articles/copy_editing.py | 66 ++++++++++++++++----------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/jdhapi/views/articles/copy_editing.py b/jdhapi/views/articles/copy_editing.py index e948886..4a918d7 100644 --- a/jdhapi/views/articles/copy_editing.py +++ b/jdhapi/views/articles/copy_editing.py @@ -29,32 +29,9 @@ def get_docx(request): if not pid: return Response({"error": "Article PID is required."}, status=400) try: - 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, - ) + workflow_error = ensure_pandoc_workflow(pid) + if workflow_error: + return workflow_error docx_bytes = fetch_docx_bytes(pid, branch_name) return HttpResponse( @@ -84,6 +61,10 @@ def send_docx_email(request): 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) return Response({"status": "sent", "pid": pid}) @@ -152,4 +133,35 @@ def run_pandoc_workflow(repository_url): logger.debug("Pandoc workflow completed repo=%s", repository_url) except Exception as e: logger.error("run_pandoc_workflow failed: %s", e) - raise \ No newline at end of file + raise + + +def ensure_pandoc_workflow(pid): + try: + 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, + ) + + 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, + ) + + return None \ No newline at end of file From 0d51089af49b7e0ab008f99a6b6d37a8eee65eb2 Mon Sep 17 00:00:00 2001 From: eliselavy Date: Wed, 18 Feb 2026 14:24:11 +0100 Subject: [PATCH 30/45] [feature] CopyEditingHandler --- jdhapi/models/article.py | 9 ++++++++- jdhapi/views/articles/articles.py | 3 ++- jdhapi/views/articles/copy_editing.py | 4 ++++ jdhapi/views/articles/status_handlers.py | 22 ++++++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/jdhapi/models/article.py b/jdhapi/models/article.py index 0120dbb..e565010 100644 --- a/jdhapi/models/article.py +++ b/jdhapi/models/article.py @@ -62,7 +62,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 = ( diff --git a/jdhapi/views/articles/articles.py b/jdhapi/views/articles/articles.py index 81c6837..39fb277 100644 --- a/jdhapi/views/articles/articles.py +++ b/jdhapi/views/articles/articles.py @@ -4,7 +4,7 @@ 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 TechnicalReviewHandler +from jdhapi.views.articles.status_handlers import TechnicalReviewHandler, CopyEditingHandler from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework.views import APIView @@ -94,6 +94,7 @@ class ArticleStatus(APIView): permission_classes = [IsAdminUser] STATUS_HANDLERS = { 'TECHNICAL_REVIEW': TechnicalReviewHandler(), + 'COPY_EDITING': CopyEditingHandler(), } def patch(self, request, abstract__pid): diff --git a/jdhapi/views/articles/copy_editing.py b/jdhapi/views/articles/copy_editing.py index 4a918d7..e729dbe 100644 --- a/jdhapi/views/articles/copy_editing.py +++ b/jdhapi/views/articles/copy_editing.py @@ -60,6 +60,10 @@ def send_docx_email(request): if not pid: return Response({"error": "Article PID is required."}, status=400) + return send_docx_email_pid(pid, branch_name=branch_name) + + +def send_docx_email_pid(pid, branch_name="pandoc"): try: workflow_error = ensure_pandoc_workflow(pid) if workflow_error: diff --git a/jdhapi/views/articles/status_handlers.py b/jdhapi/views/articles/status_handlers.py index cd998cb..16e4b19 100644 --- a/jdhapi/views/articles/status_handlers.py +++ b/jdhapi/views/articles/status_handlers.py @@ -1,5 +1,9 @@ +import logging from rest_framework.response import Response from jdhapi.models import Article +from jdhapi.views.articles.copy_editing import send_docx_email_pid + +logger = logging.getLogger(__name__) class StatusHandler: def handle(self, article, request): @@ -7,7 +11,25 @@ def handle(self, article, request): 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) + email_response = send_docx_email_pid(article.abstract.pid) + if getattr(email_response, "status_code", 200) >= 400: + logger.warning( + "COPY_EDITING email failed pid=%s status_code=%s", + article.abstract.pid, + getattr(email_response, "status_code", None), + ) + return email_response + + 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}) + From 53117eaacf64bfc111ec34159fe5b4b0e9d8ef78 Mon Sep 17 00:00:00 2001 From: eliselavy Date: Wed, 18 Feb 2026 16:52:54 +0100 Subject: [PATCH 31/45] [feature] peer-review handler --- jdhapi/views/articles/articles.py | 3 ++- jdhapi/views/articles/status_handlers.py | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/jdhapi/views/articles/articles.py b/jdhapi/views/articles/articles.py index 39fb277..07048b1 100644 --- a/jdhapi/views/articles/articles.py +++ b/jdhapi/views/articles/articles.py @@ -4,7 +4,7 @@ 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 TechnicalReviewHandler, CopyEditingHandler +from jdhapi.views.articles.status_handlers import TechnicalReviewHandler, CopyEditingHandler, PeerReviewHandler from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework.views import APIView @@ -95,6 +95,7 @@ class ArticleStatus(APIView): STATUS_HANDLERS = { 'TECHNICAL_REVIEW': TechnicalReviewHandler(), 'COPY_EDITING': CopyEditingHandler(), + 'PEER_REVIEW': PeerReviewHandler(), } def patch(self, request, abstract__pid): diff --git a/jdhapi/views/articles/status_handlers.py b/jdhapi/views/articles/status_handlers.py index 16e4b19..1c4826e 100644 --- a/jdhapi/views/articles/status_handlers.py +++ b/jdhapi/views/articles/status_handlers.py @@ -33,3 +33,11 @@ def handle(self, article, request): 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}) + \ No newline at end of file From 9a5598233fc3994320d985a8750eb4041dc7e92b Mon Sep 17 00:00:00 2001 From: eliselavy Date: Thu, 19 Feb 2026 17:19:56 +0100 Subject: [PATCH 32/45] [feature] handler PUBLISHED - save_citation no more cleery task --- jdhapi/admin.py | 4 +-- jdhapi/tasks.py | 13 --------- jdhapi/utils/articles.py | 14 ++++++++- jdhapi/views/articles/articles.py | 3 +- jdhapi/views/articles/status_handlers.py | 37 ++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 17 deletions(-) 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/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/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/views/articles/articles.py b/jdhapi/views/articles/articles.py index 07048b1..f21e74e 100644 --- a/jdhapi/views/articles/articles.py +++ b/jdhapi/views/articles/articles.py @@ -4,7 +4,7 @@ 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 TechnicalReviewHandler, CopyEditingHandler, PeerReviewHandler +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 @@ -96,6 +96,7 @@ class ArticleStatus(APIView): 'TECHNICAL_REVIEW': TechnicalReviewHandler(), 'COPY_EDITING': CopyEditingHandler(), 'PEER_REVIEW': PeerReviewHandler(), + 'PUBLISHED' : PublishedHandler() } def patch(self, request, abstract__pid): diff --git a/jdhapi/views/articles/status_handlers.py b/jdhapi/views/articles/status_handlers.py index 1c4826e..defaaea 100644 --- a/jdhapi/views/articles/status_handlers.py +++ b/jdhapi/views/articles/status_handlers.py @@ -1,7 +1,10 @@ import logging +from django.utils import timezone +from rest_framework import status from rest_framework.response import Response from jdhapi.models import Article from jdhapi.views.articles.copy_editing import send_docx_email_pid +from jdhapi.utils.articles import save_citation logger = logging.getLogger(__name__) @@ -40,4 +43,38 @@ def handle(self, article, request): 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 From 9b17eb49147165adcd349e33af41a7d894dabe5f Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Thu, 26 Feb 2026 13:54:12 +0100 Subject: [PATCH 33/45] (review) add variables to .env + renaming + merging function to api endpoint + docstring --- jdh/settings.py | 3 + jdhapi/forms/articleForm.py | 2 +- jdhapi/models/article.py | 10 +- jdhapi/urls.py | 12 +- ...{run_github_action.py => github_action.py} | 110 ++++++++++++------ ...tup_repository.py => github_repository.py} | 0 jdhapi/views/articles/copy_editing.py | 102 +++++++++++----- jdhapi/views/articles/status_handlers.py | 9 +- 8 files changed, 160 insertions(+), 88 deletions(-) rename jdhapi/utils/{run_github_action.py => github_action.py} (52%) rename jdhapi/utils/{gitup_repository.py => github_repository.py} (100%) diff --git a/jdh/settings.py b/jdh/settings.py index f234ca1..175d50e 100644 --- a/jdh/settings.py +++ b/jdh/settings.py @@ -289,3 +289,6 @@ #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", "") +COPY_EDITOR_NAME=get_env_variable("COPY_EDITOR_NAME", "") \ No newline at end of file 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/models/article.py b/jdhapi/models/article.py index e565010..6cc47f8 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__) @@ -144,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, diff --git a/jdhapi/urls.py b/jdhapi/urls.py index 503b8cc..4bb25a0 100644 --- a/jdhapi/urls.py +++ b/jdhapi/urls.py @@ -24,17 +24,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.ArticleStatus.as_view(), name='article-status'), - 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", diff --git a/jdhapi/utils/run_github_action.py b/jdhapi/utils/github_action.py similarity index 52% rename from jdhapi/utils/run_github_action.py rename to jdhapi/utils/github_action.py index b806caf..4bfa7e8 100644 --- a/jdhapi/utils/run_github_action.py +++ b/jdhapi/utils/github_action.py @@ -1,23 +1,24 @@ #!/usr/bin/env python3 import logging -import os -import sys +import requests import time from datetime import datetime, timezone -import requests -from pathlib import Path from urllib.parse import urlparse +from jdh.settings import GITHUB_ACCESS_TOKEN logger = logging.getLogger(__name__) def trigger_workflow(repo_url, workflow_filename, token=None, ref="main"): """ - :param owner: GitHub username or organization - :param repo: Repository name + :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 - """ + """ + + logger.info("[trigger_workflow] - Trigger workflow '%s' on ref '%s' for %s/%s", workflow_filename, ref, owner, repo) + token = _get_github_token(token) owner, repo = _parse_owner_repo(repo_url) @@ -28,8 +29,8 @@ def trigger_workflow(repo_url, workflow_filename, token=None, ref="main"): } payload = {"ref": ref} try: - resp = requests.post(url, json=payload, headers=headers, timeout=10) - if resp.status_code == 204: + 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, @@ -37,18 +38,17 @@ def trigger_workflow(repo_url, workflow_filename, token=None, ref="main"): owner, repo, ) - return - - logger.error( - "Failed to dispatch workflow '%s' (%s): %s", - workflow_filename, - resp.status_code, - resp.text, - ) - resp.raise_for_status() + 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 + raise requests.RequestException(f"Workflow dispatch failed: {e}") from e def trigger_workflow_and_wait( @@ -59,6 +59,17 @@ def trigger_workflow_and_wait( 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 + """ + + logger.info("[trigger_workflow_and_wait] - Trigger workflow and wait for '%s' on ref '%s' for %s/%s", workflow_filename, ref, owner, repo) + token = _get_github_token(token) owner, repo = _parse_owner_repo(repo_url) started_at = datetime.now(timezone.utc) @@ -79,12 +90,12 @@ def trigger_workflow_and_wait( while time.time() < deadline: try: - resp = requests.get(runs_url, headers=headers, timeout=10) - resp.raise_for_status() - data = resp.json() + 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 + 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")) @@ -109,7 +120,7 @@ def trigger_workflow_and_wait( ) return raise RuntimeError( - f"Workflow '{workflow_filename}' завершён: {conclusion}" + f"Workflow '{workflow_filename}' interrupted: {conclusion}" ) break @@ -124,6 +135,14 @@ def trigger_workflow_and_wait( 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("/") @@ -131,26 +150,47 @@ def _parse_owner_repo(repo_url): path = path[:-4] parts = path.split("/") - if len(parts) >= 2: - return parts[0], parts[1] - raise ValueError(f"Invalid repository URL: '{repo_url}'") + 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): - if token: - return token - from jdh.settings import GITHUB_ACCESS_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 - return GITHUB_ACCESS_TOKEN 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.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=timezone.utc - ) - except ValueError: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except (ValueError, AttributeError): + logger.error("Failed to parse GitHub datetime value: '%s'", value) return None \ No newline at end of file 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/articles/copy_editing.py b/jdhapi/views/articles/copy_editing.py index e729dbe..34bcbf0 100644 --- a/jdhapi/views/articles/copy_editing.py +++ b/jdhapi/views/articles/copy_editing.py @@ -1,33 +1,39 @@ -import io import logging import requests from django.conf import settings -from django.http import HttpResponse from django.core.mail import EmailMessage -from jsonschema.exceptions import ValidationError +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 -from jdhapi.models import Article -from jdhapi.utils.run_github_action import trigger_workflow_and_wait logger = logging.getLogger(__name__) +COPY_EDITOR_ADDRESS = settings.COPY_EDITOR_ADDRESS +COPY_EDITOR_NAME = settings.COPY_EDITOR_NAME @api_view(["GET"]) @permission_classes([IsAdminUser]) def get_docx(request): """ - Helper function to get the docx file path from the 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: @@ -38,6 +44,7 @@ def get_docx(request): 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) @@ -53,17 +60,20 @@ def get_docx(request): @permission_classes([IsAdminUser]) def send_docx_email(request): """ + GET api/articles/docx/email + Send the docx as an email attachment. + :params pid: the article PID + :params branch_name: the branch name where the docx file is located, by default "pandoc" """ + logger.info("GET api/articles/docx/email") + branch_name = "pandoc" pid = request.GET.get("pid") + if not pid: return Response({"error": "Article PID is required."}, status=400) - return send_docx_email_pid(pid, branch_name=branch_name) - - -def send_docx_email_pid(pid, branch_name="pandoc"): try: workflow_error = ensure_pandoc_workflow(pid) if workflow_error: @@ -71,7 +81,8 @@ def send_docx_email_pid(pid, branch_name="pandoc"): docx_bytes = fetch_docx_bytes(pid, branch_name) send_email_copy_editor(pid, docx_bytes) - return Response({"status": "sent", "pid": pid}) + return Response({"message": "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: @@ -85,6 +96,13 @@ def send_docx_email_pid(pid, branch_name="pandoc"): 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}"} @@ -99,7 +117,9 @@ def fetch_docx_bytes(pid, branch_name): 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}'.") @@ -107,11 +127,17 @@ def fetch_docx_bytes(pid, branch_name): def send_email_copy_editor(pid, docx_bytes): - COPY_EDITOR_ADDRESS = "elisabeth.guerard@uni.lu" - body = "Dear Andy, find in attachment the docx to review for copy-editing" + """ + 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) + + body = f"Dear {COPY_EDITOR_NAME}, find in attachment the docx to review for copy editing." filename = f"article_{pid}.docx" message = EmailMessage( - subject="Article to review for copy-editing", + subject="Article to review for copy editing", body=body, from_email="jdh.admin@uni.lu", to=[COPY_EDITOR_ADDRESS], @@ -125,6 +151,12 @@ def send_email_copy_editor(pid, docx_bytes): 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", @@ -137,28 +169,36 @@ def run_pandoc_workflow(repository_url): logger.debug("Pandoc workflow completed repo=%s", repository_url) except Exception as e: logger.error("run_pandoc_workflow failed: %s", e) - raise + return Response( + {"error": "Failed to run pandoc workflow", "details": str(e)}, + status=502, + ) -def ensure_pandoc_workflow(pid): - try: - try: - article = Article.objects.get(abstract__pid=pid) - except Article.DoesNotExist: - return Response( - {"error": f"Article not found for PID '{pid}'."}, status=404 - ) +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) - if not article.repository_url: + 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 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) @@ -166,6 +206,4 @@ def ensure_pandoc_workflow(pid): return Response( {"error": "Failed to run pandoc workflow", "details": str(e)}, status=502, - ) - - return None \ No newline at end of file + ) \ No newline at end of file diff --git a/jdhapi/views/articles/status_handlers.py b/jdhapi/views/articles/status_handlers.py index defaaea..78374dc 100644 --- a/jdhapi/views/articles/status_handlers.py +++ b/jdhapi/views/articles/status_handlers.py @@ -1,10 +1,9 @@ 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 -from jdhapi.models import Article -from jdhapi.views.articles.copy_editing import send_docx_email_pid -from jdhapi.utils.articles import save_citation logger = logging.getLogger(__name__) @@ -13,7 +12,7 @@ def handle(self, article, request): raise NotImplementedError class TechnicalReviewHandler(StatusHandler): - def handle(self, article, request): + def handle(self, article, request): logger.info("Setting status TECHNICAL_REVIEW pid=%s", article.abstract.pid) article.status = article.Status.TECHNICAL_REVIEW article.save() @@ -22,7 +21,7 @@ def handle(self, article, request): class CopyEditingHandler(StatusHandler): def handle(self, article, request): logger.info("Starting COPY_EDITING flow pid=%s", article.abstract.pid) - email_response = send_docx_email_pid(article.abstract.pid) + email_response = send_docx_email(article) if getattr(email_response, "status_code", 200) >= 400: logger.warning( "COPY_EDITING email failed pid=%s status_code=%s", From 920c75be4f75a206a58950d5cdd0a73e2c7f32c7 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Thu, 26 Feb 2026 14:13:54 +0100 Subject: [PATCH 34/45] (review) remove sending email code fromn copyediting handler --- jdhapi/views/articles/status_handlers.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/jdhapi/views/articles/status_handlers.py b/jdhapi/views/articles/status_handlers.py index 78374dc..58f572b 100644 --- a/jdhapi/views/articles/status_handlers.py +++ b/jdhapi/views/articles/status_handlers.py @@ -21,14 +21,6 @@ def handle(self, article, request): class CopyEditingHandler(StatusHandler): def handle(self, article, request): logger.info("Starting COPY_EDITING flow pid=%s", article.abstract.pid) - email_response = send_docx_email(article) - if getattr(email_response, "status_code", 200) >= 400: - logger.warning( - "COPY_EDITING email failed pid=%s status_code=%s", - article.abstract.pid, - getattr(email_response, "status_code", None), - ) - return email_response article.status = article.Status.COPY_EDITING article.save() From 045447b3539e3fcfe00baab995ddfbe096fcfc2d Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Thu, 26 Feb 2026 15:30:49 +0100 Subject: [PATCH 35/45] (review) changing send_docx_email to POST to be able to adapt body in FE --- jdh/settings.py | 3 +-- jdhapi/views/articles/copy_editing.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/jdh/settings.py b/jdh/settings.py index 175d50e..bc04533 100644 --- a/jdh/settings.py +++ b/jdh/settings.py @@ -290,5 +290,4 @@ 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", "") -COPY_EDITOR_NAME=get_env_variable("COPY_EDITOR_NAME", "") \ No newline at end of file +COPY_EDITOR_ADDRESS=get_env_variable("COPY_EDITOR_ADDRESS", "") \ No newline at end of file diff --git a/jdhapi/views/articles/copy_editing.py b/jdhapi/views/articles/copy_editing.py index 34bcbf0..e9dff18 100644 --- a/jdhapi/views/articles/copy_editing.py +++ b/jdhapi/views/articles/copy_editing.py @@ -15,7 +15,6 @@ logger = logging.getLogger(__name__) COPY_EDITOR_ADDRESS = settings.COPY_EDITOR_ADDRESS -COPY_EDITOR_NAME = settings.COPY_EDITOR_NAME @api_view(["GET"]) @permission_classes([IsAdminUser]) @@ -56,20 +55,22 @@ def get_docx(request): ) -@api_view(["GET"]) +@api_view(["POST"]) @permission_classes([IsAdminUser]) def send_docx_email(request): """ - GET api/articles/docx/email + 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("GET api/articles/docx/email") + logger.info("POST api/articles/docx/email") branch_name = "pandoc" - pid = request.GET.get("pid") + pid = request.data.get("pid") + body= request.data.get("body") if not pid: return Response({"error": "Article PID is required."}, status=400) @@ -80,8 +81,8 @@ def send_docx_email(request): return workflow_error docx_bytes = fetch_docx_bytes(pid, branch_name) - send_email_copy_editor(pid, docx_bytes) - return Response({"message": "Docx sent succesfully by email for article : {pid}"}, status=200) + 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) @@ -126,7 +127,7 @@ def fetch_docx_bytes(pid, branch_name): raise ValueError("Unexpected error occurred while contacting GitHub API.") -def send_email_copy_editor(pid, docx_bytes): +def send_email_copy_editor(pid, docx_bytes, body): """ Helper function to send the email to copy editing editor :params pid: the article PID @@ -134,7 +135,6 @@ def send_email_copy_editor(pid, docx_bytes): """ logger.info("[run_pandoc_workflow] Running pandoc workflow for PID '%s'", pid) - body = f"Dear {COPY_EDITOR_NAME}, find in attachment the docx to review for copy editing." filename = f"article_{pid}.docx" message = EmailMessage( subject="Article to review for copy editing", From 5a58d8b17e64b3011f513ed4e0bd9781c94924c8 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Thu, 26 Feb 2026 16:04:13 +0100 Subject: [PATCH 36/45] (fix) removed flake8 linter for ruff configuration pyproject.yml --- .flake8 | 5 ----- jdhapi/signals.py | 6 ++---- pyproject.toml | 4 ++++ 3 files changed, 6 insertions(+), 9 deletions(-) delete mode 100644 .flake8 create mode 100644 pyproject.toml 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/jdhapi/signals.py b/jdhapi/signals.py index e286e1d..4135c1f 100644 --- a/jdhapi/signals.py +++ b/jdhapi/signals.py @@ -1,13 +1,12 @@ import requests -import logging 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(pre_save, sender=Article) def validate_urls_for_article_submission(sender, instance, **kwargs): def check_github_url(url): @@ -37,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/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 From 50a03d460f737be90192794deaef7ae86948d29c Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Fri, 27 Feb 2026 18:34:45 +0100 Subject: [PATCH 37/45] (fix) formatting python file ojs + add rule to have primary_contact listed in authors array --- jdhapi/utils/ojs.py | 111 +++++++++---------- jdhapi/views/articles/ojs.py | 203 +++++++++++++++++++---------------- 2 files changed, 161 insertions(+), 153 deletions(-) diff --git a/jdhapi/utils/ojs.py b/jdhapi/utils/ojs.py index b129929..9fbf759 100644 --- a/jdhapi/utils/ojs.py +++ b/jdhapi/utils/ojs.py @@ -1,9 +1,9 @@ -import marko +import marko import requests from django.conf import settings from django.template.loader import render_to_string -from jdhapi.models import Article from jdh.validation import JSONSchema +from jdhapi.models import Article from lxml import html from weasyprint import HTML @@ -12,119 +12,107 @@ 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}' + "Content-Type": "application/json", + "Authorization": f"Bearer {settings.OJS_API_KEY_TOKEN}", } OJS_API_URL = settings.OJS_API_URL -def create_blank_submission(): + +def create_blank_submission(): 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 ) + 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): 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}' - } + 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 + 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) + 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): - logger.info("creating the article contributor in OJS") - primary_contact_id = 0 +def create_contributor_in_ojs(submission_id, publication_id, article: Article): + logger.info("creating the article contributor in OJS") - url=f"{settings.OJS_API_URL}/submissions/{submission_id}/publications/{publication_id}/contributors" + 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 - }, + "affiliation": {"en": author.affiliation}, "country": str(author.country), - "email": author.email, - "familyName": { - "en": author.lastname - }, + "email": author.email, + "familyName": {"en": author.lastname}, "fullName": f"{author.firstname} {author.lastname}", - "givenName": { - "en": author.firstname - }, + "givenName": {"en": author.firstname}, "includeInBrowse": True, "locale": "en", "orcid": author.orcid, - "preferredPublicName": { - "en": "" - }, + "preferredPublicName": {"en": ""}, "publicationId": publication_id, "seq": 0, "userGroupId": 14, - "userGroupName": { - "en": "Author" - } + "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()}") + logger.info( + f"Contributor {author.firstname} {author.lastname} created with response: {res.json()}" + ) - if article.abstract.contact_email == author.email and article.abstract.contact_lastname == author.lastname : - primary_contact_id = res.json().get('id',0) + 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): - 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={ +def assign_primary_contact_and_metadata( + submission_id, publication_id, contributor_id, article +): + 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" - } + "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) + res = requests.put(url=url, headers=headers, json=payload) return res + def submit_submission_to_ojs(submission_id): logger.info("Submit the article to OJS") - url=f"{OJS_API_URL}/submissions/{submission_id}/submit" - payload= { - "confirmCopyright": "true" - } + url = f"{OJS_API_URL}/submissions/{submission_id}/submit" + payload = {"confirmCopyright": "true"} - res=requests.put(url, headers=headers, json=payload) + res = requests.put(url, headers=headers, json=payload) return res + def generate_pdf_for_submission(article): template = "jdhseo/peer_review.html" if "title" in article.data: @@ -139,4 +127,3 @@ def generate_pdf_for_submission(article): logger.info("Pdf generated") return pdf_file - diff --git a/jdhapi/views/articles/ojs.py b/jdhapi/views/articles/ojs.py index 4c16929..8849175 100644 --- a/jdhapi/views/articles/ojs.py +++ b/jdhapi/views/articles/ojs.py @@ -1,20 +1,26 @@ import requests from django.conf import settings from django.db import transaction -from jdhapi.models import Article -from jdhapi.utils.ojs import create_blank_submission, upload_manuscript_to_ojs, create_contributor_in_ojs, assign_primary_contact_and_metadata, generate_pdf_for_submission -from jdhapi.utils.logger import logger as get_logger 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 rest_framework import status +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") @@ -36,7 +42,7 @@ def get_count_submission_from_ojs(_): logger.info("GET /api/articles/ojs/submissions") - url=f"{OJS_API_URL}/submissions?stageIds=1" + url = f"{OJS_API_URL}/submissions?stageIds=1" try: response = requests.get(url, headers=headers) @@ -71,7 +77,7 @@ def send_article_to_ojs(request): try: res = submit_to_ojs(request) return Response( - {"message": "Article(s) send successfully to OJS.", "data": res}, + {"message": "Article send successfully to OJS.", "data": res}, status=status.HTTP_200_OK, ) except ValidationError as e: @@ -81,7 +87,6 @@ def send_article_to_ojs(request): status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: - logger.exception("An unexpected error occurred.") return Response( { "error": "InternalError", @@ -97,101 +102,117 @@ def submit_to_ojs(request): logger.info('Submitting article to OJS') - with transaction.atomic(): + try: - article_to_ojs_schema.validate(instance=request.data) + with transaction.atomic(): - pid = request.data.get("pid", None) + article_to_ojs_schema.validate(instance=request.data) - logger.info("Retrieve article according to the PID.") + pid = request.data.get("pid", None) - 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 - - 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() + 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.") - 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.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 Exception(error_msg) + raise ValidationError(error_msg) - logger.info(f"Manuscript uploaded with response: {res.json()}") + 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) - # 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") + try: + # 1. create a blank submission in OJS + res = create_blank_submission() + logger.info(f"Blank submission created with response: {res.json()}") - contributor_id=primary_contact_id + submission_id = res.json().get('id', 0) + publication_id = res.json().get('currentPublicationId', 0) - # 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) + 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 assign metadata. Status: {res.status_code}, Response: {res.text}" - logger.error(error_msg) - raise Exception(error_msg) + 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()}") - # 5. Submit the submission to OJS - # uncomment if we do not need human check on OJS dashboard anymore - # submit_to_ojs(submission_id) + # 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") - except Exception as e: - logger.error(f"Error during OJS submission process: {e}") - raise e + 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 From 8e38a4ce15269fbbee154677fe16258e8f49f74e Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Fri, 27 Feb 2026 18:42:16 +0100 Subject: [PATCH 38/45] (fix) add doc strings --- jdhapi/utils/ojs.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/jdhapi/utils/ojs.py b/jdhapi/utils/ojs.py index 9fbf759..cf451c1 100644 --- a/jdhapi/utils/ojs.py +++ b/jdhapi/utils/ojs.py @@ -19,6 +19,11 @@ 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" @@ -29,6 +34,13 @@ def create_blank_submission(): 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" @@ -47,6 +59,13 @@ def upload_manuscript_to_ojs(pid, submission_id, pdf_bytes): 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 @@ -85,6 +104,14 @@ def create_contributor_in_ojs(submission_id, publication_id, article: Article): 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" ) @@ -103,6 +130,11 @@ def assign_primary_contact_and_metadata( 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" @@ -114,6 +146,11 @@ def submit_submission_to_ojs(submission_id): 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( From 8f942d54f20df5d9ee56b19b3244f9cd72c17bcf Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Fri, 27 Feb 2026 18:46:26 +0100 Subject: [PATCH 39/45] (test) fix test --- tests/jdhapi/views/articles/test_ojs.py | 287 ++++++++++++------------ 1 file changed, 148 insertions(+), 139 deletions(-) diff --git a/tests/jdhapi/views/articles/test_ojs.py b/tests/jdhapi/views/articles/test_ojs.py index 024117c..38dc26f 100644 --- a/tests/jdhapi/views/articles/test_ojs.py +++ b/tests/jdhapi/views/articles/test_ojs.py @@ -1,279 +1,288 @@ from unittest.mock import Mock, patch -from django.test import TestCase + from django.contrib.auth import get_user_model -from rest_framework.test import APIClient +from django.test import TestCase +from jdhapi.models import Abstract, Article, Author, Issue from rest_framework import status -from jdhapi.models import Article, Abstract, Author, Issue +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' + 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' + 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' + 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', + pid="jdh000", + name="Issue 0", volume=1, issue=1, - status=Issue.Status.PUBLISHED + status=Issue.Status.PUBLISHED, ) - + self.article = Article.objects.create( abstract=self.abstract, - data={'title': 'Test Article Title'}, - issue=self.issue + data={"title": "Test Article Title"}, + issue=self.issue, ) - - self.url = '/api/articles/ojs/submission' - self.valid_payload = {'pid': 'test-article-001'} + + 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') + 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' + 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') + + 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') + @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_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 + "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_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_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} - + 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_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') - + + 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(s) send successfully to OJS.') - + 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') + @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') - + 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') + @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') - + + 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): + @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_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 + "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_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' + 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' + 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 + 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 + 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_500_INTERNAL_SERVER_ERROR) - @patch('jdhapi.views.articles.ojs.generate_pdf_for_submission') - @patch('jdhapi.views.articles.ojs.requests.post') + 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_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 + "id": 123, + "currentPublicationId": 456, } - + # Mock failed upload mock_upload_fail = Mock() mock_upload_fail.status_code = 400 - mock_upload_fail.text = 'Upload failed' - + 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') - + + 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) + 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): + @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_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_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_upload.json.return_value = {"id": 789} + mock_contributor = Mock() mock_contributor.status_code = 201 - mock_contributor.json.return_value = {'id': 999} - + 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_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) \ No newline at end of file + + response = self.client.post(self.url, self.valid_payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) From c10b8d113701a67feaf8a317429b40a509c37702 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Fri, 27 Feb 2026 18:53:01 +0100 Subject: [PATCH 40/45] (fix) add env variables for test CI --- .github/workflows/run_unit_tests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index d0f263c..5fabfe3 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: @@ -66,6 +66,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 From 4b9311e2b1c61e3b65fd7235006f18c2df69d022 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Fri, 27 Feb 2026 18:59:28 +0100 Subject: [PATCH 41/45] (fix) move logging.info after owner, repo variable for linter --- jdhapi/utils/github_action.py | 43 +++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/jdhapi/utils/github_action.py b/jdhapi/utils/github_action.py index 4bfa7e8..367d3d0 100644 --- a/jdhapi/utils/github_action.py +++ b/jdhapi/utils/github_action.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 import logging -import requests 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__) @@ -15,13 +16,18 @@ def trigger_workflow(repo_url, workflow_filename, token=None, ref="main"): :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 - """ - - logger.info("[trigger_workflow] - Trigger workflow '%s' on ref '%s' for %s/%s", workflow_filename, ref, owner, repo) - + """ 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}", @@ -38,7 +44,7 @@ def trigger_workflow(repo_url, workflow_filename, token=None, ref="main"): owner, repo, ) - else : + else: logger.error( "Failed to dispatch workflow '%s' (%s): %s", workflow_filename, @@ -66,14 +72,20 @@ def trigger_workflow_and_wait( :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 - """ - - logger.info("[trigger_workflow_and_wait] - Trigger workflow and wait for '%s' on ref '%s' for %s/%s", workflow_filename, ref, owner, repo) - + """ + 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 = ( @@ -137,11 +149,13 @@ def trigger_workflow_and_wait( 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") + logger.info( + "[_parse_owner_repo] - Retrieve owner and repository name from a github repository url" + ) parsed = urlparse(repo_url) path = parsed.path.lstrip("/") @@ -156,7 +170,7 @@ def _parse_owner_repo(repo_url): if len(parts) >= 2: return owner, repo - else : + else: raise ValueError(f"Invalid repository URL: '{repo_url}'") @@ -178,7 +192,6 @@ def _get_github_token(token): return resolved - def _parse_github_datetime(value): """ Parse a GitHub datetime string into a timezone-aware datetime object. @@ -193,4 +206,4 @@ def _parse_github_datetime(value): return datetime.fromisoformat(value.replace("Z", "+00:00")) except (ValueError, AttributeError): logger.error("Failed to parse GitHub datetime value: '%s'", value) - return None \ No newline at end of file + return None From d727c1d9a17b34148665f168fb392742d3180e62 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Mon, 2 Mar 2026 13:21:49 +0100 Subject: [PATCH 42/45] (feat) linkedinId added --- jdhapi/models/author.py | 1 + jdhapi/serializers/author.py | 11 ++-- jdhapi/views/abstracts/submit_abstract.py | 57 +++++++++++-------- schema/submit_abstract.json | 12 ++++ .../views/abstracts/test_submit_abstract.py | 13 +++-- 5 files changed, 61 insertions(+), 33 deletions(-) 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/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/views/abstracts/submit_abstract.py b/jdhapi/views/abstracts/submit_abstract.py index 8f04edb..e5b4b44 100644 --- a/jdhapi/views/abstracts/submit_abstract.py +++ b/jdhapi/views/abstracts/submit_abstract.py @@ -1,36 +1,43 @@ -from django.conf import settings -from django.core.mail import send_mail +from textwrap import dedent + from django.db import transaction from jdh.validation import JSONSchema -from jdhapi.models import Abstract, Author, Dataset, CallForPaper -from jdhapi.utils.altcha import verify_challenge_solution -from jdhapi.utils.logger import logger as get_logger -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 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() document_json_schema = JSONSchema(filepath="submit_abstract.json") + def get_default_body(id, title, firstname, lastname): default_body = dedent( f""" 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 @@ -43,13 +50,14 @@ def get_default_body(id, title, firstname, lastname): def send_mail_abstract_received(pid, subject, sent_to, firstname, lastname): body = get_default_body(pid, subject, firstname, lastname) try: - send_mail( - subject, - body, - "jdh.admin@uni.lu", - [sent_to, "jdh.admin@uni.lu"], - fail_silently=False, - ) + # send_mail( + # subject, + # body, + # "jdh.admin@uni.lu", + # [sent_to, "jdh.admin@uni.lu"], + # fail_silently=False, + # ) + print("TEST DONE") except Exception as e: print(e) @@ -65,11 +73,11 @@ def submit_abstract(request): 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( @@ -116,7 +124,7 @@ def validate_and_submit_abstract(request): if not verified: raise ValidationError(f"Invalid Altcha payload: {err}") - + except ValidationError as e: raise ValidationError(f"Failed to process Altcha payload: {str(e)}") @@ -196,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/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/jdhapi/views/abstracts/test_submit_abstract.py b/tests/jdhapi/views/abstracts/test_submit_abstract.py index 0312f70..6add007 100644 --- a/tests/jdhapi/views/abstracts/test_submit_abstract.py +++ b/tests/jdhapi/views/abstracts/test_submit_abstract.py @@ -1,12 +1,11 @@ from datetime import date +from unittest.mock import patch + from django.test import Client from django.urls import reverse -from jdhapi.models import Abstract, Author, Dataset, CallForPaper -from rest_framework.test import APITestCase +from jdhapi.models import Abstract, Author, CallForPaper, Dataset from rest_framework import status - - -from unittest.mock import Mock, patch +from rest_framework.test import APITestCase class SubmitAbstractTestCase(APITestCase): @@ -36,6 +35,7 @@ def setUp(self): "githubId": "janesmith", "blueskyId": "jane.bsky.social", "facebookId": "jane.smith", + "linkedinId": "jane-smith", "primaryContact": True, } ], @@ -70,6 +70,7 @@ def setUp(self): "githubId": "", "blueskyId": "jane.bsky.social", "facebookId": "jane.smith", + "linkedinId": "jane-smith", "primaryContact": True, } ], @@ -180,6 +181,7 @@ def test_update_existing_author(self, mock_captcha): github_id="existinggithub", bluesky_id="existing.bsky.social", facebook_id="existing.author", + linkedin_id="existing.author.linkedin", ) url = reverse("submit-abstract") @@ -199,6 +201,7 @@ def test_update_existing_author(self, mock_captcha): 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): From 85a65e489aca39d28bd0c8ad6d5bd03976485ed0 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Mon, 2 Mar 2026 13:22:14 +0100 Subject: [PATCH 43/45] (feat) linkedin id makemigrations --- ...author_linkedin_id_alter_article_status.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 jdhapi/migrations/0053_author_linkedin_id_alter_article_status.py 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), + ), + ] From f1e444b7082df81724a58d1b7e2bc5daad4e51d0 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Thu, 5 Mar 2026 12:25:13 +0100 Subject: [PATCH 44/45] (fix) reactivate sending email to author and admin --- jdhapi/views/abstracts/submit_abstract.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/jdhapi/views/abstracts/submit_abstract.py b/jdhapi/views/abstracts/submit_abstract.py index e5b4b44..70f50f7 100644 --- a/jdhapi/views/abstracts/submit_abstract.py +++ b/jdhapi/views/abstracts/submit_abstract.py @@ -50,14 +50,13 @@ def get_default_body(id, title, firstname, lastname): def send_mail_abstract_received(pid, subject, sent_to, firstname, lastname): body = get_default_body(pid, subject, firstname, lastname) try: - # send_mail( - # subject, - # body, - # "jdh.admin@uni.lu", - # [sent_to, "jdh.admin@uni.lu"], - # fail_silently=False, - # ) - print("TEST DONE") + send_mail( + subject, + body, + "jdh.admin@uni.lu", + [sent_to, "jdh.admin@uni.lu"], + fail_silently=False, + ) except Exception as e: print(e) From f6760ce663a06410c8ae88598db7445a1d164837 Mon Sep 17 00:00:00 2001 From: "salaun.marion" Date: Thu, 5 Mar 2026 12:30:22 +0100 Subject: [PATCH 45/45] (fix) send_mail reimported --- jdhapi/views/abstracts/submit_abstract.py | 1 + 1 file changed, 1 insertion(+) diff --git a/jdhapi/views/abstracts/submit_abstract.py b/jdhapi/views/abstracts/submit_abstract.py index 70f50f7..320d71b 100644 --- a/jdhapi/views/abstracts/submit_abstract.py +++ b/jdhapi/views/abstracts/submit_abstract.py @@ -1,5 +1,6 @@ from textwrap import dedent +from django.core.mail import send_mail from django.db import transaction from jdh.validation import JSONSchema from jsonschema.exceptions import SchemaError, ValidationError