diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 9b28454..4b84a79 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -17,25 +17,41 @@ jobs: steps: - name: Fetch Main Janeway Branch - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: PLOS/janeway ref: develop path: /home/runner/work/editorial_manager_transfer_service/editorial_manager_transfer_service/janeway - name: Fetch Editorial Manager Transfer Service - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: path: /home/runner/work/editorial_manager_transfer_service/editorial_manager_transfer_service/janeway/src/plugins/editorial_manager_transfer_service - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - path: /home/runner/work/editorial_manager_transfer_service/editorial_manager_transfer_service/janeway + - name: Cache pip dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/pip # Path to pip's cache directory + key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} # Unique cache key + restore-keys: | + ${{ runner.os }}-pip- - name: Install Dependencies - working-directory: /home/runner/work/editorial_manager_transfer_service/editorial_manager_transfer_service/janeway run: | python -m pip install --upgrade pip - sudo find . -name "*requirements.txt" -type f -exec pip3 install -r '{}' ';' + find . -name "*requirements.txt" -type f -exec pip install -r '{}' ';' + - name: Add `src` to Python Path + run: | + echo "PYTHONPATH=/home/runner/work/editorial_manager_transfer_service/editorial_manager_transfer_service/janeway/src" >> $GITHUB_ENV + shell: bash + - name: Import the Default Janeway Settings + run: | + cp /home/runner/work/editorial_manager_transfer_service/editorial_manager_transfer_service/janeway/src/core/dev_settings.py /home/runner/work/editorial_manager_transfer_service/editorial_manager_transfer_service/janeway/src/core/settings.py - name: Run Tests + env: + JANEWAY_SETTINGS_MODULE: core.settings + DJANGO_SETTINGS_MODULE: core.settings + working-directory: /home/runner/work/editorial_manager_transfer_service/editorial_manager_transfer_service/janeway run: | - pytest /home/runner/work/editorial_manager_transfer_service/editorial_manager_transfer_service/janeway/src/plugins/editorial_manager_transfer_service/ + python src/manage.py test editorial_manager_transfer_service diff --git a/.gitignore b/.gitignore index ef81b1e..daa5a64 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /.venv/ +/editorial_manager_transfer_service.iml +/.idea/ +/.hypothesis/ diff --git a/README.md b/README.md index e3d5ec9..bb5a8f8 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,83 @@ A plugin to provide information for Aries' Editorial Manager to enable automatic ## Requirements This plugin depends on the Production Transporter plugin in order to work properly. + +## Development Tips +This section contains guidance for developing this plugin. + +For the purposes of this guide we'll refer to the Janeway installation folder as `[workspace]`. + +The plugin's installation folder is assumed to be `[workspace]/src/plugins/editorial_manager_transfer_service`. + +### Adding The Janeway SRC to the `PYTHONPATH` +Adding the Janeway SRC folder to the `PYTHONPATH` can help any type of IDE correctly identify imports while developing plugins. + +#### Instructions for Python Virtual Environment (VENV) +First, open your `activate` file in a text editor. It is located at: +```text +[workspace]/.venv/bin/activate +``` +Once open, scroll to find the following text section: +```bash +VIRTUAL_ENV="[workspace]/.venv" +if ([ "$OSTYPE" = "cygwin" ] || [ "$OSTYPE" = "msys" ]) && $(command -v cygpath &> /dev/null) ; then + VIRTUAL_ENV=$(cygpath -u "$VIRTUAL_ENV") +fi +export VIRTUAL_ENV + + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/bin:$PATH" +export PATH +``` +Please note and change the `[worksapce]` variable to your Janeway installation path. + +You will modify this section to become the following: +```bash +_PYTHON_ENV_PKG='[workspace]' +VIRTUAL_ENV="$_PYTHON_ENV_PKG/.venv" +if ([ "$OSTYPE" = "cygwin" ] || [ "$OSTYPE" = "msys" ]) && $(command -v cygpath &> /dev/null) ; then + VIRTUAL_ENV=$(cygpath -u "$VIRTUAL_ENV") +fi +export VIRTUAL_ENV + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/bin:$PATH" +export PATH + +_OLD_VIRTUAL_PYTHONPATH="$PYTHONPATH" +PYTHONPATH="$_PYTHON_ENV_PKG:$PYTHONPATH" +export PYTHONPATH +``` +Next you will modify the `deactivate()` script. Scroll until you find this section of script: +```bash +deactivate () { + unset -f pydoc >/dev/null 2>&1 || true + + # reset old environment variables + # ! [ -z ${VAR+_} ] returns true if VAR is declared at all + if ! [ -z "${_OLD_VIRTUAL_PATH:+_}" ] ; then + PATH="$_OLD_VIRTUAL_PATH" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if ! [ -z "${_OLD_VIRTUAL_PYTHONHOME+_}" ] ; then + PYTHONHOME="$_OLD_VIRTUAL_PYTHONHOME" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi +``` +Modify it to add the `PYTHONPATH` deactivation: +```bash + ... + unset _OLD_VIRTUAL_PATH + fi + if ! [ -z "${_OLD_VIRTUAL_PYTHONPATH:+_}" ] ; then + PYTHONPATH="$_OLD_VIRTUAL_PYTHONPATH" + export PYTHONPATH + unset _OLD_VIRTUAL_PYTHONPATH + fi + if ! [ -z "${_OLD_VIRTUAL_PYTHONHOME+_}" ] ; then + ... +``` +After restarting your IDE, you should see it properly detect the SRC folder. \ No newline at end of file diff --git a/consts.py b/consts.py new file mode 100644 index 0000000..6d84160 --- /dev/null +++ b/consts.py @@ -0,0 +1,48 @@ +import os + +from django.conf import settings + +# Plugin Settings +PLUGIN_NAME = 'Editorial Manager Transfer Service Plugin' +DISPLAY_NAME = 'Editorial Manager Transfer Service' +DESCRIPTION = 'A plugin to provide information for Aries\' Editorial Manager to enable automatic transfers.' +AUTHOR = 'PLOS' +VERSION = '0.1' +SHORT_NAME = 'editorial_manager_transfer_service' +MANAGER_URL = 'editorial_manager_transfer_service_manager' +JANEWAY_VERSION = "1.8.0" + +# Setting Configuration +PLUGIN_SETTINGS_GROUP_NAME = "plugin:editorial_manager_transfer_service" +PLUGIN_SETTINGS_LICENSE_CODE = "license_code" +PLUGIN_SETTINGS_JOURNAL_CODE = "journal_code" +PLUGIN_SETTINGS_SUBMISSION_PARTNER_CODE = "submission_partner_code" + +# Import and export filepaths +EXPORT_FILE_PATH = os.path.join(settings.BASE_DIR, 'files', 'plugins', 'editorial-manager-transfer-service', 'export') +IMPORT_FILE_PATH = os.path.join(settings.BASE_DIR, 'files', 'plugins', 'editorial-manager-transfer-service', 'import') + +# XML File +GO_FILE_ELEMENT_TAG_GO = "GO" +GO_FILE_GO_ELEMENT_ATTRIBUTE_XMLNS_XSI_KEY = "xmlns:xsi" +GO_FILE_GO_ELEMENT_ATTRIBUTE_XMLNS_XSI_VALUE = "http://www.w3.org/2001/XMLSchema-instance" +GO_FILE_GO_ELEMENT_ATTRIBUTE_SCHEMA_LOCATION_KEY = "xsi:noNamespaceSchemaLocation" +GO_FILE_GO_ELEMENT_ATTRIBUTE_SCHEMA_LOCATION_VALUE = "app://Aries.EditorialManager/Resources/XmlDefineTransformFiles/aries_import_go_file.xsd" +GO_FILE_ELEMENT_TAG_HEADER = "header" +GO_FILE_ELEMENT_TAG_VERSION = "version" +GO_FILE_VERSION_ELEMENT_ATTRIBUTE_NUMBER_KEY = "number" +GO_FILE_VERSION_ELEMENT_ATTRIBUTE_NUMBER_VALUE = "1.0" +GO_FILE_ELEMENT_TAG_JOURNAL = "journal" +GO_FILE_JOURNAL_ELEMENT_ATTRIBUTE_CODE_KEY = "code" +GO_FILE_ELEMENT_TAG_IMPORT_TYPE = "import-type" +GO_FILE_IMPORT_TYPE_ELEMENT_ATTRIBUTE_ID_KEY = "id" +GO_FILE_IMPORT_TYPE_ELEMENT_ATTRIBUTE_ID_VALUE = "2" +GO_FILE_ELEMENT_TAG_PARAMETERS = "parameters" +GO_FILE_ELEMENT_TAG_PARAMETER = "parameter" +GO_FILE_ATTRIBUTE_ELEMENT_NAME_KEY = "name" +GO_FILE_PARAMETER_ELEMENT_NAME_VALUE = "license-code" +GO_FILE_PARAMETER_ELEMENT_VALUE_KEY = "value" +GO_FILE_ELEMENT_TAG_ARCHIVE_FILE = "archive-file" +GO_FILE_ELEMENT_TAG_FILEGROUP = "filegroup" +GO_FILE_ELEMENT_TAG_FILE = "file" +GO_FILE_ELEMENT_TAG_METADATA_FILE = "metadata-file" diff --git a/dev-requirements.txt b/dev-requirements.txt index e079f8a..2da04d8 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1 +1,2 @@ -pytest +hypothesis==6.138.7 +pytest==8.4.1 \ No newline at end of file diff --git a/file_exporter.py b/file_exporter.py new file mode 100644 index 0000000..9310934 --- /dev/null +++ b/file_exporter.py @@ -0,0 +1,295 @@ +""" +A list of functions handling file exports and imports. +""" +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + +import os +import uuid +import xml.etree.cElementTree as ETree +import zipfile +from collections.abc import Sequence +from typing import List + +from django.core.exceptions import ObjectDoesNotExist + +import plugins.editorial_manager_transfer_service.consts as consts +import plugins.editorial_manager_transfer_service.logger_messages as logger_messages +from core.models import File +from journal.models import Journal +from submission.models import Article +from utils import setting_handler +from utils.logger import get_logger + +logger = get_logger(__name__) + + +def get_article_export_folders() -> str: + """ + Gets the filepaths for the folders used for exporting articles. + + :return: A list of filepaths for the export folders. + """ + if os.path.exists(consts.EXPORT_FILE_PATH): + return consts.EXPORT_FILE_PATH + else: + return "" + + +class ExportFileCreation: + """ + A class for managing the export file creation process. + """ + + def __init__(self, journal_code: str, article_id: str): + 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: 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 + 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 + 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.in_error_state = True + return + self.export_folder = export_folders + + # Start export process + self.__create_export_file() + + def get_zip_filepath(self) -> str | None: + """ + Gets the zip file path for the exported files. + :return: The zip file path. + """ + if self.zip_filepath is None: + self.in_error_state = True + return None + else: + return self.zip_filepath + + def get_go_filepath(self) -> str | None: + """ + Gets the filepath for the go.xml file for exporting to Editorial Manager. + :return: The filepath for the go.xml file or None, if the process failed. + """ + if self.go_filepath is None: + self.in_error_state = True + return None + else: + return self.go_filepath + + def __create_export_file(self): + """ + Creates the export file for + """ + + if not self.can_export(): + self.in_error_state = True + return + + # TODO: Attempt to create the metadata file. + # metadata_file: File | None = self.__create_metadata_file(self.article) + # if metadata_file is None: + # logger.error(logger_messages.process_failed_fetching_metadata(self.article_id)) + # self.in_error_state = True + # return + + # 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.in_error_state = True + return + + prefix: str = "{0}_{1}".format(self.get_submission_partner_code(), uuid.uuid4()) + + self.zip_filepath: str = os.path.join(self.export_folder, "{0}.zip".format(prefix)) + with zipfile.ZipFile(self.zip_filepath, "w") as zipf: + # TODO: zipf.write(metadata_file.get_file_path(self.article)) + for article_file in article_files: + zipf.write(article_file.get_file_path(self.article)) + filenames: Sequence[str] = zipf.namelist() + zipf.close() + + # Remove the manuscript + + # TODO: Remove below and replace with 'self.__create_go_xml_file(metadata_file.uuid_filename, filenames, prefix)' + self.__create_go_xml_file("fake name", filenames, prefix) + + def get_license_code(self) -> str: + """ + Gets the license code for exporting files. + :return: The license code or None, if the process failed. + """ + if not self.__license_code: + self.__license_code: str = self.get_setting(consts.PLUGIN_SETTINGS_LICENSE_CODE) + return self.__license_code + + def get_journal_code(self) -> str: + """ + Gets the journal code for exporting files. + :return: The journal code or None, if the process failed. + """ + if not self.__journal_code: + self.__journal_code: str = self.get_setting(consts.PLUGIN_SETTINGS_JOURNAL_CODE) + return self.__journal_code + + def get_submission_partner_code(self) -> str: + """ + Gets the submission partner code for exporting files. + :return: The submission partner code or None, if the process failed. + """ + if not self.__submission_partner_code: + self.__submission_partner_code: str = self.get_setting(consts.PLUGIN_SETTINGS_SUBMISSION_PARTNER_CODE) + return self.__submission_partner_code + + def can_export(self) -> bool: + """ + Checks if the export file can be created. + :return: True if the export file can be created, False otherwise. + """ + return (not self.in_error_state and + self.article is not None and + self.journal is not None and + self.get_license_code() is not None and + self.get_journal_code() is not None and + self.get_submission_partner_code() is not None) + + def get_setting(self, setting_name: str) -> str: + """ + Gets the setting for the given setting name. + :param setting_name: The name of the setting to get the value for. + :return: The value for the given setting or a blank string, if the process failed. + """ + 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)) + self.in_error_state = True + return "" + + def __create_go_xml_file(self, metadata_filename: str, article_filenames: Sequence[str], filename: str): + """ + Creates the go xml file for the export process for Editorial Manager. + :param metadata_filename: The name of the metadata file. + :param article_filenames: The filenames of the article's associated files. + :param filename: The name to use for the go.xml file (Must match the name of the zip file). + """ + if not self.can_export(): + self.in_error_state = True + return + + go: ETree.Element = ETree.Element(consts.GO_FILE_ELEMENT_TAG_GO) + go.set(consts.GO_FILE_GO_ELEMENT_ATTRIBUTE_XMLNS_XSI_KEY, consts.GO_FILE_GO_ELEMENT_ATTRIBUTE_XMLNS_XSI_VALUE) + go.set(consts.GO_FILE_GO_ELEMENT_ATTRIBUTE_SCHEMA_LOCATION_KEY, + consts.GO_FILE_GO_ELEMENT_ATTRIBUTE_SCHEMA_LOCATION_VALUE) + + # Format the header. + header: ETree.Element = ETree.SubElement(go, consts.GO_FILE_ELEMENT_TAG_HEADER) + version: ETree.Element = ETree.SubElement(header, consts.GO_FILE_ELEMENT_TAG_VERSION) + 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()) + 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) + parameters: ETree.Element = ETree.SubElement(header, consts.GO_FILE_ELEMENT_TAG_PARAMETERS) + parameter: ETree.Element = ETree.SubElement(parameters, consts.GO_FILE_ELEMENT_TAG_PARAMETER) + parameter.set(consts.GO_FILE_ATTRIBUTE_ELEMENT_NAME_KEY, consts.GO_FILE_PARAMETER_ELEMENT_NAME_VALUE) + parameter.set(consts.GO_FILE_PARAMETER_ELEMENT_VALUE_KEY, + "{0}_{1}".format(self.get_submission_partner_code(), self.get_license_code())) + + # Begin the filegroup. + filegroup: ETree.Element = ETree.SubElement(go, consts.GO_FILE_ELEMENT_TAG_FILEGROUP) + + # Create the archive and metadata files. + archive_file: ETree.Element = ETree.SubElement(filegroup, consts.GO_FILE_ELEMENT_TAG_ARCHIVE_FILE) + archive_file.set(consts.GO_FILE_ATTRIBUTE_ELEMENT_NAME_KEY, "{0}.zip".format(filename)) + metadata_file: ETree.Element = ETree.SubElement(filegroup, consts.GO_FILE_ELEMENT_TAG_METADATA_FILE) + metadata_file.set(consts.GO_FILE_ATTRIBUTE_ELEMENT_NAME_KEY, metadata_filename) + + for article_filename in article_filenames: + file_tree = ETree.SubElement(filegroup, consts.GO_FILE_ELEMENT_TAG_FILE) + file_tree.set(consts.GO_FILE_ATTRIBUTE_ELEMENT_NAME_KEY, article_filename) + + tree = ETree.ElementTree(go) + self.go_filepath = os.path.join(self.export_folder, "{0}.go.xml".format(filename)) + tree.write(self.go_filepath) + + def __create_metadata_file(self, article: Article) -> File | None: + """ + Creates the metadata file based on the given article. + :param article: The article to convert to JATS. + :return: + """ + pass + + @staticmethod + def __fetch_article(journal: Journal, article_id: str) -> Article: + """ + 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) + + @staticmethod + def __fetch_article_files(article: Article) -> List[File]: + """ + Fetches the manuscript (or content or body) of an article alongside any other files associated with it. + :param article: The article to fetch the manuscript files for. + :return: A list of all files related to the article. + """ + + files: List[File] = list() + + for manuscript in article.manuscript_files.all(): + files.append(manuscript) + + for data_file in article.data_figure_files.all(): + files.append(data_file) + + for source_file in article.source_files.all(): + files.append(source_file) + + for supplementary_file in article.supplementary_files.all(): + files.append(supplementary_file) + + return files diff --git a/file_transfer_service.py b/file_transfer_service.py new file mode 100644 index 0000000..0a883a4 --- /dev/null +++ b/file_transfer_service.py @@ -0,0 +1,141 @@ +""" +This service is used to manage the transfers to and from Aries's Editorial Manager system. +""" +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + +import os +from typing import List + +from plugins.editorial_manager_transfer_service.file_exporter import ExportFileCreation +from utils.logger import get_logger + +logger = get_logger(__name__) + + +class FileTransferService: + """ + Manages the transfers to and from Aries's Editorial Manager system. + """ + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + """ + Constructor. + """ + if not hasattr(self, '_initialized'): # Prevent re-initialization on subsequent calls + self.exports: dict[str, ExportFileCreation] = dict() + self.files_to_delete: List[str] = list() + self._initialized = True + + def get_export_file_creator(self, journal_code: str, article_id: str) -> ExportFileCreation | None: + """ + Gets the export file creator 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 export file creator. + """ + + dictionary_identifier: str = self.__get_dictionary_identifier(journal_code, article_id) + + if dictionary_identifier not in self.exports: + file_creator = ExportFileCreation(journal_code, article_id) + if file_creator.in_error_state: + return None + self.exports[dictionary_identifier] = file_creator + return self.exports[dictionary_identifier] + + @staticmethod + def __get_dictionary_identifier(journal_code: str, article_id: str) -> str: + return f"{journal_code}-{article_id}" + + def get_export_zip_filepath(self, journal_code: str, article_id: str) -> str | None: + """ + Gets the export zip file path for the given article. + :param journal_code: The journal code of the journal the article lives in. + :param article_id: The article id. + :return: The export zip file path. + """ + file_export_creator = self.get_export_file_creator(journal_code, article_id) + return file_export_creator.get_zip_filepath() if file_export_creator else None + + def get_export_go_filepath(self, journal_code: str, article_id: str) -> str | None: + """ + Gets the export go file path for the given article. + :param journal_code: The journal code of the journal the article lives in. + :param article_id: The article id. + :return: The export go file path. + """ + 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 delete_export_files(self, journal_code: str, article_id: str) -> None: + dictionary_identifier: str = self.__get_dictionary_identifier(journal_code, article_id) + if dictionary_identifier not in self.exports: + return + file_exporter = self.exports.pop(dictionary_identifier) + + self.files_to_delete.append(file_exporter.get_zip_filepath()) + self.files_to_delete.append(file_exporter.get_go_filepath()) + + del file_exporter + + def __delete_files(self) -> None: + for file in self.files_to_delete: + if self.__delete_file(file): + self.files_to_delete.remove(file) + + @staticmethod + def __delete_file(filepath: str) -> bool: + if not os.path.exists(filepath): + return True + try: + os.remove(filepath) + except OSError: + return False + + return True + + +def get_export_zip_filepath(journal_code: str, article_id: str) -> str | None: + """ + Gets the zip file path for a given article. + :param journal_code: The journal code of the journal the article lives in. + :param article_id: The article id. + :return: The zip file path. + """ + return FileTransferService().get_export_zip_filepath(journal_code, article_id) + + +def get_export_go_filepath(journal_code: str, article_id: str) -> str | None: + """ + Gets the export file path for a go file created for a given article. + :param journal_code: The journal code of the journal the article lives in. + :param article_id: The article id. + :return: The export go file path. + """ + return FileTransferService().get_export_go_filepath(journal_code, article_id) + + +def export_success_callback(journal_code: str, article_id: str) -> None: + """ + The callback in case of a successful export. + :param journal_code: The journal code of the journal the article lives in. + :param article_id: The article id. + """ + FileTransferService().delete_export_files(journal_code, article_id) + + +def export_failure_callback(journal_code: str, article_id: str) -> None: + """ + The callback in case of a failed export. + :param journal_code: The journal code of the journal the article lives in. + :param article_id: The article id. + """ + FileTransferService().delete_export_files(journal_code, article_id) diff --git a/forms.py b/forms.py index 6459616..6cb0577 100644 --- a/forms.py +++ b/forms.py @@ -1,5 +1,10 @@ from django import forms -class DummyManagerForm(forms.Form): - dummy_field = forms.CharField() +class EditorialManagerTransferServiceForm(forms.Form): + """ + The form for the Editorial Manager Transfer Service + """ + submission_partner_code = forms.CharField(required=False, help_text="Your organization's Submission Partner Code.") + license_code = forms.CharField(required=False, help_text="The license code for your organization.") + journal_code = forms.CharField(required=False, help_text="The code for the current journal.") diff --git a/models.py b/install/__init__.py similarity index 100% rename from models.py rename to install/__init__.py diff --git a/install/settings.json b/install/settings.json new file mode 100644 index 0000000..5a6dfaa --- /dev/null +++ b/install/settings.json @@ -0,0 +1,47 @@ +[ + { + "group": { + "name": "plugin:editorial_manager_transfer_service" + }, + "setting": { + "description": "Your journal's submission partner code for Editorial Manager. Capitalization DOES matter.", + "is_translatable": false, + "name": "submission_partner_code", + "pretty_name": "Submission Partner Code", + "type": "char" + }, + "value": { + "default": "" + } + }, + { + "group": { + "name": "plugin:editorial_manager_transfer_service" + }, + "setting": { + "description": "Your journal's license code for Editorial Manager.", + "is_translatable": false, + "name": "license_code", + "pretty_name": "License Code", + "type": "char" + }, + "value": { + "default": "" + } + }, + { + "group": { + "name": "plugin:editorial_manager_transfer_service" + }, + "setting": { + "description": "Your journal's journal code for Editorial Manager.", + "is_translatable": false, + "name": "journal_code", + "pretty_name": "Journal Code", + "type": "char" + }, + "value": { + "default": "" + } + } +] \ No newline at end of file diff --git a/logger_messages.py b/logger_messages.py new file mode 100644 index 0000000..0b0e489 --- /dev/null +++ b/logger_messages.py @@ -0,0 +1,125 @@ +""" +Holds all log messages to remove "magic strings" from the codebase and make reviewing confusing messages easier. +""" +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + +from plugins.editorial_manager_transfer_service.consts import PLUGIN_NAME, EXPORT_FILE_PATH, IMPORT_FILE_PATH + + +def plugin_installation_beginning() -> str: + """ + Gets the log message for a plugin beginning installation. + :return: The logger message. + """ + return '{0} installation beginning...'.format(PLUGIN_NAME) + + +def plugin_installed() -> str: + """ + Gets the log message for a plugin being successfully installed. + :return: The logger message. + """ + return '{0} installed.'.format(PLUGIN_NAME) + + +def plugin_already_installed() -> str: + """ + Gets the log message for a plugin has been previously installed. + :return: The logger message. + """ + return '{0} is already installed.'.format(PLUGIN_NAME) + + +def export_folder_creating() -> str: + """ + Gets the log message for when an export folder is being created. + :return: The logger message. + """ + return '{0} creating export folder (Filepath: \"{1}\")...'.format(PLUGIN_NAME, EXPORT_FILE_PATH) + + +def export_folder_created() -> str: + """ + Gets the log message for when an export folder has been created. + :return: The logger message. + """ + return '{0} export folder already exists.'.format(PLUGIN_NAME) + + +def import_folder_creating() -> str: + """ + Gets the log message for when an import folder is being created. + :return: The logger message. + """ + return '{0} creating import folder (Filepath: \"{1}\")...'.format(PLUGIN_NAME, IMPORT_FILE_PATH) + + +def import_folder_created() -> str: + """ + Gets the log message for when an import folder has been created. + :return: The logger message. + """ + return '{0} import folder already exists.'.format(PLUGIN_NAME) + + +def process_fetching_article(article_id: str) -> 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 "Fetching article from database (ID: {0})...".format(article_id) + + +def process_failed_fetching_article(article_id: str) -> str: + """ + Gets the log message for when an article failed to be fetched. + :param: article_id: The ID of the article being fetched. + :return: The logger message. + """ + return "Fetching article from database (ID: {0}) failed. Discontinuing export process.".format(article_id) + + +def process_failed_fetching_metadata(article_id) -> str: + """ + Gets the log message for when an article's metadata failed to be fetched. + :param: article_id: The ID of the article being fetched. + :return: The logger message. + """ + return "Fetching article (ID: {0}) metadata failed. Discontinuing export process.".format(article_id) + + +def process_failed_fetching_article_files(article_id) -> str: + """ + Gets the log message for when an article's files failed to be fetched. + :param: article_id: The ID of the article being fetched. + :return: The logger message. + """ + return "Fetching files for article (ID: {0}) failed. Discontinuing export process.".format(article_id) + + +def process_failed_fetching_journal(article_id) -> str: + """ + Gets the log message for when an article's journal failed to be fetched. + :param: article_id: The ID of the article. + :return: The logger message. + """ + return "Fetching journal where article (ID: {0}) lives failed. Discontinuing export process.".format(article_id) + + +def process_failed_no_article_id_provided() -> str: + """ + Gets the log message for when an article ID was not provided. + :return: The logger message. + """ + return "No article ID 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." diff --git a/logic.py b/logic.py new file mode 100644 index 0000000..aa8dc4e --- /dev/null +++ b/logic.py @@ -0,0 +1,62 @@ +import plugins.editorial_manager_transfer_service.consts as consts +from journal.models import Journal +from utils import setting_handler +from utils.logger import get_logger + +logger = get_logger(__name__) + + +def get_plugin_settings(journal: Journal): + """ + Get the plugin settings for the Editorial Manager Transfer Service. + :param journal: the journal + """ + + logger.debug("Fetching journal settings for the following journal: %s", journal.code) + submission_partner_code = setting_handler.get_setting( + setting_group_name=consts.PLUGIN_SETTINGS_GROUP_NAME, + setting_name="submission_partner_code", + journal=journal, + ).processed_value + license_code = setting_handler.get_setting( + setting_group_name=consts.PLUGIN_SETTINGS_GROUP_NAME, + setting_name="license_code", + journal=journal, + ).processed_value + journal_code = setting_handler.get_setting( + setting_group_name=consts.PLUGIN_SETTINGS_GROUP_NAME, + setting_name="journal_code", + journal=journal, + ).processed_value + + return ( + submission_partner_code, + license_code, + journal_code, + ) + + +def save_plugin_settings( + submission_partner_code, + license_code, + journal_code, + request, +): + setting_handler.save_setting( + setting_group_name=consts.PLUGIN_SETTINGS_GROUP_NAME, + setting_name="submission_partner_code", + journal=request.journal, + value=submission_partner_code, + ) + setting_handler.save_setting( + setting_group_name=consts.PLUGIN_SETTINGS_GROUP_NAME, + setting_name="license_code", + journal=request.journal, + value=license_code, + ) + setting_handler.save_setting( + setting_group_name=consts.PLUGIN_SETTINGS_GROUP_NAME, + setting_name="journal_code", + journal=request.journal, + value=journal_code, + ) diff --git a/management/__init__.py b/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/management/commands/__init__.py b/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/management/commands/create_export_zip.py b/management/commands/create_export_zip.py new file mode 100644 index 0000000..be392cb --- /dev/null +++ b/management/commands/create_export_zip.py @@ -0,0 +1,38 @@ +""" +Commands for exporting and importing files to/form Aries's Editorial Manager. +""" + +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + +from django.core.management.base import BaseCommand, CommandError + +import plugins.editorial_manager_transfer_service.file_transfer_service as file_transfer_service + + +class Command(BaseCommand): + """Creates an export ZIP from an article.""" + + help = "Creates an export ZIP from an article." + + def add_arguments(self, parser): + parser.add_argument('article_id', help="The ID of the article to create a zip file for.") + parser.add_argument('journal_code', help="The code of the journal where the article to export lives.") + + def handle(self, *args, **options): + article_id: str = open(options["article_id"], "r", encoding="utf-8-sig").read().strip() + journal_code: str = open(options["journal_code"], "r", encoding="utf-8-sig").read().strip() + + print("Beginning bundling process for article...") + export_zip_file: str = file_transfer_service.get_export_zip_filepath(journal_code, article_id) + if not export_zip_file: + raise CommandError("Error while creating export ZIP.") + + export_go_file: str = file_transfer_service.get_export_go_filepath(journal_code, article_id) + if not export_go_file: + raise CommandError("Error while creating export GO file.") + + print("Export files created.") + + # TODO: Initiate HTTP request to FTP server. diff --git a/plugin_settings.py b/plugin_settings.py index 21db733..e58730f 100644 --- a/plugin_settings.py +++ b/plugin_settings.py @@ -1,31 +1,77 @@ +""" +A plugin to provide information for Aries' Editorial Manager to enable automatic transfers. +""" +__author__ = "Rosetta Reatherford" +__license__ = "AGPL v3" +__maintainer__ = "The Public Library of Science (PLOS)" + +import os + +import plugins.editorial_manager_transfer_service.consts as consts +import plugins.editorial_manager_transfer_service.logger_messages as logger_messages from utils import plugins +from utils.install import update_settings +from utils.logger import get_logger -PLUGIN_NAME = 'Editorial Manager Transfer Service Plugin' -DISPLAY_NAME = 'Editorial Manager Transfer Service' -DESCRIPTION = 'A plugin to provide information for Aries' Editorial Manager to enable automatic transfers.' -AUTHOR = 'PLOS' -VERSION = '0.1' -SHORT_NAME = 'editorial_manager_transfer_service' -MANAGER_URL = 'editorial_manager_transfer_service_manager' -JANEWAY_VERSION = "1.3.8" +logger = get_logger(__name__) +# Plugin Settings +PLUGIN_NAME = consts.PLUGIN_NAME +DISPLAY_NAME = consts.DISPLAY_NAME +DESCRIPTION = consts.DESCRIPTION +AUTHOR = consts.AUTHOR +VERSION = consts.VERSION +SHORT_NAME = consts.SHORT_NAME +MANAGER_URL = consts.MANAGER_URL +JANEWAY_VERSION = consts.JANEWAY_VERSION class EditorialManagerTransferServicePlugin(plugins.Plugin): - plugin_name = PLUGIN_NAME - display_name = DISPLAY_NAME - description = DESCRIPTION - author = AUTHOR - short_name = SHORT_NAME - manager_url = MANAGER_URL + """ + The plugin class for the Editorial Manager Transfer Service. + """ + plugin_name = consts.PLUGIN_NAME + display_name = consts.DISPLAY_NAME + description = consts.DESCRIPTION + author = consts.AUTHOR + short_name = consts.SHORT_NAME + manager_url = consts.MANAGER_URL - version = VERSION - janeway_version = JANEWAY_VERSION - + version = consts.VERSION + janeway_version = consts.JANEWAY_VERSION def install(): - EditorialManagerTransferServicePlugin.install() + """ + Installs the Editorial Manager Transfer Service. + """ + logger.info(logger_messages.plugin_installation_beginning()) + update_settings( + file_path="plugins/editorial_manager_transfer_service/install/settings.json" + ) + plugin, created = EditorialManagerTransferServicePlugin.install() + + if created: + # Create the export folder. + try: + logger.info(logger_messages.export_folder_creating()) + os.makedirs(consts.EXPORT_FILE_PATH) + except FileExistsError: + logger.info(logger_messages.export_folder_created()) + pass + + # Create the import folder. + try: + logger.info(logger_messages.import_folder_creating()) + os.makedirs(consts.IMPORT_FILE_PATH) + except FileExistsError: + logger.info(logger_messages.import_folder_created()) + pass + + # Log the plugin was installed. + logger.info(logger_messages.plugin_installed()) + else: + logger.info(logger_messages.plugin_already_installed()) def hook_registry(): diff --git a/templates/editorial_manager_transfer_service/manager.html b/templates/editorial_manager_transfer_service/manager.html index 22a8d06..5752626 100644 --- a/templates/editorial_manager_transfer_service/manager.html +++ b/templates/editorial_manager_transfer_service/manager.html @@ -1,15 +1,27 @@ {% extends "admin/core/base.html" %} {% load foundation %} -{% block title %}editorial_manager_transfer_service Manager{% endblock %} +{% block title %}Editorial Manager Transfer Service Configuration{% endblock %} {% block body %} -
-
-

