diff --git a/consts.py b/consts.py index 6d84160..ba2d444 100644 --- a/consts.py +++ b/consts.py @@ -1,3 +1,7 @@ +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + import os from django.conf import settings diff --git a/dev-requirements.txt b/dev-requirements.txt index 2da04d8..64e5c6c 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,2 +1,3 @@ hypothesis==6.138.7 -pytest==8.4.1 \ No newline at end of file +pytest==8.4.1 +python-magic==0.4.27 \ No newline at end of file diff --git a/enums/__init__.py b/enums/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/enums/transfer_log_message_type.py b/enums/transfer_log_message_type.py new file mode 100644 index 0000000..d33a6eb --- /dev/null +++ b/enums/transfer_log_message_type.py @@ -0,0 +1,13 @@ +""" +A file for tracking the transfer log message types. +""" +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + +import django.db.models as models +from django.utils.translation import gettext_lazy as _ + +class TransferLogMessageType(models.TextChoices): + EXPORT = "EX", _("Export Message") + IMPORT = "IM", _("Import Message") \ No newline at end of file diff --git a/file_exporter.py b/file_exporter.py index 9310934..96ebd42 100644 --- a/file_exporter.py +++ b/file_exporter.py @@ -18,6 +18,8 @@ import plugins.editorial_manager_transfer_service.logger_messages as logger_messages from core.models import File from journal.models import Journal +from plugins.editorial_manager_transfer_service.enums.transfer_log_message_type import TransferLogMessageType +from plugins.editorial_manager_transfer_service.models import TransferLogs from submission.models import Article from utils import setting_handler from utils.logger import get_logger @@ -42,47 +44,32 @@ class ExportFileCreation: A class for managing the export file creation process. """ - def __init__(self, journal_code: str, article_id: str): + def __init__(self, janeway_journal_code: str, article_id: int | None) -> None: self.zip_filepath: str | None = None self.go_filepath: str | None = None self.in_error_state: bool = False self.__license_code: str | None = None self.__journal_code: str | None = None self.__submission_partner_code: str | None = None - self.article_id: str | None = article_id.strip() if article_id else None + self.article_id: int | None = article_id self.article: Article | None = None self.journal: Journal | None = None self.export_folder: str | None = None - # If no article ID, return an error. - if not self.article_id or len(self.article_id) <= 0: - logger.error(logger_messages.process_failed_no_article_id_provided()) - self.in_error_state = True - return - - # Attempt to get the journal. - try: - self.journal: Journal = Journal.objects.get(code=journal_code) - except Journal.DoesNotExist: - logger.error(logger_messages.process_failed_fetching_journal(article_id)) - self.in_error_state = True + # Gets the journal + self.journal: Journal | None = self.__fetch_journal(janeway_journal_code) + if self.in_error_state: return # Get the article based upon the given article ID. - logger.info(logger_messages.process_fetching_article(article_id)) - try: - self.article: Article = self.__fetch_article(self.journal, article_id) - if not self.article: - raise Article.DoesNotExist - except Article.DoesNotExist: - logger.error(logger_messages.process_failed_fetching_article(article_id)) - self.in_error_state = True + self.article: Article | None = self.__fetch_article(self.journal, article_id) + if self.in_error_state: return # Get the export folder. export_folders: str = get_article_export_folders() if len(export_folders) <= 0: - logger.error(logger_messages.export_process_failed_no_export_folder()) + self.log_error(logger_messages.export_process_failed_no_export_folder()) self.in_error_state = True return self.export_folder = export_folders @@ -131,7 +118,7 @@ def __create_export_file(self): # Attempt to fetch the article files. article_files: Sequence[File] = self.__fetch_article_files(self.article) if len(article_files) <= 0: - logger.error(logger_messages.process_failed_fetching_article_files(self.article_id)) + self.log_error(logger_messages.process_failed_fetching_article_files(self.article_id)) self.in_error_state = True return @@ -198,8 +185,8 @@ def get_setting(self, setting_name: str) -> str: try: return setting_handler.get_setting(setting_group_name=consts.PLUGIN_SETTINGS_GROUP_NAME, setting_name=setting_name, journal=self.journal, ).processed_value - except ObjectDoesNotExist: - logger.error("Could not get the following setting, '{0}'".format(setting_name)) + except ObjectDoesNotExist as e: + self.log_error("Could not get the following setting, '{0}'".format(setting_name), e) self.in_error_state = True return "" @@ -225,7 +212,7 @@ def __create_go_xml_file(self, metadata_filename: str, article_filenames: Sequen version.set(consts.GO_FILE_VERSION_ELEMENT_ATTRIBUTE_NUMBER_KEY, consts.GO_FILE_VERSION_ELEMENT_ATTRIBUTE_NUMBER_VALUE) journal: ETree.Element = ETree.SubElement(header, consts.GO_FILE_ELEMENT_TAG_JOURNAL) - journal.set(consts.GO_FILE_JOURNAL_ELEMENT_ATTRIBUTE_CODE_KEY, self.get_license_code()) + journal.set(consts.GO_FILE_JOURNAL_ELEMENT_ATTRIBUTE_CODE_KEY, self.get_journal_code()) import_type: ETree.Element = ETree.SubElement(header, consts.GO_FILE_ELEMENT_TAG_IMPORT_TYPE) import_type.set(consts.GO_FILE_IMPORT_TYPE_ELEMENT_ATTRIBUTE_ID_KEY, consts.GO_FILE_IMPORT_TYPE_ELEMENT_ATTRIBUTE_ID_VALUE) @@ -260,15 +247,74 @@ def __create_metadata_file(self, article: Article) -> File | None: """ pass - @staticmethod - def __fetch_article(journal: Journal, article_id: str) -> Article: + def __fetch_article(self, journal: Journal | None, article_id: int | None) -> Article | None: """ Gets the article object for the given article ID. :param journal: The journal to fetch the article from. :param article_id: The ID of the article. :return: The article object with the given article ID. """ - return Article.get_article(journal, "id", article_id) + # If no article ID or journal, return an error. + if not article_id or article_id <= 0: + self.log_error(logger_messages.process_failed_no_article_id_provided()) + self.in_error_state = True + return None + + article: Article | None = None + + logger.debug(logger_messages.process_fetching_article(article_id)) + try: + article = Article.get_article(journal, "id", article_id) + logger.debug(logger_messages.process_finished_fetching_article(article_id)) + except Article.DoesNotExist as e: + self.log_error(logger_messages.process_failed_fetching_article(article_id), e) + self.in_error_state = True + + return article + + def __fetch_journal(self, janeway_journal_code: str | None) -> Journal | None: + """ + Gets the journal from the database given the Janeway journal code. + :param janeway_journal_code: The code of the Janeway journal to fetch. + :return: The journal object with the given Janeway journal code, if there is one. None otherwise. + """ + # If no journal code, return an error. + if not janeway_journal_code or len(janeway_journal_code) <= 0: + self.log_error(logger_messages.process_failed_no_janeway_journal_code_provided()) + self.in_error_state = True + return None + + journal: Journal | None = None + + # Attempt to get the journal. + logger.debug(logger_messages.process_fetching_journal(janeway_journal_code)) + try: + journal = Journal.objects.get(code=janeway_journal_code) + logger.debug(logger_messages.process_finished_fetching_journal(janeway_journal_code)) + except Journal.DoesNotExist as e: + self.log_error(logger_messages.process_failed_fetching_journal(janeway_journal_code), e) + self.in_error_state = True + + return journal + + def log_error(self, message: str, error: Exception = None) -> None: + """ + Logs the given error message in both the database and plaintext logs. + :param message: The message to log. + :param error: The exception, if there is one. + """ + logger.exception(error) + logger.error(message) + TransferLogs.objects.create(journal=self.journal, article=self.article, message=message, + message_type=TransferLogMessageType.EXPORT, success=False) + + def log_success(self) -> None: + """ + Logs a success message in both the database and plaintext logs. + """ + TransferLogs.objects.create(journal=self.journal, article=self.article, + message=logger_messages.export_process_succeeded(self.article_id), + message_type=TransferLogMessageType.EXPORT, success=True) @staticmethod def __fetch_article_files(article: Article) -> List[File]: diff --git a/file_transfer_service.py b/file_transfer_service.py index 0a883a4..ad5737c 100644 --- a/file_transfer_service.py +++ b/file_transfer_service.py @@ -8,6 +8,7 @@ import os from typing import List +from plugins.editorial_manager_transfer_service import logger_messages from plugins.editorial_manager_transfer_service.file_exporter import ExportFileCreation from utils.logger import get_logger @@ -53,6 +54,12 @@ def get_export_file_creator(self, journal_code: str, article_id: str) -> ExportF @staticmethod def __get_dictionary_identifier(journal_code: str, article_id: str) -> str: + """ + Gets the dictionary identifier for the given article. + :param journal_code: The journal code of the journal where the article lives. + :param article_id: The article id. + :return: The dictionary identifier. + """ return f"{journal_code}-{article_id}" def get_export_zip_filepath(self, journal_code: str, article_id: str) -> str | None: @@ -75,7 +82,38 @@ def get_export_go_filepath(self, journal_code: str, article_id: str) -> str | No file_export_creator = self.get_export_file_creator(journal_code, article_id) return file_export_creator.get_go_filepath() if file_export_creator else None + def log_export_error(self, journal_code: str, + article_id: str, + error_message: str = None, + error: Exception = None) -> None: + """ + Logs an error message in both the database and plaintext logs. + :param error: The exception, if there is one. + :param error_message: The error message to print out. + :param journal_code: The journal code of the journal where the article lives. + :param article_id: The article id. + """ + file_export_creator = self.get_export_file_creator(journal_code, article_id) + if file_export_creator: + file_export_creator.log_error(logger_messages.export_process_failed_ingest(article_id, error_message), error) + + def log_export_success(self, journal_code: str, + article_id: str) -> None: + """ + Logs the success message for when an article has completed a journey to Editorial Manager. + :param journal_code: The journal code of the journal where the article lives. + :param article_id: The article id. + """ + file_export_creator = self.get_export_file_creator(journal_code, article_id) + if file_export_creator: + file_export_creator.log_success() + def delete_export_files(self, journal_code: str, article_id: str) -> None: + """ + Deletes the export files for the given article. + :param journal_code: The journal code of the journal the article lives in. + :param article_id: The article id. + """ dictionary_identifier: str = self.__get_dictionary_identifier(journal_code, article_id) if dictionary_identifier not in self.exports: return @@ -93,11 +131,18 @@ def __delete_files(self) -> None: @staticmethod def __delete_file(filepath: str) -> bool: + """ + Deletes the given file. + :param filepath: The file path of the file to delete. + :return: True if the file was deleted, false otherwise. + """ if not os.path.exists(filepath): return True try: os.remove(filepath) - except OSError: + except OSError as e: + logger.exception(e) + logger.error(logger_messages.excport_process_failed_delete_file(filepath)) return False return True @@ -129,13 +174,17 @@ def export_success_callback(journal_code: str, article_id: str) -> None: :param journal_code: The journal code of the journal the article lives in. :param article_id: The article id. """ + FileTransferService().log_export_success(journal_code, article_id) FileTransferService().delete_export_files(journal_code, article_id) -def export_failure_callback(journal_code: str, article_id: str) -> None: +def export_failure_callback(journal_code: str, article_id: str, error_message: str = None, error: Exception = None) -> None: """ The callback in case of a failed export. + :param error: The exception, if there is one. + :param error_message: The error message to print. :param journal_code: The journal code of the journal the article lives in. :param article_id: The article id. """ + FileTransferService().log_export_error(journal_code, article_id, error_message, error) FileTransferService().delete_export_files(journal_code, article_id) diff --git a/forms.py b/forms.py index 6cb0577..59ac6bf 100644 --- a/forms.py +++ b/forms.py @@ -1,3 +1,7 @@ +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + from django import forms diff --git a/logger_messages.py b/logger_messages.py index 0b0e489..8a025aa 100644 --- a/logger_messages.py +++ b/logger_messages.py @@ -64,7 +64,7 @@ def import_folder_created() -> str: return '{0} import folder already exists.'.format(PLUGIN_NAME) -def process_fetching_article(article_id: str) -> str: +def process_fetching_article(article_id: int) -> str: """ Gets the log message for when an article is being fetched from the database. :param: article_id: The ID of the article being fetched. @@ -73,7 +73,16 @@ def process_fetching_article(article_id: str) -> str: return "Fetching article from database (ID: {0})...".format(article_id) -def process_failed_fetching_article(article_id: str) -> str: +def process_finished_fetching_article(article_id: int) -> str: + """ + Gets the log message for when an article is being fetched from the database. + :param: article_id: The ID of the article being fetched. + :return: The logger message. + """ + return "Completed fetching article from database (ID: {0})...".format(article_id) + + +def process_failed_fetching_article(article_id: int) -> str: """ Gets the log message for when an article failed to be fetched. :param: article_id: The ID of the article being fetched. @@ -82,6 +91,34 @@ def process_failed_fetching_article(article_id: str) -> str: return "Fetching article from database (ID: {0}) failed. Discontinuing export process.".format(article_id) +def process_fetching_journal(janway_journal_code: str) -> str: + """ + Gets the log message for when a journal is being fetched from the database. + :param: janway_journal_code: The code of the journal being fetched. + :return: The logger message. + """ + return "Fetching journal from database (Code: {0})...".format(janway_journal_code) + + +def process_finished_fetching_journal(janway_journal_code: str) -> str: + """ + Gets the log message for when a journal is being fetched from the database. + :param: janway_journal_code: The code of the journal being fetched. + :return: The logger message. + """ + return "Completed fetching journal from database (Code: {0})...".format(janway_journal_code) + + +def process_failed_fetching_journal(janway_journal_code: str) -> str: + """ + Gets the log message for when a journal failed to be fetched. + :param: janway_journal_code: The code of the journal being fetched. + :return: The logger message. + """ + return "Fetching journal from database (Code: {0}) failed. Discontinuing export process.".format( + janway_journal_code) + + def process_failed_fetching_metadata(article_id) -> str: """ Gets the log message for when an article's metadata failed to be fetched. @@ -117,9 +154,56 @@ def process_failed_no_article_id_provided() -> str: return "No article ID provided. Discontinuing export process." +def process_failed_no_janeway_journal_code_provided() -> str: + """ + Gets the log message for when no journal code was provided. + :return: The logger message. + """ + return "No Janeway journal code was provided. Discontinuing export process." + + def export_process_failed_no_export_folder() -> str: """ Gets the log message for when an export folder was not created. :return: The logger message. """ return "No export folder provided. Discontinuing export process." + + +def __export_process_failed_ingest(article_id: str) -> str: + """ + Gets the log message for when the article failed to be ingested into Editorial Manager. + :param article_id: The ID of the article being fetched. + :return: The logger message. + """ + "Export process failed during ingest to Editorial Manager for article (ID: {0})".format(article_id) + + +def export_process_failed_ingest(article_id: str, error_message: str = None) -> str: + """ + Gets the log message for when the article failed to be ingested into Editorial Manager. + :param article_id: The ID of the article being fetched. + :param error_message: The error message to be logged. + :param error: The exception to be logged. + :return: The logger message. + """ + if error_message: + return error_message + return __export_process_failed_ingest(article_id) + + +def export_process_succeeded(article_id: int) -> str: + """ + Gets the log message for when the export process was successful. + :return: The logger message. + """ + return "Export process succeeded for article (ID: {0}).".format(article_id) + + +def excport_process_failed_delete_file(filepath: str) -> str: + """ + Gets the log message for when an export file failed to be deleted. + :param filepath: The path to the export file. + :return: The logger message. + """ + return "Export process failed to delete file at filepath: {0}.".format(filepath) diff --git a/logic.py b/logic.py index aa8dc4e..71659fc 100644 --- a/logic.py +++ b/logic.py @@ -1,3 +1,7 @@ +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + import plugins.editorial_manager_transfer_service.consts as consts from journal.models import Journal from utils import setting_handler diff --git a/migrations/0001_initial.py b/migrations/0001_initial.py new file mode 100644 index 0000000..04d2a0e --- /dev/null +++ b/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.22 on 2025-09-18 15:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('journal', '0068_issue_cached_display_title_a11y_and_more'), + ('submission', '0088_auto_20250506_1214'), + ] + + operations = [ + migrations.CreateModel( + name='TransferLogs', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message_type', models.CharField(choices=[('EX', 'Export Message'), ('IM', 'Import Message')], default='EX', max_length=2)), + ('message', models.TextField()), + ('message_date_time', models.DateTimeField(auto_now_add=True)), + ('success', models.BooleanField(default=False)), + ('article', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='submission.article')), + ('journal', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='journal.journal')), + ], + ), + ] diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models.py b/models.py new file mode 100644 index 0000000..92bc6fb --- /dev/null +++ b/models.py @@ -0,0 +1,36 @@ +""" +The models used for the production transporter. +""" +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + +from django.db import models +from plugins.editorial_manager_transfer_service.enums.transfer_log_message_type import TransferLogMessageType + + +class TransferLogs(models.Model): + """ + The model used for transfer logs. + """ + message_type = models.CharField( + max_length=2, + choices=TransferLogMessageType.choices, + default=TransferLogMessageType.EXPORT, + ) + + journal = models.ForeignKey( + "journal.Journal", + on_delete=models.CASCADE, + null=True, blank=True + ) + + article = models.ForeignKey( + "submission.Article", + on_delete=models.CASCADE, + null=True, blank=True + ) + + message = models.TextField() + message_date_time = models.DateTimeField(auto_now_add=True) + success = models.BooleanField(default=False) \ No newline at end of file diff --git a/templates/editorial_manager_transfer_service/manager.html b/templates/editorial_manager_transfer_service/manager.html index 5752626..a42675b 100644 --- a/templates/editorial_manager_transfer_service/manager.html +++ b/templates/editorial_manager_transfer_service/manager.html @@ -8,11 +8,13 @@
{% csrf_token %}
-
-