Management Form

-
-
- {{ form|foundation }} -
+
+
+ {% csrf_token %} +
+
+

Configuration

+
+
+ {{ form|foundation }} +
+
+
+
+ +
+
+
+
+
{% endblock body %} \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_file_creation.py b/tests/test_file_creation.py new file mode 100644 index 0000000..b5efd22 --- /dev/null +++ b/tests/test_file_creation.py @@ -0,0 +1,102 @@ +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 + +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) + + +def _get_setting(self, setting_name: str) -> str: + match setting_name: + case consts.PLUGIN_SETTINGS_LICENSE_CODE: + return "LCODE" + case consts.PLUGIN_SETTINGS_JOURNAL_CODE: + return "JOURNAL" + case consts.PLUGIN_SETTINGS_SUBMISSION_PARTNER_CODE: + return "SUBMISSION_PARTNER" + + +class TestFileCreation(unittest.TestCase): + def setUp(self): + """ + Sets up the export folder structure. + """ + 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(article_id=from_regex(uuid4_regex), manuscript_filename=from_regex(valid_filename_regex), + data_figure_filenames=valid_filenames) + @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]): + """ + 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" + + # 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) + self.assertTrue(exporter.can_export()) + self.assertEqual(article_id.strip(), exporter.article_id) # add assertion here + + self.__check_go_file(exporter.get_go_filepath(), len(data_figure_filenames) + 1) + + def __check_go_file(self, go_filepath: str, number_of_files: int) -> None: + if not os.path.exists(go_filepath): + self.fail("Go_filepath {} does not exist".format(go_filepath)) + + # Get the XML file. + try: + tree = ElementTree.parse(go_filepath) + except ElementTree.ParseError: + self.fail("Go_filepath {} could not be parsed".format(go_filepath)) + + root: ElementTree.Element = tree.getroot() + self.assertIsNotNone(root) + self.assertEqual(consts.GO_FILE_ELEMENT_TAG_GO, root.tag) + + filegroup: ElementTree.Element = root.find(consts.GO_FILE_ELEMENT_TAG_FILEGROUP) + self.assertIsNotNone(filegroup) + self.assertEqual(consts.GO_FILE_ELEMENT_TAG_FILEGROUP, filegroup.tag) + + 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/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/article_creation_utils.py b/tests/utils/article_creation_utils.py new file mode 100644 index 0000000..4e30466 --- /dev/null +++ b/tests/utils/article_creation_utils.py @@ -0,0 +1,65 @@ +import os +from typing import Sequence +from unittest.mock import MagicMock + +from core import settings +from core.models import File +from journal.models import Journal +from plugins.editorial_manager_transfer_service import consts +from submission.models import Article + +EXPORT_FOLDER = os.path.join(settings.BASE_DIR, "collected-static", consts.SHORT_NAME, "export") + + +def _get_article_export_folders() -> str: + """ + Gets the filepaths for the folders used for exporting articles. + + :return: A list of filepaths for the export folders. + """ + return EXPORT_FOLDER + + +def _get_journal(code: str) -> Journal: + journal: Journal = MagicMock(Journal) + journal.code = code + return journal + + +def _create_txt_file(filename: str) -> File: + 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.close() + except FileExistsError: + pass + + manuscript: File = MagicMock(File) + manuscript.get_file_path.return_value = manuscript_filepath + + return manuscript + + +def _create_article(journal: Journal, article_id: str, manuscript_filename: str, data_figure_filenames: Sequence[str]) -> Article: + manuscript: File = _create_txt_file(manuscript_filename) + + article: Article = MagicMock(Article) + article.article_id = article_id + article.journal = journal + + # 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 + + # 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/views.py b/views.py index 6d6d7a5..2e25840 100644 --- a/views.py +++ b/views.py @@ -1,10 +1,47 @@ +from django.contrib.admin.views.decorators import staff_member_required from django.shortcuts import render from plugins.editorial_manager_transfer_service import forms +from plugins.editorial_manager_transfer_service.logic import get_plugin_settings, save_plugin_settings +from security import decorators +@staff_member_required +@decorators.has_journal def manager(request): - form = forms.DummyManagerForm() + """ + The manager view for the Editorial Manager Service. + :param request: the request object + """ + ( + submission_partner_code, + license_code, + journal_code, + ) = get_plugin_settings(request.journal) + + if request.POST: + form = forms.EditorialManagerTransferServiceForm(request.POST) + + if form.is_valid(): + submission_partner_code = form.cleaned_data["submission_partner_code"] + license_code = form.cleaned_data["license_code"] + journal_code = form.cleaned_data["journal_code"] + + save_plugin_settings( + submission_partner_code, + license_code, + journal_code, + request, + ) + + else: + form = forms.EditorialManagerTransferServiceForm( + initial={ + "submission_partner_code": submission_partner_code, + "license_code": license_code, + "journal_code": journal_code, + } + ) template = 'editorial_manager_transfer_service/manager.html' context = {