Configuration

-
-
- {{ form|foundation }} +
+
+

Editorial Manager Transfer Service Configuration

+
+
+ {{ form|foundation }} +
diff --git a/tests/test_file_creation.py b/tests/test_file_exporter.py similarity index 59% rename from tests/test_file_creation.py rename to tests/test_file_exporter.py index b5efd22..0d676f1 100644 --- a/tests/test_file_creation.py +++ b/tests/test_file_exporter.py @@ -1,25 +1,19 @@ +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + import os -import re import shutil -import unittest import xml.etree.ElementTree as ElementTree -from typing import Sequence from unittest.mock import patch -from hypothesis import given -from hypothesis import settings as hypothesis_settings -from hypothesis.strategies import from_regex, SearchStrategy, lists +from hypothesis import settings as hypothesis_settings, given +from hypothesis.extra.django import TestCase import plugins.editorial_manager_transfer_service.consts as consts import plugins.editorial_manager_transfer_service.file_exporter as file_exporter import plugins.editorial_manager_transfer_service.tests.utils.article_creation_utils as article_utils -from journal.models import Journal - -uuid4_regex = re.compile('^([a-z0-9]{8}(-[a-z0-9]{4}){3}-[a-z0-9]{12})$') -valid_filename_regex = re.compile("^[\w\-. ]+$") - -valid_filenames: SearchStrategy[list[str]] = lists(from_regex(valid_filename_regex), unique=True, min_size=0, - max_size=20) +from submission.models import Article def _get_setting(self, setting_name: str) -> str: @@ -32,11 +26,12 @@ def _get_setting(self, setting_name: str) -> str: return "SUBMISSION_PARTNER" -class TestFileCreation(unittest.TestCase): +class TestFileCreation(TestCase): def setUp(self): """ Sets up the export folder structure. """ + article_utils.database_crafter_do_preqs() if not os.path.exists(article_utils._get_article_export_folders()): try: os.makedirs(article_utils._get_article_export_folders()) @@ -49,32 +44,23 @@ def tearDown(self): """ shutil.rmtree(article_utils._get_article_export_folders()) - @given(article_id=from_regex(uuid4_regex), manuscript_filename=from_regex(valid_filename_regex), - data_figure_filenames=valid_filenames) + @given(article=article_utils.create_article()) @patch.object(file_exporter.ExportFileCreation, 'get_setting', new=_get_setting) @patch('plugins.editorial_manager_transfer_service.file_exporter.get_article_export_folders', new=article_utils._get_article_export_folders) - @patch.object(Journal.objects, "get", new=article_utils._get_journal) - @patch('submission.models.Article.get_article') @hypothesis_settings(max_examples=5) - def test_regular_article_creation_process(self, mock_get_article, article_id: str, manuscript_filename: str, - data_figure_filenames: Sequence[str]): + def test_regular_article_creation_process(self, article: Article) -> None: """ Tests a basic end to end use case of exporting articles. - :param mock_get_article: Mock the get_article method. - :param article_id: The id of the article. """ - journal_code = "TEST" + journal = article.journal + article_id: int = article.pk - # Set the return - mock_get_article.return_value = article_utils._create_article(Journal.objects.get(code=journal_code), article_id, manuscript_filename, - data_figure_filenames) - - exporter = file_exporter.ExportFileCreation(journal_code, article_id) + exporter = file_exporter.ExportFileCreation(journal.code, article_id) self.assertTrue(exporter.can_export()) - self.assertEqual(article_id.strip(), exporter.article_id) # add assertion here + self.assertEqual(article_id, exporter.article_id) # add assertion here - self.__check_go_file(exporter.get_go_filepath(), len(data_figure_filenames) + 1) + self.__check_go_file(exporter.get_go_filepath(), len(article.data_figure_files.all()) + 1) def __check_go_file(self, go_filepath: str, number_of_files: int) -> None: if not os.path.exists(go_filepath): @@ -96,7 +82,3 @@ def __check_go_file(self, go_filepath: str, number_of_files: int) -> None: files: list[ElementTree.Element] = filegroup.findall(consts.GO_FILE_ELEMENT_TAG_FILE) self.assertEqual(number_of_files, len(files)) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_message_logs.py b/tests/test_message_logs.py new file mode 100644 index 0000000..76849da --- /dev/null +++ b/tests/test_message_logs.py @@ -0,0 +1,66 @@ +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + +import os +import shutil + +import hypothesis.strategies as hypothesis_strategies +from hypothesis import given +from hypothesis.extra.django import TestCase + +import plugins.editorial_manager_transfer_service.tests.utils.article_creation_utils as article_utils +from plugins.editorial_manager_transfer_service.enums.transfer_log_message_type import TransferLogMessageType +from plugins.editorial_manager_transfer_service.models import TransferLogs +from submission.models import Article + + +class TestMessageLogs(TestCase): + def setUp(self): + """ + Sets up the export folder structure. + """ + article_utils.database_crafter_do_preqs() + if not os.path.exists(article_utils._get_article_export_folders()): + try: + os.makedirs(article_utils._get_article_export_folders()) + except FileExistsError: + pass + + def tearDown(self): + """ + Tears down after each test to ensure each test is unique. + """ + shutil.rmtree(article_utils._get_article_export_folders()) + + @given(message=hypothesis_strategies.text(), + message_type=hypothesis_strategies.sampled_from(TransferLogMessageType.choices), + success=hypothesis_strategies.booleans()) + def test_blank_transfer_logs(self, message: str, message_type: TransferLogMessageType, success: bool): + """ + Tests creating an empty TransferLogs object. + """ + TransferLogs.objects.create( + journal=None, + article=None, + message=message, + message_type=message_type, + success=success + ) + + @given(article=article_utils.create_article(), + message=hypothesis_strategies.text(), + message_type=hypothesis_strategies.sampled_from(TransferLogMessageType.choices), + success=hypothesis_strategies.booleans()) + def test_normal_transfer_logs(self, article: Article, message: str, message_type: TransferLogMessageType, + success: bool): + """ + Tests creating an empty TransferLogs object. + """ + TransferLogs.objects.create( + journal=article.journal, + article=article, + message=message, + message_type=message_type, + success=success + ) diff --git a/tests/utils/article_creation_utils.py b/tests/utils/article_creation_utils.py index 4e30466..bd39c88 100644 --- a/tests/utils/article_creation_utils.py +++ b/tests/utils/article_creation_utils.py @@ -1,13 +1,30 @@ +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + +import codecs +import json import os -from typing import Sequence -from unittest.mock import MagicMock +import re + +from django.conf import settings as django_settings +from django.core.files import File as DjangoFile +from hypothesis import strategies as st +from hypothesis.extra.django import from_field +from core import ( + models as core_models, + files as core_files, +) from core import settings -from core.models import File +from core.models import File, Account from journal.models import Journal from plugins.editorial_manager_transfer_service import consts from submission.models import Article +uuid4_regex = re.compile('^([a-z0-9]{8}(-[a-z0-9]{4}){3}-[a-z0-9]{12})$') +valid_filename_regex = re.compile("^[\w\-. ]+$") + EXPORT_FOLDER = os.path.join(settings.BASE_DIR, "collected-static", consts.SHORT_NAME, "export") @@ -20,46 +37,141 @@ def _get_article_export_folders() -> str: return EXPORT_FOLDER -def _get_journal(code: str) -> Journal: - journal: Journal = MagicMock(Journal) - journal.code = code +def database_crafter_do_preqs() -> None: + """ + A number of items are required to be setup in advance within the database. + """ + create_default_settings() + database_crafter_create_default_xsl() + + +def create_default_settings() -> None: + """ + Creates the default settings for to test this plugin. + """ + with codecs.open( + os.path.join(settings.BASE_DIR, "utils/install/submission_items.json") + ) as json_data: + submission_items = json.load(json_data) + for i, setting in enumerate(submission_items): + if not setting.get("group") == "special": + setting_group_obj, c = core_models.SettingGroup.objects.get_or_create( + name=setting.get("group"), + ) + core_models.Setting.objects.get_or_create( + group=setting_group_obj, + name=setting.get("name"), + ) + + +def database_crafter_create_default_xsl() -> None: + """ + Creates the default XSL file if it doesn't exist. + """ + try: + core_models.XSLFile.objects.get(label=django_settings.DEFAULT_XSL_FILE_LABEL) + except core_models.XSLFile.DoesNotExist: + core_models.XSLFile.objects.create(label=django_settings.DEFAULT_XSL_FILE_LABEL) + + +@st.composite +def create_journal(draw) -> Journal: + """ + Creates a journal object from the given settings. + :param draw: The Hypothesis object provided by the hypothesis framework. + :return: The newly created journal. + """ + code = draw(st.text(min_size=1, max_size=40)) + journal: Journal = Journal.objects.create(code=code) return journal -def _create_txt_file(filename: str) -> File: - manuscript_filepath = os.path.join(_get_article_export_folders(), "{0}.txt".format(filename)) +@st.composite +def create_username(draw) -> str | None: + """ + Creates a unique username or None, if the username created was not unique. + :param draw: The Hypothesis object provided by the hypothesis framework. + :return: A unique username or None, if the username created was not unique. + """ + username = draw(from_field(Account._meta.get_field('username'))) + try: + Account.objects.get(username=username) + return None + except Account.DoesNotExist: + return username + + +@st.composite +def create_unique_email(draw) -> str | None: + """ + Creates a unique email address or None, if the email address was not unique. + :param draw: A Hypothesis object provided by the hypothesis framework. + :return: A unique email address or None, if the email address was not unique. + """ + email = draw(st.emails()) + try: + Account.objects.get(email=email) + return None + except Account.DoesNotExist: + return email + + +@st.composite +def create_account(draw) -> Account: + """ + Creates a new account object from the given settings. + :param draw: A Hypothesis object provided by the hypothesis framework. + :return: A newly created account. + """ + username = draw(create_username()) + while not username: + username = draw(create_username()) + + email = draw(create_unique_email()) + while not email: + email = draw(create_unique_email()) + + return Account.objects.create( + email=email, + username=username, + ) + + +@st.composite +def create_txt_file(draw, article: Article) -> File: + filename: str = draw(st.from_regex(valid_filename_regex)) + manuscript_filepath = os.path.join(_get_article_export_folders(), + "{0}.txt".format(filename)) content = "This is the first line.\nThis is the second line." with open(manuscript_filepath, 'w') as file: try: file.write(content) + file.flush() file.close() except FileExistsError: pass + with open(manuscript_filepath, 'rb+') as file: + django_file = DjangoFile(file, name=filename) + return core_files.save_file_to_article(file_to_handle=django_file, article=article, + owner=draw(create_account())) - manuscript: File = MagicMock(File) - manuscript.get_file_path.return_value = manuscript_filepath - - return manuscript +@st.composite +def create_article(draw) -> Article: + journal: Journal = draw(create_journal()) + article: Article = Article.objects.create( + journal=journal, + ) -def _create_article(journal: Journal, article_id: str, manuscript_filename: str, data_figure_filenames: Sequence[str]) -> Article: - manuscript: File = _create_txt_file(manuscript_filename) + manuscript: File = draw(create_txt_file(article=article)) + article.manuscript_files.add(manuscript) - article: Article = MagicMock(Article) - article.article_id = article_id - article.journal = journal + # Handle the data figure files. + number_of_files: int = draw(st.integers(min_value=0, max_value=20)) + for i in range(number_of_files): + data_figure: File = draw(create_txt_file(article=article)) + article.data_figure_files.add(data_figure) - # Handle the manuscript files. - article.manuscript_files = MagicMock(File.objects) - manuscript_files: list[File] = list() - manuscript_files.append(manuscript) - article.manuscript_files.all.return_value = manuscript_files + article.save() - # Handle the data figure files. - article.data_figure_files = MagicMock(File.objects) - data_figure_files: list[File] = list() - for data_figure_filename in data_figure_filenames: - data_figure: File = _create_txt_file(data_figure_filename) - data_figure_files.append(data_figure) - article.data_figure_files.all.return_value = data_figure_files return article diff --git a/urls.py b/urls.py index 2bf794c..9b43176 100644 --- a/urls.py +++ b/urls.py @@ -1,3 +1,7 @@ +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + from django.urls import re_path from plugins.editorial_manager_transfer_service import views diff --git a/views.py b/views.py index 2e25840..5baf3ca 100644 --- a/views.py +++ b/views.py @@ -1,3 +1,7 @@ +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + from django.contrib.admin.views.decorators import staff_member_required from django.shortcuts import render