From 828e846ead887be9465b7cdc0a13bfcbee2ca215 Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 8 Apr 2024 11:50:34 +0100 Subject: [PATCH 01/80] add RisEntry --- doajtest/unit/test_ris.py | 74 ++++++++++++++ portality/lib/ris.py | 205 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 doajtest/unit/test_ris.py create mode 100644 portality/lib/ris.py diff --git a/doajtest/unit/test_ris.py b/doajtest/unit/test_ris.py new file mode 100644 index 000000000..7e2ef085b --- /dev/null +++ b/doajtest/unit/test_ris.py @@ -0,0 +1,74 @@ +from unittest import TestCase + +from portality.lib.ris import RisEntry + + +class TestRisEntry(TestCase): + + def test_get_set_item__basic(self): + test_value = 'value_a' + entry = RisEntry() + entry['primary_author'] = test_value + assert entry['primary_author'] == test_value + + def test_get_set_item__alias(self): + test_value = 'value_a' + entry = RisEntry() + entry['primary_author'] = test_value + assert entry['A1'] == test_value + + def test_getitem__valid_undefined(self): + entry = RisEntry() + assert entry['primary_author'] is None + + def test_setitem__raise_field_not_found(self): + entry = RisEntry() + with self.assertRaises(ValueError): + entry['qoidjqowijdkncoiqw'] = 'value_a' + + def test_getitem__raise_field_not_found(self): + entry = RisEntry() + with self.assertRaises(ValueError): + print(entry['qoidjqowijdkncoiqw']) + + def test_to_dict(self): + entry = RisEntry() + entry['primary_author'] = 'value_a' + entry['secondary_author'] = 'value_b' + entry['tertiary_author'] = 'value_c' + assert entry.to_dict() == { + 'A1': 'value_a', + 'A2': 'value_b', + 'A3': 'value_c' + } + + def test_to_text(self): + entry = RisEntry() + entry['primary_author'] = 'value_a' + entry['secondary_author'] = 'value_b' + entry['TY'] = 'JOUR' + + expected = """ +TY - JOUR +A1 - value_a +A2 - value_b +ER - + """.strip() + ' \n' + + assert entry.to_text() == expected + + def test_from_text(self): + expected = """ + TY - JOUR + A1 - value_a + A2 - value_b + ER - + """.strip() + ' \n' + + entry = RisEntry.from_text(expected) + assert entry['TY'] == 'JOUR' + assert dict(entry.to_dict()) == { + 'TY': 'JOUR', + 'A1': 'value_a', + 'A2': 'value_b' + } diff --git a/portality/lib/ris.py b/portality/lib/ris.py new file mode 100644 index 000000000..f1ba0083d --- /dev/null +++ b/portality/lib/ris.py @@ -0,0 +1,205 @@ +""" +very simple library for RIS format + +file format references: https://en.wikipedia.org/wiki/RIS_(file_format) +""" +import logging +from collections import OrderedDict +from typing import Dict, Optional + +log = logging.getLogger(__name__) + +RTAG_TYPE = 'TY' +RTAG_END = 'ER' +RIS_ALIAS = [ + ['A1', 'primary_author'], + ['A2', 'secondary_author'], + ['A3', 'tertiary_author'], + ['A4', 'quaternary_author'], + ['A5', 'quinary_author_compiler'], + ['A6', 'website_editor'], + ['AB', 'abstract_synopsis'], + ['AD', 'author_editor_address'], + ['AN', 'accession_number'], + ['AU', 'author_editor_translator'], + ['AV', 'availability_location'], + ['BT', 'primary_secondary_title'], + ['C1', 'custom1'], + ['C2', 'custom2'], + ['C3', 'custom3'], + ['C4', 'custom4'], + ['C5', 'custom5'], + ['C6', 'custom6'], + ['C7', 'custom7'], + ['C8', 'custom8'], + ['CA', 'caption'], + ['CL', 'classification'], + ['CN', 'call_number'], + ['CP', 'city_place_publication'], + ['CR', 'cited_references'], + ['CT', 'caption_primary_title'], + ['CY', 'place_published'], + ['DA', 'date'], + ['DB', 'name_of_database'], + ['DI', 'digital_object_identifier', 'di', ], + ['DO', 'digital_object_identifier2', 'do', ], + ['DOI', 'digital_object_identifier3', 'doi', ], + ['DP', 'database_provider'], + ['DS', 'data_source'], + ['ED', 'secondary_author'], + ['EP', 'end_page'], + ['ET', 'edition'], + ['FD', 'free_form_publication_data'], + ['H1', 'location_library'], + ['H2', 'location_call_number'], + ['ID', 'reference_identifier'], + ['IP', 'identifying_phrase'], + ['IS', 'number_volumes'], + ['J1', 'journal_abbreviation_1'], + ['J2', 'alternate_title'], + ['JA', 'journal_standard_abbreviation'], + ['JF', 'journal_full_name'], + ['JO', 'journal_abbreviation'], + ['K1', 'keyword1'], + ['KW', 'keyword_phrase'], + ['L1', 'file_attachments'], + ['L2', 'url_link'], + ['L3', 'doi_link'], + ['L4', 'figure_image_link'], + ['LA', 'language'], + ['LB', 'label'], + ['LK', 'links'], + ['LL', 'sponsoring_library_location'], + ['M1', 'miscellaneous1'], + ['M2', 'miscellaneous2'], + ['M3', 'type_of_work'], + ['N1', 'notes1'], + ['N2', 'abstract_notes'], + ['NO', 'notes'], + ['NV', 'number_of_volumes'], + ['OL', 'output_language'], + ['OP', 'original_publication'], + ['PA', 'personal_notes'], + ['PB', 'publisher'], + ['PMCID', 'pmcid'], + ['PMID', 'pmid'], + ['PP', 'place_of_publication'], + ['PY', 'publication_year'], + ['RD', 'retrieved_date'], + ['RI', 'reviewed_item'], + ['RN', 'research_notes'], + ['RP', 'reprint_status'], + ['RT', 'reference_type'], + ['SE', 'section'], + ['SF', 'subfile_database'], + ['SL', 'sponsoring_library'], + ['SN', 'issn_isbn'], + ['SP', 'start_pages'], + ['SR', 'source_type'], + ['ST', 'short_title'], + ['SV', 'series_volume'], + ['T1', 'primary_title'], + ['T2', 'secondary_title'], + ['T3', 'tertiary_title'], + ['TA', 'translated_author'], + ['TI', 'title'], + ['TT', 'translated_title'], + [RTAG_TYPE, 'type_of_reference'], + ['U1', 'user_definable1'], + ['U2', 'user_definable2'], + ['U3', 'user_definable3'], + ['U4', 'user_definable4'], + ['U5', 'user_definable5'], + ['U6', 'user_definable6'], + ['U7', 'user_definable7'], + ['U8', 'user_definable8'], + ['U9', 'user_definable9'], + ['U10', 'user_definable10'], + ['U11', 'user_definable11'], + ['U12', 'user_definable12'], + ['U13', 'user_definable13'], + ['U14', 'user_definable14'], + ['U15', 'user_definable15'], + ['UR', 'web_url'], + ['VL', 'volume'], + ['VO', 'volume_published_standard'], + ['WP', 'date_of_electronic_publication'], + ['WT', 'website_title'], + ['WV', 'website_version'], + ['Y1', 'year_date'], + ['Y2', 'access_date_secondary_date'], + ['YR', 'publication_year_ref'] +] + + +def find_tag(field_name) -> Optional[str]: + for alias in RIS_ALIAS: + if field_name in alias: + return alias[0] + raise ValueError(f'Field not found: {field_name}') + + +class RisEntry: + + def __init__(self): + self.data: Dict[str, str] = OrderedDict() + + def __setitem__(self, field_name, value): + tag = find_tag(field_name) + self.data[tag] = value + + def __getitem__(self, field_name) -> str: + tag = find_tag(field_name) + return self.data.get(tag) + + @classmethod + def from_dict(cls, d: dict): + instance = cls() + for k, v in d.items(): + setattr(instance, k, v) + return instance + + def to_dict(self) -> dict: + return self.data.copy() + + @classmethod + def from_text(cls, text: str): + def _to_tag_value(line: str): + tag, value = line.split('-', 1) + tag = tag.strip() + value = value.lstrip() + value = value.replace('\\n', '\n') + return tag, value + + text = text.strip() + lines = text.splitlines() + entry = RisEntry() + for line in lines: + tag, val = _to_tag_value(line) + if tag == RTAG_END: + break + entry[tag] = val + return entry + + def to_text(self) -> str: + tags = list(self.data.keys()) + if RTAG_TYPE in tags: + tags.remove(RTAG_TYPE) + tags.insert(0, RTAG_TYPE) + + if RTAG_END in tags: + tags.remove(RTAG_END) + + def _to_line(tag, value): + if '\n' in value: + value = value.replace('\n', '\\n') + if value is None: + value = '' + return f'{tag} - {value}\n' + + text = '' + for tag in tags: + text += _to_line(tag, self.data[tag]) + + text += _to_line(RTAG_END, '') + return text From 4848dff4d45b5a114fb9964f91a52727c843729f Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 8 Apr 2024 12:50:54 +0100 Subject: [PATCH 02/80] replace RIS_ALIAS --- doajtest/unit/test_ris.py | 18 +-- portality/lib/ris.py | 250 ++++++++++++++++++++------------------ 2 files changed, 138 insertions(+), 130 deletions(-) diff --git a/doajtest/unit/test_ris.py b/doajtest/unit/test_ris.py index 7e2ef085b..e49f5eea0 100644 --- a/doajtest/unit/test_ris.py +++ b/doajtest/unit/test_ris.py @@ -8,18 +8,18 @@ class TestRisEntry(TestCase): def test_get_set_item__basic(self): test_value = 'value_a' entry = RisEntry() - entry['primary_author'] = test_value - assert entry['primary_author'] == test_value + entry['A1'] = test_value + assert entry['A1'] == test_value def test_get_set_item__alias(self): test_value = 'value_a' entry = RisEntry() - entry['primary_author'] = test_value + entry['A1'] = test_value assert entry['A1'] == test_value def test_getitem__valid_undefined(self): entry = RisEntry() - assert entry['primary_author'] is None + assert entry['A1'] is None def test_setitem__raise_field_not_found(self): entry = RisEntry() @@ -33,9 +33,9 @@ def test_getitem__raise_field_not_found(self): def test_to_dict(self): entry = RisEntry() - entry['primary_author'] = 'value_a' - entry['secondary_author'] = 'value_b' - entry['tertiary_author'] = 'value_c' + entry['A1'] = 'value_a' + entry['A2'] = 'value_b' + entry['A3'] = 'value_c' assert entry.to_dict() == { 'A1': 'value_a', 'A2': 'value_b', @@ -44,8 +44,8 @@ def test_to_dict(self): def test_to_text(self): entry = RisEntry() - entry['primary_author'] = 'value_a' - entry['secondary_author'] = 'value_b' + entry['A1'] = 'value_a' + entry['A2'] = 'value_b' entry['TY'] = 'JOUR' expected = """ diff --git a/portality/lib/ris.py b/portality/lib/ris.py index f1ba0083d..3c16c8379 100644 --- a/portality/lib/ris.py +++ b/portality/lib/ris.py @@ -11,131 +11,131 @@ RTAG_TYPE = 'TY' RTAG_END = 'ER' -RIS_ALIAS = [ - ['A1', 'primary_author'], - ['A2', 'secondary_author'], - ['A3', 'tertiary_author'], - ['A4', 'quaternary_author'], - ['A5', 'quinary_author_compiler'], - ['A6', 'website_editor'], - ['AB', 'abstract_synopsis'], - ['AD', 'author_editor_address'], - ['AN', 'accession_number'], - ['AU', 'author_editor_translator'], - ['AV', 'availability_location'], - ['BT', 'primary_secondary_title'], - ['C1', 'custom1'], - ['C2', 'custom2'], - ['C3', 'custom3'], - ['C4', 'custom4'], - ['C5', 'custom5'], - ['C6', 'custom6'], - ['C7', 'custom7'], - ['C8', 'custom8'], - ['CA', 'caption'], - ['CL', 'classification'], - ['CN', 'call_number'], - ['CP', 'city_place_publication'], - ['CR', 'cited_references'], - ['CT', 'caption_primary_title'], - ['CY', 'place_published'], - ['DA', 'date'], - ['DB', 'name_of_database'], - ['DI', 'digital_object_identifier', 'di', ], - ['DO', 'digital_object_identifier2', 'do', ], - ['DOI', 'digital_object_identifier3', 'doi', ], - ['DP', 'database_provider'], - ['DS', 'data_source'], - ['ED', 'secondary_author'], - ['EP', 'end_page'], - ['ET', 'edition'], - ['FD', 'free_form_publication_data'], - ['H1', 'location_library'], - ['H2', 'location_call_number'], - ['ID', 'reference_identifier'], - ['IP', 'identifying_phrase'], - ['IS', 'number_volumes'], - ['J1', 'journal_abbreviation_1'], - ['J2', 'alternate_title'], - ['JA', 'journal_standard_abbreviation'], - ['JF', 'journal_full_name'], - ['JO', 'journal_abbreviation'], - ['K1', 'keyword1'], - ['KW', 'keyword_phrase'], - ['L1', 'file_attachments'], - ['L2', 'url_link'], - ['L3', 'doi_link'], - ['L4', 'figure_image_link'], - ['LA', 'language'], - ['LB', 'label'], - ['LK', 'links'], - ['LL', 'sponsoring_library_location'], - ['M1', 'miscellaneous1'], - ['M2', 'miscellaneous2'], - ['M3', 'type_of_work'], - ['N1', 'notes1'], - ['N2', 'abstract_notes'], - ['NO', 'notes'], - ['NV', 'number_of_volumes'], - ['OL', 'output_language'], - ['OP', 'original_publication'], - ['PA', 'personal_notes'], - ['PB', 'publisher'], - ['PMCID', 'pmcid'], - ['PMID', 'pmid'], - ['PP', 'place_of_publication'], - ['PY', 'publication_year'], - ['RD', 'retrieved_date'], - ['RI', 'reviewed_item'], - ['RN', 'research_notes'], - ['RP', 'reprint_status'], - ['RT', 'reference_type'], - ['SE', 'section'], - ['SF', 'subfile_database'], - ['SL', 'sponsoring_library'], - ['SN', 'issn_isbn'], - ['SP', 'start_pages'], - ['SR', 'source_type'], - ['ST', 'short_title'], - ['SV', 'series_volume'], - ['T1', 'primary_title'], - ['T2', 'secondary_title'], - ['T3', 'tertiary_title'], - ['TA', 'translated_author'], - ['TI', 'title'], - ['TT', 'translated_title'], - [RTAG_TYPE, 'type_of_reference'], - ['U1', 'user_definable1'], - ['U2', 'user_definable2'], - ['U3', 'user_definable3'], - ['U4', 'user_definable4'], - ['U5', 'user_definable5'], - ['U6', 'user_definable6'], - ['U7', 'user_definable7'], - ['U8', 'user_definable8'], - ['U9', 'user_definable9'], - ['U10', 'user_definable10'], - ['U11', 'user_definable11'], - ['U12', 'user_definable12'], - ['U13', 'user_definable13'], - ['U14', 'user_definable14'], - ['U15', 'user_definable15'], - ['UR', 'web_url'], - ['VL', 'volume'], - ['VO', 'volume_published_standard'], - ['WP', 'date_of_electronic_publication'], - ['WT', 'website_title'], - ['WV', 'website_version'], - ['Y1', 'year_date'], - ['Y2', 'access_date_secondary_date'], - ['YR', 'publication_year_ref'] +RIS_TAGS = [ + 'A1', # primary_author + 'A2', # secondary_author + 'A3', # tertiary_author + 'A4', # quaternary_author + 'A5', # quinary_author_compiler + 'A6', # website_editor + 'AB', # abstract_synopsis + 'AD', # author_editor_address + 'AN', # accession_number + 'AU', # author_editor_translator + 'AV', # availability_location + 'BT', # primary_secondary_title + 'C1', # custom1 + 'C2', # custom2 + 'C3', # custom3 + 'C4', # custom4 + 'C5', # custom5 + 'C6', # custom6 + 'C7', # custom7 + 'C8', # custom8 + 'CA', # caption + 'CL', # classification + 'CN', # call_number + 'CP', # city_place_publication + 'CR', # cited_references + 'CT', # caption_primary_title + 'CY', # place_published + 'DA', # date + 'DB', # name_of_database + 'DI', # digital_object_identifier + 'DO', # digital_object_identifier2 + 'DOI', # digital_object_identifier3 + 'DP', # database_provider + 'DS', # data_source + 'ED', # secondary_author + 'EP', # end_page + 'ET', # edition + 'FD', # free_form_publication_data + 'H1', # location_library + 'H2', # location_call_number + 'ID', # reference_identifier + 'IP', # identifying_phrase + 'IS', # number_volumes + 'J1', # journal_abbreviation_1 + 'J2', # alternate_title + 'JA', # journal_standard_abbreviation + 'JF', # journal_full_name + 'JO', # journal_abbreviation + 'K1', # keyword1 + 'KW', # keyword_phrase + 'L1', # file_attachments + 'L2', # url_link + 'L3', # doi_link + 'L4', # figure_image_link + 'LA', # language + 'LB', # label + 'LK', # links + 'LL', # sponsoring_library_location + 'M1', # miscellaneous1 + 'M2', # miscellaneous2 + 'M3', # type_of_work + 'N1', # notes1 + 'N2', # abstract_notes + 'NO', # notes + 'NV', # number_of_volumes + 'OL', # output_language + 'OP', # original_publication + 'PA', # personal_notes + 'PB', # publisher + 'PMCID', # pmcid + 'PMID', # pmid + 'PP', # place_of_publication + 'PY', # publication_year + 'RD', # retrieved_date + 'RI', # reviewed_item + 'RN', # research_notes + 'RP', # reprint_status + 'RT', # reference_type + 'SE', # section + 'SF', # subfile_database + 'SL', # sponsoring_library + 'SN', # issn_isbn + 'SP', # start_pages + 'SR', # source_type + 'ST', # short_title + 'SV', # series_volume + 'T1', # primary_title + 'T2', # secondary_title + 'T3', # tertiary_title + 'TA', # translated_author + 'TI', # title + 'TT', # translated_title + RTAG_TYPE, # 'type_of_reference' + 'U1', # user_definable1 + 'U2', # user_definable2 + 'U3', # user_definable3 + 'U4', # user_definable4 + 'U5', # user_definable5 + 'U6', # user_definable6 + 'U7', # user_definable7 + 'U8', # user_definable8 + 'U9', # user_definable9 + 'U10', # user_definable10 + 'U11', # user_definable11 + 'U12', # user_definable12 + 'U13', # user_definable13 + 'U14', # user_definable14 + 'U15', # user_definable15 + 'UR', # web_url + 'VL', # volume + 'VO', # volume_published_standard + 'WP', # date_of_electronic_publication + 'WT', # website_title + 'WV', # website_version + 'Y1', # year_date + 'Y2', # access_date_secondary_date + 'YR', # publication_year_ref ] def find_tag(field_name) -> Optional[str]: - for alias in RIS_ALIAS: - if field_name in alias: - return alias[0] + field_name = field_name.upper() + if field_name in RIS_TAGS: + return field_name raise ValueError(f'Field not found: {field_name}') @@ -152,6 +152,14 @@ def __getitem__(self, field_name) -> str: tag = find_tag(field_name) return self.data.get(tag) + @property + def type(self): + return self[RTAG_TYPE] + + @type.setter + def type(self, value): + self[RTAG_TYPE] = value + @classmethod def from_dict(cls, d: dict): instance = cls() From cd9b42abfb3d4eeadf1f7e2261b8ab2849b213f6 Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 8 Apr 2024 13:13:42 +0100 Subject: [PATCH 03/80] support values list --- doajtest/unit/test_ris.py | 38 +++++++++++++++----------------------- portality/lib/ris.py | 32 +++++++++++++++++++++----------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/doajtest/unit/test_ris.py b/doajtest/unit/test_ris.py index e49f5eea0..346181c79 100644 --- a/doajtest/unit/test_ris.py +++ b/doajtest/unit/test_ris.py @@ -5,21 +5,24 @@ class TestRisEntry(TestCase): - def test_get_set_item__basic(self): + def test_get_set_item(self): test_value = 'value_a' entry = RisEntry() entry['A1'] = test_value - assert entry['A1'] == test_value + assert entry['A1'] == [test_value] - def test_get_set_item__alias(self): - test_value = 'value_a' + def test_append(self): entry = RisEntry() - entry['A1'] = test_value - assert entry['A1'] == test_value + entry.append('A1', '1') + entry['A1'].append('2') + assert entry['A1'] == ['1', '2'] + + entry['A1'] = '9' + assert entry['A1'] == ['9'] def test_getitem__valid_undefined(self): entry = RisEntry() - assert entry['A1'] is None + assert entry['A1'] == [] def test_setitem__raise_field_not_found(self): entry = RisEntry() @@ -31,17 +34,6 @@ def test_getitem__raise_field_not_found(self): with self.assertRaises(ValueError): print(entry['qoidjqowijdkncoiqw']) - def test_to_dict(self): - entry = RisEntry() - entry['A1'] = 'value_a' - entry['A2'] = 'value_b' - entry['A3'] = 'value_c' - assert entry.to_dict() == { - 'A1': 'value_a', - 'A2': 'value_b', - 'A3': 'value_c' - } - def test_to_text(self): entry = RisEntry() entry['A1'] = 'value_a' @@ -66,9 +58,9 @@ def test_from_text(self): """.strip() + ' \n' entry = RisEntry.from_text(expected) - assert entry['TY'] == 'JOUR' - assert dict(entry.to_dict()) == { - 'TY': 'JOUR', - 'A1': 'value_a', - 'A2': 'value_b' + assert entry.type == 'JOUR' + assert dict(entry.data) == { + 'TY': ['JOUR'], + 'A1': ['value_a'], + 'A2': ['value_b'], } diff --git a/portality/lib/ris.py b/portality/lib/ris.py index 3c16c8379..3e012b51d 100644 --- a/portality/lib/ris.py +++ b/portality/lib/ris.py @@ -3,6 +3,7 @@ file format references: https://en.wikipedia.org/wiki/RIS_(file_format) """ +import collections import logging from collections import OrderedDict from typing import Dict, Optional @@ -142,19 +143,24 @@ def find_tag(field_name) -> Optional[str]: class RisEntry: def __init__(self): - self.data: Dict[str, str] = OrderedDict() + self.data: collections.defaultdict[str, list] = collections.defaultdict(list) def __setitem__(self, field_name, value): tag = find_tag(field_name) - self.data[tag] = value + self.data[tag] = [value] - def __getitem__(self, field_name) -> str: + def append(self, tag, value) -> list: + tag = find_tag(tag) + self[tag].append(value) + return self[tag] + + def __getitem__(self, field_name) -> list: tag = find_tag(field_name) - return self.data.get(tag) + return self.data[tag] @property def type(self): - return self[RTAG_TYPE] + return self[RTAG_TYPE] and self[RTAG_TYPE][0] @type.setter def type(self, value): @@ -164,11 +170,13 @@ def type(self, value): def from_dict(cls, d: dict): instance = cls() for k, v in d.items(): - setattr(instance, k, v) - return instance + if isinstance(v, list): + for vv in v: + instance[k].append(vv) + else: + instance[k].append(v) - def to_dict(self) -> dict: - return self.data.copy() + return instance @classmethod def from_text(cls, text: str): @@ -186,7 +194,7 @@ def _to_tag_value(line: str): tag, val = _to_tag_value(line) if tag == RTAG_END: break - entry[tag] = val + entry[tag].append(val) return entry def to_text(self) -> str: @@ -207,7 +215,9 @@ def _to_line(tag, value): text = '' for tag in tags: - text += _to_line(tag, self.data[tag]) + values = self.data[tag] + for v in values: + text += _to_line(tag, v) text += _to_line(RTAG_END, '') return text From 5d07e34802119278b30000fcf8e6ad62be89300c Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 8 Apr 2024 14:22:15 +0100 Subject: [PATCH 04/80] add type_of_reference --- portality/lib/ris.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/portality/lib/ris.py b/portality/lib/ris.py index 3e012b51d..d2b741595 100644 --- a/portality/lib/ris.py +++ b/portality/lib/ris.py @@ -142,8 +142,9 @@ def find_tag(field_name) -> Optional[str]: class RisEntry: - def __init__(self): + def __init__(self, type_of_reference: str = None): self.data: collections.defaultdict[str, list] = collections.defaultdict(list) + self.type = type_of_reference def __setitem__(self, field_name, value): tag = find_tag(field_name) From 2a8c3ebcf04791badb3ea9503667809420447155 Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 8 Apr 2024 14:22:39 +0100 Subject: [PATCH 05/80] add jsonpath_utils.py --- portality/lib/jsonpath_utils.py | 7 +++++++ setup.py | 3 +++ 2 files changed, 10 insertions(+) create mode 100644 portality/lib/jsonpath_utils.py diff --git a/portality/lib/jsonpath_utils.py b/portality/lib/jsonpath_utils.py new file mode 100644 index 000000000..7201c9bfe --- /dev/null +++ b/portality/lib/jsonpath_utils.py @@ -0,0 +1,7 @@ +from typing import Iterable + +import jsonpath_ng.ext + + +def find_values(query: str, data: dict) -> Iterable: + return (m.value for m in jsonpath_ng.ext.parse(query).find(data)) diff --git a/setup.py b/setup.py index eccafbada..a9a44680b 100644 --- a/setup.py +++ b/setup.py @@ -63,6 +63,9 @@ 'pandas~=2.0.1', # pandas lets us generate URLs for linkcheck 'gspread-dataframe~=3.3.1', 'gspread-formatting~=1.1.2', + + 'jsonpath-ng~=1.6', + ] + (["setproctitle==1.1.10"] if "linux" in sys.platform else []), extras_require={ # prevent backtracking through all versions From aa25ee3319d2a113f79e76f214491ee6e7bdf95c Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 8 Apr 2024 14:22:59 +0100 Subject: [PATCH 06/80] add ArticleRisXWalk --- doajtest/unit/test_crosswalks_article_ris.py | 39 ++++++++++++++++++++ portality/crosswalks/article_ris.py | 35 ++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 doajtest/unit/test_crosswalks_article_ris.py create mode 100644 portality/crosswalks/article_ris.py diff --git a/doajtest/unit/test_crosswalks_article_ris.py b/doajtest/unit/test_crosswalks_article_ris.py new file mode 100644 index 000000000..b4b499f59 --- /dev/null +++ b/doajtest/unit/test_crosswalks_article_ris.py @@ -0,0 +1,39 @@ +import unittest + +from doajtest.fixtures import ArticleFixtureFactory +from portality import models +from portality.crosswalks.article_ris import ArticleRisXWalk + + +class TestArticleRisXWalk(unittest.TestCase): + def test_article2ris(self): + article = ArticleFixtureFactory.make_article_source() + article = models.Article(**article) + article.bibjson().abstract = "abstract" + ris = ArticleRisXWalk.article2ris(article) + assert ris.type == 'JOUR' + assert ris['T1'] == [article.data['bibjson']['title']] + assert ris.to_text().split() == """ +TY - JOUR +T1 - Article Title +AU - The Author +PY - 1991 +JO - The Title +VL - 1 +SP - 3 +EP - 21 +UR - http://www.example.com/article +AB - abstract +KW - word +KW - key +DOI - 10.0000/SOME.IDENTIFIER +ER - + """.split() + + def test_article2ris__only_title(self): + ris = ArticleRisXWalk.article2ris({"bibjson": {"title": "Article Title"}}) + assert ris.to_text().split() == """ +TY - JOUR +T1 - Article Title +ER - + """.split() diff --git a/portality/crosswalks/article_ris.py b/portality/crosswalks/article_ris.py new file mode 100644 index 000000000..11006ab34 --- /dev/null +++ b/portality/crosswalks/article_ris.py @@ -0,0 +1,35 @@ +from typing import Union + +from portality import models +from portality.lib import jsonpath_utils +from portality.lib.ris import RisEntry + +RIS_ARTICLE_MAPPING = { + 'T1': '$.bibjson.title', + 'AU': '$.bibjson.author[*].name', + 'PY': '$.bibjson.year', + 'JO': '$.bibjson.journal.title', + 'VL': '$.bibjson.journal.volume', + 'SP': '$.bibjson.start_page', + 'EP': '$.bibjson.end_page', + 'UR': '$.bibjson.link[*].url', + 'AB': '$.bibjson.abstract', + 'KW': '$.bibjson.keywords[*]', + 'DOI': '$.bibjson.identifier[?(@.type == "doi")].id', + 'SN': '$.bibjson.journal.issns[*]', +} + + +class ArticleRisXWalk: + + @classmethod + def article2ris(cls, article: Union[models.Article, dict]) -> RisEntry: + if isinstance(article, models.Article): + article = article.data + + entry = RisEntry(type_of_reference='JOUR') + for tag, query in RIS_ARTICLE_MAPPING.items(): + for v in jsonpath_utils.find_values(query, article): + entry[tag].append(v) + + return entry From 6dc56c697ecb59599036274775ba2cb47cf0b528 Mon Sep 17 00:00:00 2001 From: philip Date: Tue, 9 Apr 2024 10:32:57 +0100 Subject: [PATCH 07/80] fix type None --- portality/lib/ris.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/portality/lib/ris.py b/portality/lib/ris.py index d2b741595..36bb83ab9 100644 --- a/portality/lib/ris.py +++ b/portality/lib/ris.py @@ -144,7 +144,8 @@ class RisEntry: def __init__(self, type_of_reference: str = None): self.data: collections.defaultdict[str, list] = collections.defaultdict(list) - self.type = type_of_reference + if type_of_reference: + self.type = type_of_reference def __setitem__(self, field_name, value): tag = find_tag(field_name) From 50024ca3200ed6deda01be804db252a7be62fe4b Mon Sep 17 00:00:00 2001 From: philip Date: Tue, 9 Apr 2024 10:36:11 +0100 Subject: [PATCH 08/80] add export ris for each article result --- portality/static/js/doaj.fieldrender.edges.js | 5 +++ portality/view/doajservices.py | 33 +++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/portality/static/js/doaj.fieldrender.edges.js b/portality/static/js/doaj.fieldrender.edges.js index b82822cfb..6b670ca48 100644 --- a/portality/static/js/doaj.fieldrender.edges.js +++ b/portality/static/js/doaj.fieldrender.edges.js @@ -2872,6 +2872,8 @@ $.extend(true, doaj, { published = 'Published ' + name; } + const export_url = this.doaj_url + '/service/export/article/' + resultobj.id; + var frag = '
  • \
    \
    \ @@ -2905,6 +2907,9 @@ $.extend(true, doaj, {
  • \ About the journal\
  • \ +
  • \ + Export RIS\ +
  • \
  • \ ' + published + '\
  • \ diff --git a/portality/view/doajservices.py b/portality/view/doajservices.py index 48c9c6500..a46fd1461 100644 --- a/portality/view/doajservices.py +++ b/portality/view/doajservices.py @@ -1,13 +1,14 @@ -import json, urllib.request, urllib.parse, urllib.error, requests +import json +from io import BytesIO -from flask import Blueprint, make_response, request, abort, render_template +from flask import Blueprint, make_response, abort, render_template, send_file from flask_login import current_user, login_required -from portality.core import app -from portality.decorators import ssl_required, write_required, restrict_to_role -from portality.util import jsonp from portality import lock, models from portality.bll import DOAJ +from portality.crosswalks.article_ris import ArticleRisXWalk +from portality.decorators import ssl_required, write_required +from portality.util import jsonp blueprint = Blueprint('doajservices', __name__) @@ -40,7 +41,7 @@ def unlock(object_type, object_id): abort(400) # otherwise, return success - resp = make_response(json.dumps({"result" : "success"})) + resp = make_response(json.dumps({"result": "success"})) resp.mimetype = "application/json" return resp @@ -107,7 +108,8 @@ def group_status(group_id): :param group_id: :return: """ - if (not (current_user.has_role("editor") and models.EditorGroup.pull(group_id).editor == current_user.id)) and (not current_user.has_role("admin")): + if (not (current_user.has_role("editor") and models.EditorGroup.pull(group_id).editor == current_user.id)) and ( + not current_user.has_role("admin")): abort(404) svc = DOAJ.todoService() stats = svc.group_stats(group_id) @@ -126,6 +128,7 @@ def dismiss_autocheck(autocheck_set_id, autocheck_id): abort(404) return make_response(json.dumps({"status": "success"})) + @blueprint.route("/autocheck/undismiss//", methods=["GET", "POST"]) @jsonp @login_required @@ -138,3 +141,19 @@ def undismiss_autocheck(autocheck_set_id, autocheck_id): abort(404) return make_response(json.dumps({"status": "success"})) + +@blueprint.route('/export/article/') +def export_article_ris(article_id): + article = models.Article.pull(article_id) + if not article: + abort(404) + + byte_stream = BytesIO() + ris = ArticleRisXWalk.article2ris(article) + byte_stream.write(ris.to_text().encode('utf-8', errors='ignore')) + byte_stream.seek(0) + + filename = f'article-{article_id[:10]}.ris' + + resp = make_response(send_file(byte_stream, as_attachment=True, attachment_filename=filename)) + return resp From a839f54bc3373c94983b06b10dc14b6e73abde72 Mon Sep 17 00:00:00 2001 From: philip Date: Tue, 9 Apr 2024 12:49:20 +0100 Subject: [PATCH 09/80] default autocheck disabled for test cases --- doajtest/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/doajtest/helpers.py b/doajtest/helpers.py index a8f33d52d..713d91989 100644 --- a/doajtest/helpers.py +++ b/doajtest/helpers.py @@ -129,6 +129,7 @@ class DoajTestCase(TestCase): @classmethod def create_app_patch(cls): return { + 'AUTOCHECK_INCOMING': False, # old test cases design and depend on work flow of autocheck disabled "STORE_IMPL": "portality.store.StoreLocal", "STORE_LOCAL_DIR": paths.rel2abs(__file__, "..", "tmp", "store", "main", cls.__name__.lower()), "STORE_TMP_DIR": paths.rel2abs(__file__, "..", "tmp", "store", "tmp", cls.__name__.lower()), From 866c52c0ceef1f43a9b53929741371748baecf55 Mon Sep 17 00:00:00 2001 From: philip Date: Tue, 9 Apr 2024 12:52:59 +0100 Subject: [PATCH 10/80] avoid duplicate author names --- portality/crosswalks/article_ris.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/portality/crosswalks/article_ris.py b/portality/crosswalks/article_ris.py index 11006ab34..2b57e2e03 100644 --- a/portality/crosswalks/article_ris.py +++ b/portality/crosswalks/article_ris.py @@ -4,9 +4,16 @@ from portality.lib import jsonpath_utils from portality.lib.ris import RisEntry + +def extra_author_names(article) -> list: + query = '$.bibjson.author[*].name' + values = jsonpath_utils.find_values(query, article) + return sorted(set(values)) + + RIS_ARTICLE_MAPPING = { 'T1': '$.bibjson.title', - 'AU': '$.bibjson.author[*].name', + 'AU': extra_author_names, 'PY': '$.bibjson.year', 'JO': '$.bibjson.journal.title', 'VL': '$.bibjson.journal.volume', @@ -29,7 +36,12 @@ def article2ris(cls, article: Union[models.Article, dict]) -> RisEntry: entry = RisEntry(type_of_reference='JOUR') for tag, query in RIS_ARTICLE_MAPPING.items(): - for v in jsonpath_utils.find_values(query, article): + if callable(query): + values = query(article) + else: + values = jsonpath_utils.find_values(query, article) + + for v in values: entry[tag].append(v) return entry From 8f4fe0737e0034c2b363473e239ca8e52109b5fb Mon Sep 17 00:00:00 2001 From: philip Date: Wed, 10 Apr 2024 10:39:53 +0100 Subject: [PATCH 11/80] add test_view_doajservices.py --- doajtest/unit/test_view_doajservices.py | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 doajtest/unit/test_view_doajservices.py diff --git a/doajtest/unit/test_view_doajservices.py b/doajtest/unit/test_view_doajservices.py new file mode 100644 index 000000000..3b41f39db --- /dev/null +++ b/doajtest/unit/test_view_doajservices.py @@ -0,0 +1,28 @@ +from doajtest.fixtures import ArticleFixtureFactory +from doajtest.helpers import DoajTestCase +from portality.crosswalks.article_ris import ArticleRisXWalk +from portality.models import Article +from portality.util import url_for + + +class TestDoajservices(DoajTestCase): + + def test_export_article_ris(self): + article = Article(**ArticleFixtureFactory.make_article_source()) + article.save(blocking=True) + Article.refresh() + + ris = ArticleRisXWalk.article2ris(article).to_text() + + with self.app_test.test_client() as t_client: + url = url_for('doajservices.export_article_ris', article_id=article.id) + response = t_client.get(url) + assert response.status_code == 200 + assert response.get_data(as_text=True) == ris + + def test_export_article_ris__not_found(self): + with self.app_test.test_client() as t_client: + url = url_for('doajservices.export_article_ris', + article_id='article_id_that_does_not_exist') + response = t_client.get(url) + assert response.status_code == 404 From b761ad92aaddbe6d13bb56d53847a862c013f709 Mon Sep 17 00:00:00 2001 From: philip Date: Wed, 10 Apr 2024 11:19:04 +0100 Subject: [PATCH 12/80] improve RIS mapping --- portality/crosswalks/article_ris.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/portality/crosswalks/article_ris.py b/portality/crosswalks/article_ris.py index 2b57e2e03..214720084 100644 --- a/portality/crosswalks/article_ris.py +++ b/portality/crosswalks/article_ris.py @@ -15,8 +15,10 @@ def extra_author_names(article) -> list: 'T1': '$.bibjson.title', 'AU': extra_author_names, 'PY': '$.bibjson.year', - 'JO': '$.bibjson.journal.title', + 'JF': '$.bibjson.journal.title', + 'PB': '$.bibjson.journal.publisher', 'VL': '$.bibjson.journal.volume', + 'IS': '$.bibjson.journal.number', 'SP': '$.bibjson.start_page', 'EP': '$.bibjson.end_page', 'UR': '$.bibjson.link[*].url', @@ -24,6 +26,7 @@ def extra_author_names(article) -> list: 'KW': '$.bibjson.keywords[*]', 'DOI': '$.bibjson.identifier[?(@.type == "doi")].id', 'SN': '$.bibjson.journal.issns[*]', + 'LA': '$.language.language', } From 9ada58ee895617fe5e6c34a92f634cd22d805c0f Mon Sep 17 00:00:00 2001 From: philip Date: Wed, 10 Apr 2024 11:24:23 +0100 Subject: [PATCH 13/80] add Export article in RIS format --- doajtest/testbook/public_site/public_search.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/doajtest/testbook/public_site/public_search.yml b/doajtest/testbook/public_site/public_search.yml index 6ff91b38d..166b02c25 100644 --- a/doajtest/testbook/public_site/public_search.yml +++ b/doajtest/testbook/public_site/public_search.yml @@ -186,4 +186,13 @@ tests: - step: click spacebar to check the filter results: - filter is applied - +- title: Export article in RIS format + context: + role: anonymous + steps: + - step: Go to the DOAJ search page at /search/articles + results: + - Only articles are shown in the results + - step: Click on 'Export RIS' of any article + results: + - A RIS file is downloaded From 3c138f1738e6fc6c3e67b730221664c131c9f14e Mon Sep 17 00:00:00 2001 From: philip Date: Wed, 10 Apr 2024 18:32:52 +0100 Subject: [PATCH 14/80] fix testcases --- doajtest/unit/test_crosswalks_article_ris.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/doajtest/unit/test_crosswalks_article_ris.py b/doajtest/unit/test_crosswalks_article_ris.py index b4b499f59..5d58655f4 100644 --- a/doajtest/unit/test_crosswalks_article_ris.py +++ b/doajtest/unit/test_crosswalks_article_ris.py @@ -18,8 +18,10 @@ def test_article2ris(self): T1 - Article Title AU - The Author PY - 1991 -JO - The Title +JF - The Title +PB - The Publisher VL - 1 +IS - 99 SP - 3 EP - 21 UR - http://www.example.com/article From b0dfc6bca65c57a3cb8fda7993fa1391fdabe13b Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 15 Apr 2024 11:46:35 +0100 Subject: [PATCH 15/80] draft articleinfo --- portality/forms/application_forms.py | 6 ++++-- portality/static/js/formulaic.js | 19 +++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/portality/forms/application_forms.py b/portality/forms/application_forms.py index b370d2905..490269ad5 100644 --- a/portality/forms/application_forms.py +++ b/portality/forms/application_forms.py @@ -1881,7 +1881,8 @@ class FieldDefinitions: "entry_template": "application_form/_entry_group.html", "widgets": [ {"infinite_repeat" : {"enable_on_repeat" : ["textarea"]}}, - "note_modal" + "note_modal", + "article_info", ], "merge_disabled" : "merge_disabled_notes", } @@ -3042,7 +3043,8 @@ def wtforms(field, settings): "trim_whitespace" : "formulaic.widgets.newTrimWhitespace", # ~~-> TrimWhitespace:FormWidget~~ "note_modal" : "formulaic.widgets.newNoteModal", # ~~-> NoteModal:FormWidget~~ "autocheck": "formulaic.widgets.newAutocheck", # ~~-> Autocheck:FormWidget~~ - "issn_link" : "formulaic.widgets.newIssnLink" # ~~-> IssnLink:FormWidget~~, + "issn_link" : "formulaic.widgets.newIssnLink", # ~~-> IssnLink:FormWidget~~, + "article_info": "formulaic.widgets.newArticleInfo", # ~~-> ArticleInfo:FormWidget~~ } diff --git a/portality/static/js/formulaic.js b/portality/static/js/formulaic.js index 0c5932c3c..7ae33d0a9 100644 --- a/portality/static/js/formulaic.js +++ b/portality/static/js/formulaic.js @@ -1155,14 +1155,14 @@ var formulaic = { this._renderAutocheck = function(autocheck) { let frag = "
  • "; - + if (autocheck.checked_by && doaj.autocheckers && doaj.autocheckers.registry.hasOwnProperty(autocheck.checked_by)) { frag += (new doaj.autocheckers.registry[autocheck.checked_by]()).draw(autocheck) } else { frag += this._defaultRender(autocheck); } - + frag += `
  • `; return frag; } @@ -2252,5 +2252,20 @@ var formulaic = { this.init(); }, + + newArticleInfo : (params) => edges.instantiate(formulaic.widgets.ArticleInfo, params), + ArticleInfo: function ({formulaic, fieldDef, args}) { + const init = () => { + console.log() + console.log(this.fieldDef); + debugger + }; + + init(); + }, + + + + } }; From edcd98adb884e40eab5f4c86eeddea9054e9f402 Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 15 Apr 2024 13:03:14 +0100 Subject: [PATCH 16/80] implement ArticleInfo --- portality/static/js/formulaic.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/portality/static/js/formulaic.js b/portality/static/js/formulaic.js index 7ae33d0a9..ec438f321 100644 --- a/portality/static/js/formulaic.js +++ b/portality/static/js/formulaic.js @@ -2256,9 +2256,15 @@ var formulaic = { newArticleInfo : (params) => edges.instantiate(formulaic.widgets.ArticleInfo, params), ArticleInfo: function ({formulaic, fieldDef, args}) { const init = () => { - console.log() - console.log(this.fieldDef); - debugger + const paths = window.location.pathname.split('/') + const journalId = paths[paths.length - 1] + fetch(`/admin/journal/${journalId}/article-info`) + .then(response => response.json()) + .then(data => { + const $p = $('.doaj_seal__container').prev('p'); + const text = $p.text() + $p.text(text + `This journal has ${data.n_articles} articles in DOAJ.`) + }) }; init(); From c90cda05b647c8d1a042482aeee35b5570c9232c Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 15 Apr 2024 13:06:24 +0100 Subject: [PATCH 17/80] update dictionary --- docs/dictionary.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/dictionary.md b/docs/dictionary.md index 49ba798b8..fcbee273d 100644 --- a/docs/dictionary.md +++ b/docs/dictionary.md @@ -1,10 +1,11 @@ -| Short | Description | -|---------|------------------------------| -| bgjob | background job | -| noti | notification | -| noqa | NO-QA (NO Quality Assurance) | -| inst | instance | -| fmt | format | -| exparam | extra parameter | -| maned | Managing Editor | -| gsheet | Google Sheet | \ No newline at end of file +| Short | Description | +|----------|------------------------------| +| bgjob | background job | +| noti | notification | +| noqa | NO-QA (NO Quality Assurance) | +| inst | instance | +| fmt | format | +| exparam | extra parameter | +| maned | Managing Editor | +| gsheet | Google Sheet | +| svc,serv | service | \ No newline at end of file From dcd962af3b2c966f2a91d69e5495f80d9b03f47b Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 15 Apr 2024 13:06:53 +0100 Subject: [PATCH 18/80] setup article_info widgets --- portality/forms/application_forms.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/portality/forms/application_forms.py b/portality/forms/application_forms.py index 490269ad5..71dde9389 100644 --- a/portality/forms/application_forms.py +++ b/portality/forms/application_forms.py @@ -1603,7 +1603,7 @@ class FieldDefinitions: # ~~->$ DOAJSeal:FormField~~ DOAJ_SEAL = { "name": "doaj_seal", - "label": "The journal has fulfilled all the criteria for the Seal. Award the Seal?", + "label": "Award the Seal?", "input": "checkbox", "validate": [ { @@ -1626,7 +1626,10 @@ class FieldDefinitions: "the journal must use a persistent identifier" } } - ] + ], + "widgets": [ + "article_info", + ], } # FIXME: this probably shouldn't be in the admin form fieldsets, rather its own separate form @@ -1882,7 +1885,6 @@ class FieldDefinitions: "widgets": [ {"infinite_repeat" : {"enable_on_repeat" : ["textarea"]}}, "note_modal", - "article_info", ], "merge_disabled" : "merge_disabled_notes", } From 969c687d7d3eff26fcb6b2174f0358ea11a41c32 Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 15 Apr 2024 13:44:21 +0100 Subject: [PATCH 19/80] implement query of journal_article_info --- portality/view/admin.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/portality/view/admin.py b/portality/view/admin.py index 484441a02..187aee04e 100644 --- a/portality/view/admin.py +++ b/portality/view/admin.py @@ -7,13 +7,14 @@ from flask_login import current_user, login_required from werkzeug.datastructures import MultiDict -from portality import dao import portality.models as models from portality import constants +from portality import dao from portality import lock from portality.background import BackgroundSummary from portality.bll import DOAJ, exceptions from portality.bll.exceptions import ArticleMergeConflict, DuplicateArticleException +from portality.bll.services.query import Query from portality.core import app from portality.crosswalks.application_form import ApplicationFormXWalk from portality.decorators import ssl_required, restrict_to_role, write_required @@ -28,8 +29,6 @@ from portality.ui.messages import Messages from portality.util import flash_with_url, jsonp, make_json_resp, get_web_json_payload, validate_json from portality.view.forms import EditorGroupForm, MakeContinuation - -from portality.bll.services.query import Query from portality.view.view_helper import exparam_editing_user # ~~Admin:Blueprint~~ @@ -322,6 +321,15 @@ def journals_bulk_reinstate(): # ##################################################################### +@blueprint.route("/journal//article-info/", methods=["GET"]) +def journal_article_info(journal_id): + j = models.Journal.pull(journal_id) + if j is None: + abort(404) + + return {'n_articles': models.Article.count_by_issns(j.bibjson().issns())} + + @blueprint.route("/journal//continue", methods=["GET", "POST"]) @login_required @ssl_required @@ -432,7 +440,8 @@ def application(application_id): flash(str(e)) return redirect(url_for("admin.application", application_id=ap.id, _anchor='cannot_edit')) else: - return fc.render_template(obj=ap, lock=lockinfo, form_diff=form_diff, current_journal=current_journal, lcc_tree=lcc_jstree, autochecks=autochecks) + return fc.render_template(obj=ap, lock=lockinfo, form_diff=form_diff, current_journal=current_journal, + lcc_tree=lcc_jstree, autochecks=autochecks) @blueprint.route("/application_quick_reject/", methods=["POST"]) From 3dcc38ddc02e60684958383ed990a5ec4cff0803 Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 15 Apr 2024 13:49:12 +0100 Subject: [PATCH 20/80] add checking before init --- portality/static/js/formulaic.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/portality/static/js/formulaic.js b/portality/static/js/formulaic.js index ec438f321..07d986f98 100644 --- a/portality/static/js/formulaic.js +++ b/portality/static/js/formulaic.js @@ -2255,19 +2255,26 @@ var formulaic = { newArticleInfo : (params) => edges.instantiate(formulaic.widgets.ArticleInfo, params), ArticleInfo: function ({formulaic, fieldDef, args}) { + const sealSelector = '.doaj_seal__container' + const init = () => { const paths = window.location.pathname.split('/') const journalId = paths[paths.length - 1] fetch(`/admin/journal/${journalId}/article-info`) .then(response => response.json()) .then(data => { - const $p = $('.doaj_seal__container').prev('p'); + const $p = $(sealSelector).prev('p'); const text = $p.text() $p.text(text + `This journal has ${data.n_articles} articles in DOAJ.`) }) }; - init(); + + if ($(sealSelector).length) { + init(); + } else { + console.log('skip ArticleInfo, seal section not found') + } }, From 96ac2eb3f8aa8fb26f05f6297b8d7f703590e0a2 Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 15 Apr 2024 16:33:55 +0100 Subject: [PATCH 21/80] add test cases --- doajtest/fixtures/accounts.py | 6 ++- doajtest/helpers.py | 4 +- .../api_tests/test_api_crud_returnvalues.py | 5 +-- doajtest/unit/test_view_admin.py | 37 +++++++++++++++++++ portality/view/admin.py | 1 + 5 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 doajtest/unit/test_view_admin.py diff --git a/doajtest/fixtures/accounts.py b/doajtest/fixtures/accounts.py index a36dd60c6..4b974490f 100644 --- a/doajtest/fixtures/accounts.py +++ b/doajtest/fixtures/accounts.py @@ -83,7 +83,11 @@ def create_publisher_a(): return publisher -def create_maned_a(): +def create_maned_a(is_save=False): from portality import models maned = models.Account(**AccountFixtureFactory.make_managing_editor_source()) + maned.set_password("password") + if is_save: + maned.save(blocking=True) return maned + diff --git a/doajtest/helpers.py b/doajtest/helpers.py index a8f33d52d..0cd4fb76f 100644 --- a/doajtest/helpers.py +++ b/doajtest/helpers.py @@ -410,9 +410,9 @@ def assert_expected_dict(test_case: TestCase, target, expected: dict): test_case.assertDictEqual(actual, expected) -def login(app_client, username, password, follow_redirects=True): +def login(app_client, email, password, follow_redirects=True): return app_client.post(url_for('account.login'), - data=dict(user=username, password=password), + data=dict(user=email, password=password), follow_redirects=follow_redirects) diff --git a/doajtest/unit/api_tests/test_api_crud_returnvalues.py b/doajtest/unit/api_tests/test_api_crud_returnvalues.py index 1d708b422..b398d0d23 100644 --- a/doajtest/unit/api_tests/test_api_crud_returnvalues.py +++ b/doajtest/unit/api_tests/test_api_crud_returnvalues.py @@ -1,3 +1,4 @@ +from doajtest import helpers from doajtest.helpers import DoajTestCase, with_es from portality import models from doajtest.fixtures import ApplicationFixtureFactory, ArticleFixtureFactory, JournalFixtureFactory @@ -205,9 +206,7 @@ def test_04_article_structure_exceptions(self): @staticmethod def login(app, username, password): - return app.post('/account/login', - data=dict(username=username, password=password), - follow_redirects=True) + return helpers.login(app, username, password) @staticmethod def logout(app): diff --git a/doajtest/unit/test_view_admin.py b/doajtest/unit/test_view_admin.py new file mode 100644 index 000000000..2f111bb2d --- /dev/null +++ b/doajtest/unit/test_view_admin.py @@ -0,0 +1,37 @@ +import json + +from doajtest import helpers +from doajtest.fixtures import JournalFixtureFactory +from doajtest.fixtures.accounts import create_maned_a +from doajtest.helpers import DoajTestCase +from portality import models +from portality.util import url_for + + +class TestViewAdmin(DoajTestCase): + + def setUp(self): + super().setUp() + self.acc = create_maned_a(is_save=True) + + def test_journal_article_info(self): + journal = models.Journal( + **JournalFixtureFactory.make_journal_source() + ) + journal.save(blocking=True) + models.Journal.refresh() + + with self.app_test.test_client() as client: + resp = helpers.login(client, self.acc.email, 'password') + assert resp.status_code == 200 + + resp = client.get(url_for("admin.journal_article_info", journal_id=journal.id)) + assert resp.status_code == 200 + assert json.loads(resp.data) == {'n_articles': 0} + + def test_journal_article_info__not_found(self): + with self.app_test.test_client() as client: + helpers.login(client, self.acc.email, 'password') + + resp = client.get(url_for("admin.journal_article_info", journal_id='aksjdlaksjdlkajsdlkajsdlk')) + assert resp.status_code == 404 diff --git a/portality/view/admin.py b/portality/view/admin.py index 187aee04e..ba440cea7 100644 --- a/portality/view/admin.py +++ b/portality/view/admin.py @@ -322,6 +322,7 @@ def journals_bulk_reinstate(): ##################################################################### @blueprint.route("/journal//article-info/", methods=["GET"]) +@login_required def journal_article_info(journal_id): j = models.Journal.pull(journal_id) if j is None: From d2f234d6676e8f1f5a7033900046e565c0bdd756 Mon Sep 17 00:00:00 2001 From: philip Date: Fri, 19 Apr 2024 12:07:45 +0100 Subject: [PATCH 22/80] wording --- portality/templates/application_form/editorial_form_fields.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portality/templates/application_form/editorial_form_fields.html b/portality/templates/application_form/editorial_form_fields.html index 0b91ff624..f7d28bc3a 100644 --- a/portality/templates/application_form/editorial_form_fields.html +++ b/portality/templates/application_form/editorial_form_fields.html @@ -56,7 +56,7 @@

    {{ fs.label }}

    {% set fs = formulaic_context.fieldset("seal") %} {% if fs %}

    {{ fs.label }}

    -

    The journal has fulfilled all the criteria for the Seal.

    +

    The journal may have fulfilled all the criteria for the Seal.

    {% for f in fs.fields() %} {% set field_template = f.template %} {% include field_template %} From 2ba0038dbea8822f4aca02cdb3b8631ff1b3986b Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 29 Apr 2024 14:02:19 +0100 Subject: [PATCH 23/80] add fmt --- doajtest/unit/test_view_doajservices.py | 4 ++-- portality/static/js/doaj.fieldrender.edges.js | 2 +- portality/static/vendor/edges | 2 +- portality/view/doajservices.py | 8 ++++++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/doajtest/unit/test_view_doajservices.py b/doajtest/unit/test_view_doajservices.py index 3b41f39db..30e41ba17 100644 --- a/doajtest/unit/test_view_doajservices.py +++ b/doajtest/unit/test_view_doajservices.py @@ -15,7 +15,7 @@ def test_export_article_ris(self): ris = ArticleRisXWalk.article2ris(article).to_text() with self.app_test.test_client() as t_client: - url = url_for('doajservices.export_article_ris', article_id=article.id) + url = url_for('doajservices.export_article_ris', article_id=article.id, fmt='ris') response = t_client.get(url) assert response.status_code == 200 assert response.get_data(as_text=True) == ris @@ -23,6 +23,6 @@ def test_export_article_ris(self): def test_export_article_ris__not_found(self): with self.app_test.test_client() as t_client: url = url_for('doajservices.export_article_ris', - article_id='article_id_that_does_not_exist') + article_id='article_id_that_does_not_exist', fmt='ris') response = t_client.get(url) assert response.status_code == 404 diff --git a/portality/static/js/doaj.fieldrender.edges.js b/portality/static/js/doaj.fieldrender.edges.js index 6b670ca48..3cd2d3559 100644 --- a/portality/static/js/doaj.fieldrender.edges.js +++ b/portality/static/js/doaj.fieldrender.edges.js @@ -2872,7 +2872,7 @@ $.extend(true, doaj, { published = 'Published ' + name; } - const export_url = this.doaj_url + '/service/export/article/' + resultobj.id; + const export_url = this.doaj_url + '/service/export/article/' + resultobj.id + '/ris'; var frag = '
  • \
    \ diff --git a/portality/static/vendor/edges b/portality/static/vendor/edges index 990f42201..9639b871a 160000 --- a/portality/static/vendor/edges +++ b/portality/static/vendor/edges @@ -1 +1 @@ -Subproject commit 990f4220163a3e18880f0bdc3ad5c80d234d22dd +Subproject commit 9639b871acb1f6590ee78e236f2c9333479c9fe8 diff --git a/portality/view/doajservices.py b/portality/view/doajservices.py index a46fd1461..251730d1a 100644 --- a/portality/view/doajservices.py +++ b/portality/view/doajservices.py @@ -142,12 +142,16 @@ def undismiss_autocheck(autocheck_set_id, autocheck_id): return make_response(json.dumps({"status": "success"})) -@blueprint.route('/export/article/') -def export_article_ris(article_id): +@blueprint.route('/export/article//') +def export_article_ris(article_id, fmt): article = models.Article.pull(article_id) if not article: abort(404) + if fmt != 'ris': + # only support ris for now + abort(404) + byte_stream = BytesIO() ris = ArticleRisXWalk.article2ris(article) byte_stream.write(ris.to_text().encode('utf-8', errors='ignore')) From 38fe7104cb99b8658414724389dc99aebc22de71 Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 29 Apr 2024 14:09:23 +0100 Subject: [PATCH 24/80] add download icon --- .../static/doaj/images/feather-icons/download.svg | 1 + portality/static/js/doaj.fieldrender.edges.js | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 portality/static/doaj/images/feather-icons/download.svg diff --git a/portality/static/doaj/images/feather-icons/download.svg b/portality/static/doaj/images/feather-icons/download.svg new file mode 100644 index 000000000..76767a924 --- /dev/null +++ b/portality/static/doaj/images/feather-icons/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/portality/static/js/doaj.fieldrender.edges.js b/portality/static/js/doaj.fieldrender.edges.js index 3cd2d3559..276bb08c7 100644 --- a/portality/static/js/doaj.fieldrender.edges.js +++ b/portality/static/js/doaj.fieldrender.edges.js @@ -2899,8 +2899,7 @@ $.extend(true, doaj, { Read online ' if (this.widget){ frag += 'external-link icon' - } - else { + } else { frag += '' } frag += '
  • \ @@ -2908,7 +2907,14 @@ $.extend(true, doaj, { About the journal\
  • \
  • \ - Export RIS\ + \ + Export RIS ' + if (this.widget){ + frag += 'external-link icon' + } else { + frag += '' + } + frag += '\
  • \
  • \ ' + published + '\ From ee0a321e8a0b100b5467ec7787696f2730030f6c Mon Sep 17 00:00:00 2001 From: philip Date: Wed, 1 May 2024 12:42:41 +0100 Subject: [PATCH 25/80] link order --- portality/static/js/doaj.fieldrender.edges.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/portality/static/js/doaj.fieldrender.edges.js b/portality/static/js/doaj.fieldrender.edges.js index 276bb08c7..fe135729c 100644 --- a/portality/static/js/doaj.fieldrender.edges.js +++ b/portality/static/js/doaj.fieldrender.edges.js @@ -2903,10 +2903,7 @@ $.extend(true, doaj, { frag += '' } frag += '
  • \ -
  • \ - About the journal\ -
  • \ -
  • \ +
  • \ \ Export RIS ' if (this.widget){ @@ -2916,6 +2913,9 @@ $.extend(true, doaj, { } frag += '\
  • \ +
  • \ + About the journal\ +
  • \
  • \ ' + published + '\
  • \ From 9532667c6405867abb01477f4119a6edcbc9c8d4 Mon Sep 17 00:00:00 2001 From: philip Date: Wed, 1 May 2024 13:15:01 +0100 Subject: [PATCH 26/80] naming --- doajtest/fixtures/accounts.py | 4 ++-- doajtest/unit/test_view_admin.py | 2 +- portality/static/vendor/edges | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doajtest/fixtures/accounts.py b/doajtest/fixtures/accounts.py index 4b974490f..231c02ce3 100644 --- a/doajtest/fixtures/accounts.py +++ b/doajtest/fixtures/accounts.py @@ -83,11 +83,11 @@ def create_publisher_a(): return publisher -def create_maned_a(is_save=False): +def create_maned_a(save=False): from portality import models maned = models.Account(**AccountFixtureFactory.make_managing_editor_source()) maned.set_password("password") - if is_save: + if save: maned.save(blocking=True) return maned diff --git a/doajtest/unit/test_view_admin.py b/doajtest/unit/test_view_admin.py index 2f111bb2d..84eead555 100644 --- a/doajtest/unit/test_view_admin.py +++ b/doajtest/unit/test_view_admin.py @@ -12,7 +12,7 @@ class TestViewAdmin(DoajTestCase): def setUp(self): super().setUp() - self.acc = create_maned_a(is_save=True) + self.acc = create_maned_a(save=True) def test_journal_article_info(self): journal = models.Journal( diff --git a/portality/static/vendor/edges b/portality/static/vendor/edges index 990f42201..9639b871a 160000 --- a/portality/static/vendor/edges +++ b/portality/static/vendor/edges @@ -1 +1 @@ -Subproject commit 990f4220163a3e18880f0bdc3ad5c80d234d22dd +Subproject commit 9639b871acb1f6590ee78e236f2c9333479c9fe8 From e52ca45d8e4937d78e7ba1fc1c14a06fafe509c6 Mon Sep 17 00:00:00 2001 From: philip Date: Wed, 1 May 2024 13:59:18 +0100 Subject: [PATCH 27/80] use multiple checkbox for styles only --- portality/crosswalks/journal_form.py | 4 ++-- portality/forms/application_forms.py | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/portality/crosswalks/journal_form.py b/portality/crosswalks/journal_form.py index ecdc20c61..91ad74a13 100644 --- a/portality/crosswalks/journal_form.py +++ b/portality/crosswalks/journal_form.py @@ -289,7 +289,7 @@ def form2admin(cls, form, obj): obj.set_editor(editor) if getattr(form, "doaj_seal", None): - obj.set_seal(form.doaj_seal.data) + obj.set_seal('y' in form.doaj_seal.data) @classmethod def bibjson2form(cls, bibjson, forminfo): @@ -457,7 +457,7 @@ def admin2form(cls, obj, forminfo): if obj.editor is not None: forminfo['editor'] = obj.editor - forminfo['doaj_seal'] = obj.has_seal() + forminfo['doaj_seal'] = ['y'] if obj.has_seal() else [] class JournalFormXWalk(JournalGenericXWalk): diff --git a/portality/forms/application_forms.py b/portality/forms/application_forms.py index d083a459f..93ee44e21 100644 --- a/portality/forms/application_forms.py +++ b/portality/forms/application_forms.py @@ -1603,8 +1603,13 @@ class FieldDefinitions: # ~~->$ DOAJSeal:FormField~~ DOAJ_SEAL = { "name": "doaj_seal", - "label": "Award the Seal?", + "label": "The journal may have fulfilled all the criteria for the Seal.", + "multiple": True, "input": "checkbox", + "options": [ + {"display": "Award the Seal?", "value": 'y'}, + ], + "validate": [ { "only_if" : { From f415303a7e0b57a012a653c3f45fc788a186815b Mon Sep 17 00:00:00 2001 From: philip Date: Wed, 1 May 2024 14:10:46 +0100 Subject: [PATCH 28/80] update checkbox title --- portality/static/js/formulaic.js | 7 +++---- .../templates/application_form/editorial_form_fields.html | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/portality/static/js/formulaic.js b/portality/static/js/formulaic.js index 07d986f98..04a6b85b8 100644 --- a/portality/static/js/formulaic.js +++ b/portality/static/js/formulaic.js @@ -2255,7 +2255,7 @@ var formulaic = { newArticleInfo : (params) => edges.instantiate(formulaic.widgets.ArticleInfo, params), ArticleInfo: function ({formulaic, fieldDef, args}) { - const sealSelector = '.doaj_seal__container' + const sealSelector = 'label[for=doaj_seal]'; const init = () => { const paths = window.location.pathname.split('/') @@ -2263,9 +2263,8 @@ var formulaic = { fetch(`/admin/journal/${journalId}/article-info`) .then(response => response.json()) .then(data => { - const $p = $(sealSelector).prev('p'); - const text = $p.text() - $p.text(text + `This journal has ${data.n_articles} articles in DOAJ.`) + const $ele = $(sealSelector); + $ele.text($ele.text() + `This journal has ${data.n_articles} articles in DOAJ.`) }) }; diff --git a/portality/templates/application_form/editorial_form_fields.html b/portality/templates/application_form/editorial_form_fields.html index ba8f74cab..e56769093 100644 --- a/portality/templates/application_form/editorial_form_fields.html +++ b/portality/templates/application_form/editorial_form_fields.html @@ -56,7 +56,6 @@

    {{ fs.label }}

    {% set fs = formulaic_context.fieldset("seal") %} {% if fs %}

    {{ fs.label }}

    -

    The journal may have fulfilled all the criteria for the Seal.

    {% for f in fs.fields() %} {% set field_template = f.template %} {% include field_template %} From ee1b64226e2a5e8b32031150c30e7905cfef3e05 Mon Sep 17 00:00:00 2001 From: philip Date: Wed, 19 Jun 2024 11:09:35 +0100 Subject: [PATCH 29/80] fix for seal form field --- doajtest/fixtures/v2/common.py | 2 +- .../unit/application_processors/test_maned_journal_review.py | 4 ++-- portality/tasks/journal_bulk_edit.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/doajtest/fixtures/v2/common.py b/doajtest/fixtures/v2/common.py index efa5f7367..c550437c9 100644 --- a/doajtest/fixtures/v2/common.py +++ b/doajtest/fixtures/v2/common.py @@ -25,7 +25,7 @@ } SEAL_FORM_EXPANDED = { - "doaj_seal": False, + "doaj_seal": [], } JOURNAL_LIKE_BIBJSON = { diff --git a/doajtest/unit/application_processors/test_maned_journal_review.py b/doajtest/unit/application_processors/test_maned_journal_review.py index 07badcbab..b62fbb146 100644 --- a/doajtest/unit/application_processors/test_maned_journal_review.py +++ b/doajtest/unit/application_processors/test_maned_journal_review.py @@ -148,7 +148,7 @@ def test_04_maned_review_doaj_seal(self): ) # set the seal to False using the form - fc.form.doaj_seal.data = False + fc.form.doaj_seal.data = [] # run the crosswalk, don't test it at all in this test fc.form2target() @@ -162,7 +162,7 @@ def test_04_maned_review_doaj_seal(self): fc.source.set_seal(True) fc.source2form() - assert fc.form.doaj_seal.data is True + assert 'y' in fc.form.doaj_seal.data def test_05_maned_review_continuations(self): # construct it from form data (with a known source) diff --git a/portality/tasks/journal_bulk_edit.py b/portality/tasks/journal_bulk_edit.py index 3e39d4388..0b4242adb 100644 --- a/portality/tasks/journal_bulk_edit.py +++ b/portality/tasks/journal_bulk_edit.py @@ -123,8 +123,8 @@ def run(self): job.add_audit_message("Setting {f} to {x} for journal {y}".format(f=k, x=v, y=journal_id)) fc.form[k].data = v else: - if v: - fc.form.doaj_seal.data = v + if v or (isinstance(v, str) and v.lower() == 'y'): + fc.form.doaj_seal.data = ['y'] updated = True if note: From 56a56ea473374c0242cf48f2cd5bac7ed38b9bbe Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 8 Jul 2024 12:54:36 +0100 Subject: [PATCH 30/80] avoid send request by invalid id --- portality/static/js/formulaic.js | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/portality/static/js/formulaic.js b/portality/static/js/formulaic.js index 04a6b85b8..5da5dca98 100644 --- a/portality/static/js/formulaic.js +++ b/portality/static/js/formulaic.js @@ -2255,25 +2255,24 @@ var formulaic = { newArticleInfo : (params) => edges.instantiate(formulaic.widgets.ArticleInfo, params), ArticleInfo: function ({formulaic, fieldDef, args}) { - const sealSelector = 'label[for=doaj_seal]'; - - const init = () => { - const paths = window.location.pathname.split('/') - const journalId = paths[paths.length - 1] - fetch(`/admin/journal/${journalId}/article-info`) - .then(response => response.json()) - .then(data => { - const $ele = $(sealSelector); - $ele.text($ele.text() + `This journal has ${data.n_articles} articles in DOAJ.`) - }) - }; + const $sealEle = $('label[for=doaj_seal]'); - - if ($(sealSelector).length) { - init(); - } else { + if (!$sealEle.length) { console.log('skip ArticleInfo, seal section not found') + return; + } + + const idResult = window.location.pathname.match('/journal/([a-f0-9]+)') + if (!idResult) { + console.log('skip ArticleInfo, journal id not found') + return } + const journalId = idResult[1] + fetch(`/admin/journal/${journalId}/article-info`) + .then(response => response.json()) + .then(data => { + $sealEle.text($sealEle.text() + `This journal has ${data.n_articles} articles in DOAJ.`) + }) }, From f768d1b069653ecf6ccfd8ad8d9abac742e3a843 Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 8 Jul 2024 13:04:30 +0100 Subject: [PATCH 31/80] support in_doaj for count_by_issns query --- portality/models/article.py | 10 +++++++--- portality/view/admin.py | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/portality/models/article.py b/portality/models/article.py index d431bb5ae..392a16851 100644 --- a/portality/models/article.py +++ b/portality/models/article.py @@ -85,8 +85,8 @@ def find_by_issns(cls, issns): return articles @classmethod - def count_by_issns(cls, issns): - q = ArticleQuery(issns=issns) + def count_by_issns(cls, issns, in_doaj=None): + q = ArticleQuery(issns=issns, in_doaj=in_doaj) return cls.hit_count(q.query()) @classmethod @@ -866,9 +866,10 @@ class ArticleQuery(object): _issn_terms = { "terms" : {"index.issn.exact" : [""]} } _volume_term = { "term" : {"bibjson.journal.volume.exact" : ""} } - def __init__(self, issns=None, volume=None): + def __init__(self, issns=None, volume=None, in_doaj=None): self.issns = issns self.volume = volume + self.in_doaj = in_doaj def query(self): q = deepcopy(self.base_query) @@ -883,6 +884,9 @@ def query(self): vq["term"]["bibjson.journal.volume.exact"] = self.volume q["query"]["bool"]["must"].append(vq) + if self.in_doaj is not None: + q["query"]["bool"]["must"].append({"term": {"admin.in_doaj": self.in_doaj}}) + return q class ArticleVolumesQuery(object): diff --git a/portality/view/admin.py b/portality/view/admin.py index ba440cea7..72c4fb6a6 100644 --- a/portality/view/admin.py +++ b/portality/view/admin.py @@ -328,7 +328,7 @@ def journal_article_info(journal_id): if j is None: abort(404) - return {'n_articles': models.Article.count_by_issns(j.bibjson().issns())} + return {'n_articles': models.Article.count_by_issns(j.bibjson().issns(), in_doaj=True)} @blueprint.route("/journal//continue", methods=["GET", "POST"]) From 5e9f946e232d33ef89b245874985893c5ca79bf3 Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 8 Jul 2024 13:08:23 +0100 Subject: [PATCH 32/80] change ArticleInfo result layout --- portality/static/js/formulaic.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/portality/static/js/formulaic.js b/portality/static/js/formulaic.js index 5da5dca98..2068abe12 100644 --- a/portality/static/js/formulaic.js +++ b/portality/static/js/formulaic.js @@ -2255,7 +2255,7 @@ var formulaic = { newArticleInfo : (params) => edges.instantiate(formulaic.widgets.ArticleInfo, params), ArticleInfo: function ({formulaic, fieldDef, args}) { - const $sealEle = $('label[for=doaj_seal]'); + const $sealEle = $('label[for=doaj_seal-0]'); if (!$sealEle.length) { console.log('skip ArticleInfo, seal section not found') @@ -2271,7 +2271,7 @@ var formulaic = { fetch(`/admin/journal/${journalId}/article-info`) .then(response => response.json()) .then(data => { - $sealEle.text($sealEle.text() + `This journal has ${data.n_articles} articles in DOAJ.`) + $sealEle.text($sealEle.text() + ` (This journal has ${data.n_articles} articles in DOAJ)`) }) }, From c8f3e734e6d27721184514241b253ef83d932401 Mon Sep 17 00:00:00 2001 From: philip Date: Mon, 8 Jul 2024 13:53:09 +0100 Subject: [PATCH 33/80] support admin_site_search redirection --- portality/static/js/formulaic.js | 7 ++++++- portality/view/admin.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/portality/static/js/formulaic.js b/portality/static/js/formulaic.js index 2068abe12..66d918887 100644 --- a/portality/static/js/formulaic.js +++ b/portality/static/js/formulaic.js @@ -2271,7 +2271,12 @@ var formulaic = { fetch(`/admin/journal/${journalId}/article-info`) .then(response => response.json()) .then(data => { - $sealEle.text($sealEle.text() + ` (This journal has ${data.n_articles} articles in DOAJ)`) + let articleText = `(This journal has ${data.n_articles} articles in DOAJ)` + if (data.n_articles > 0) { + const articlesUrl = `/admin/journal/${journalId}/article-info/admin-site-search` + articleText = `${articleText}` + } + $sealEle.html($sealEle.text() + ` ${articleText}`) }) }, diff --git a/portality/view/admin.py b/portality/view/admin.py index 72c4fb6a6..00d1edaec 100644 --- a/portality/view/admin.py +++ b/portality/view/admin.py @@ -331,6 +331,21 @@ def journal_article_info(journal_id): return {'n_articles': models.Article.count_by_issns(j.bibjson().issns(), in_doaj=True)} +@blueprint.route("/journal//article-info/admin-site-search", methods=["GET"]) +@login_required +def journal_article_info_admin_site_search(journal_id): + j = models.Journal.pull(journal_id) + if j is None: + abort(404) + + issns = j.bibjson().issns() + if not issns: + abort(404) + + target_url = '/admin/admin_site_search?source={"query":{"bool":{"must":[{"term":{"admin.in_doaj":true}},{"term":{"es_type.exact":"article"}},{"query_string":{"query":"%s","default_operator":"AND","default_field":"index.issn.exact"}}]}},"track_total_hits":true}' + return redirect(target_url % issns[0].replace('-', r'\\-')) + + @blueprint.route("/journal//continue", methods=["GET", "POST"]) @login_required @ssl_required From fd3f5eb972a52e5ffde9a036f145e7179c7b45cb Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Mon, 8 Jul 2024 15:59:34 +0100 Subject: [PATCH 34/80] update export text --- portality/static/js/doaj.fieldrender.edges.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portality/static/js/doaj.fieldrender.edges.js b/portality/static/js/doaj.fieldrender.edges.js index fe135729c..b667decf5 100644 --- a/portality/static/js/doaj.fieldrender.edges.js +++ b/portality/static/js/doaj.fieldrender.edges.js @@ -2905,7 +2905,7 @@ $.extend(true, doaj, { frag += '\
  • \ \ - Export RIS ' + Export Citation (RIS) ' if (this.widget){ frag += 'external-link icon' } else { From a644d0c861ed6241e11bba8fe74c0e7e00894fd8 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Mon, 8 Jul 2024 16:36:42 +0100 Subject: [PATCH 35/80] fix language crosswalk mapping for ris --- portality/crosswalks/article_ris.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portality/crosswalks/article_ris.py b/portality/crosswalks/article_ris.py index 214720084..b9ee310ea 100644 --- a/portality/crosswalks/article_ris.py +++ b/portality/crosswalks/article_ris.py @@ -26,7 +26,7 @@ def extra_author_names(article) -> list: 'KW': '$.bibjson.keywords[*]', 'DOI': '$.bibjson.identifier[?(@.type == "doi")].id', 'SN': '$.bibjson.journal.issns[*]', - 'LA': '$.language.language', + 'LA': '$.bibjson.journal.language[*]', } From 3613ab278ec7b265a71a4faaa84bddeea325e047 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Thu, 18 Jul 2024 22:41:05 +0100 Subject: [PATCH 36/80] initial implementation (pending unit tests) --- .../testdrive/todo_maned_editor_associate.py | 13 +++++- portality/bll/services/todo.py | 44 ++++++++++++++++--- portality/constants.py | 1 + portality/templates/dashboard/_todo.html | 5 +++ portality/templates/dashboard/index.html | 6 +++ portality/view/dashboard.py | 10 ++++- 6 files changed, 69 insertions(+), 10 deletions(-) diff --git a/doajtest/testdrive/todo_maned_editor_associate.py b/doajtest/testdrive/todo_maned_editor_associate.py index 1fa8ff936..0ca722c55 100644 --- a/doajtest/testdrive/todo_maned_editor_associate.py +++ b/doajtest/testdrive/todo_maned_editor_associate.py @@ -142,6 +142,14 @@ def build_maned_applications(un, eg, owner, eponymous_group): "title": un + " Maned Pending Application" }] + app = build_application(un + " Maned On Hold Application", 2 * w, 2 * w, constants.APPLICATION_STATUS_ON_HOLD, + editor_group=eg.name, owner=owner) + app.save() + apps["on_hold"] = [{ + "id": app.id, + "title": un + " Maned On Hold Application" + }] + app = build_application(un + " Maned Low Priority Pending Application", 1 * w, 1 * w, constants.APPLICATION_STATUS_PENDING, editor_group=eponymous_group.name, owner=owner) @@ -154,11 +162,11 @@ def build_maned_applications(un, eg, owner, eponymous_group): lmur = build_application(un + " Last Month Maned Update Request", 5 * w, 5 * w, constants.APPLICATION_STATUS_UPDATE_REQUEST, editor_group=eponymous_group.name, owner=owner, update_request=True) - lmur.save() + # lmur.save() tmur = build_application(un + " This Month Maned Update Request", 0, 0, constants.APPLICATION_STATUS_UPDATE_REQUEST, editor_group=eponymous_group.name, owner=owner, update_request=True) - tmur.save() + # tmur.save() apps["update_request"] = [ { @@ -183,6 +191,7 @@ def build_application(title, lmu_diff, cd_diff, status, editor=None, editor_grou if update_request: ap.application_type = constants.APPLICATION_TYPE_UPDATE_REQUEST + ap.set_current_journal(ap.makeid()) else: ap.remove_current_journal() ap.remove_related_journal() diff --git a/portality/bll/services/todo.py b/portality/bll/services/todo.py index fc57f66da..258094b0c 100644 --- a/portality/bll/services/todo.py +++ b/portality/bll/services/todo.py @@ -5,6 +5,7 @@ from portality.lib import dates from datetime import datetime + class TodoService(object): """ ~~Todo:Service->DOAJ:Service~~ @@ -63,8 +64,7 @@ def group_stats(self, group_id): return stats - - def top_todo(self, account, size=25, new_applications=True, update_requests=True): + def top_todo(self, account, size=25, new_applications=True, update_requests=True, on_hold=True): """ Returns the top number of todo items for a given user @@ -89,6 +89,8 @@ def top_todo(self, account, size=25, new_applications=True, update_requests=True if update_requests: queries.append(TodoRules.maned_last_month_update_requests(size, maned_of)) queries.append(TodoRules.maned_new_update_requests(size, maned_of)) + if on_hold: + queries.append(TodoRules.maned_on_hold(size, maned_of)) if new_applications: # editor and associate editor roles only deal with new applications if account.has_role("editor"): @@ -174,7 +176,11 @@ def maned_stalled(cls, size, maned_of): TodoQuery.is_new_application() ], must_nots=[ - TodoQuery.status([constants.APPLICATION_STATUS_ACCEPTED, constants.APPLICATION_STATUS_REJECTED]) + TodoQuery.status([ + constants.APPLICATION_STATUS_ACCEPTED, + constants.APPLICATION_STATUS_REJECTED, + constants.APPLICATION_STATUS_ON_HOLD + ]) ], sort=sort_date, size=size @@ -191,7 +197,11 @@ def maned_follow_up_old(cls, size, maned_of): TodoQuery.is_new_application() ], must_nots=[ - TodoQuery.status([constants.APPLICATION_STATUS_ACCEPTED, constants.APPLICATION_STATUS_REJECTED]) + TodoQuery.status([ + constants.APPLICATION_STATUS_ACCEPTED, + constants.APPLICATION_STATUS_REJECTED, + constants.APPLICATION_STATUS_ON_HOLD + ]) ], sort=sort_date, size=size @@ -262,7 +272,11 @@ def maned_last_month_update_requests(cls, size, maned_of): TodoQuery.is_update_request() ], must_nots=[ - TodoQuery.status([constants.APPLICATION_STATUS_ACCEPTED, constants.APPLICATION_STATUS_REJECTED]) + TodoQuery.status([ + constants.APPLICATION_STATUS_ACCEPTED, + constants.APPLICATION_STATUS_REJECTED, + constants.APPLICATION_STATUS_ON_HOLD + ]) # TodoQuery.exists("admin.editor") ], sort=sort_date, @@ -282,7 +296,11 @@ def maned_new_update_requests(cls, size, maned_of): TodoQuery.is_update_request() ], must_nots=[ - TodoQuery.status([constants.APPLICATION_STATUS_ACCEPTED, constants.APPLICATION_STATUS_REJECTED]) + TodoQuery.status([ + constants.APPLICATION_STATUS_ACCEPTED, + constants.APPLICATION_STATUS_REJECTED, + constants.APPLICATION_STATUS_ON_HOLD + ]) # TodoQuery.exists("admin.editor") ], sort=sort_date, @@ -290,6 +308,20 @@ def maned_new_update_requests(cls, size, maned_of): ) return constants.TODO_MANED_NEW_UPDATE_REQUEST, assign_pending, sort_date, -2 + @classmethod + def maned_on_hold(cls, size, maned_of): + sort_date = "created_date" + on_holds = TodoQuery( + musts=[ + TodoQuery.editor_group(maned_of), + TodoQuery.is_new_application(), + TodoQuery.status([constants.APPLICATION_STATUS_ON_HOLD]) + ], + sort=sort_date, + size=size + ) + return constants.TODO_MANED_ON_HOLD, on_holds, sort_date, 0 + @classmethod def editor_stalled(cls, groups, size): sort_date = "created_date" diff --git a/portality/constants.py b/portality/constants.py index 2fec30877..991b1ec12 100644 --- a/portality/constants.py +++ b/portality/constants.py @@ -50,6 +50,7 @@ TODO_MANED_ASSIGN_PENDING = "todo_maned_assign_pending" TODO_MANED_LAST_MONTH_UPDATE_REQUEST = "todo_maned_last_month_update_request" TODO_MANED_NEW_UPDATE_REQUEST = "todo_maned_new_update_request" +TODO_MANED_ON_HOLD = "todo_maned_on_hold" TODO_EDITOR_STALLED = "todo_editor_stalled" TODO_EDITOR_FOLLOW_UP_OLD = "todo_editor_follow_up_old" TODO_EDITOR_COMPLETED = "todo_editor_completed" diff --git a/portality/templates/dashboard/_todo.html b/portality/templates/dashboard/_todo.html index 069ba8963..7303003ea 100644 --- a/portality/templates/dashboard/_todo.html +++ b/portality/templates/dashboard/_todo.html @@ -41,6 +41,11 @@ "feather": "edit", "show_status": true }, + constants.TODO_MANED_ON_HOLD: { + "text" : "On Hold Application Review status", + "colour" : "var(--sanguine)", + "feather": "x-circle" + }, constants.TODO_EDITOR_STALLED: { "text" : "Stalled Chase Associate Editor", "show_status": true, diff --git a/portality/templates/dashboard/index.html b/portality/templates/dashboard/index.html index 99459988b..c2ba80a21 100644 --- a/portality/templates/dashboard/index.html +++ b/portality/templates/dashboard/index.html @@ -21,6 +21,12 @@ {% else %} Update Requests {% endif %} + + {% if request.values.get("filter") == "oh" %} + On Hold + {% else %} + On Hold + {% endif %} {% include "dashboard/_todo.html" %}
    diff --git a/portality/view/dashboard.py b/portality/view/dashboard.py index 2f63949a1..5637cc726 100644 --- a/portality/view/dashboard.py +++ b/portality/view/dashboard.py @@ -19,10 +19,15 @@ @ssl_required def top_todo(): filter = request.values.get("filter") - new_applications, update_requests = True, True + new_applications, update_requests, on_hold = True, True, True if filter == "na": + on_hold = False update_requests = False elif filter == "ur": + on_hold = False + new_applications = False + elif filter == "oh": + update_requests = False new_applications = False # ~~-> Todo:Service~~ @@ -30,7 +35,8 @@ def top_todo(): todos = svc.top_todo(current_user._get_current_object(), size=app.config.get("TODO_LIST_SIZE"), new_applications=new_applications, - update_requests=update_requests) + update_requests=update_requests, + on_hold=on_hold) # ~~-> Dashboard:Page~~ return render_template('dashboard/index.html', todos=todos) From e7a270979fcf7e4731067999428a2d850ed63655 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Fri, 19 Jul 2024 08:42:00 +0100 Subject: [PATCH 37/80] add unit testing for on hold dashboard --- .../bll_todo_maned/top_todo_maned.matrix.csv | 12 +-- .../top_todo_maned.settings.csv | 76 ++++++++++--------- .../top_todo_maned.settings.json | 47 ++++++++++++ doajtest/unit/test_bll_todo_top_todo_maned.py | 6 +- 4 files changed, 98 insertions(+), 43 deletions(-) diff --git a/doajtest/matrices/bll_todo_maned/top_todo_maned.matrix.csv b/doajtest/matrices/bll_todo_maned/top_todo_maned.matrix.csv index 9965c8049..0e0a97573 100644 --- a/doajtest/matrices/bll_todo_maned/top_todo_maned.matrix.csv +++ b/doajtest/matrices/bll_todo_maned/top_todo_maned.matrix.csv @@ -1,6 +1,6 @@ -test_id,account,raises,todo_maned_stalled,todo_maned_follow_up_old,todo_maned_ready,todo_maned_completed,todo_maned_assign_pending,todo_maned_new_update_request,todo_maned_new_update_request_order,todo_maned_ready_order,todo_maned_follow_up_old_order,todo_maned_stalled_order,todo_maned_assign_pending_order,todo_maned_completed_order -1,none,ArgumentException,0,0,0,0,0,0,,,,,, -2,no_role,,0,0,0,0,0,0,,,,,, -3,admin,,1,1,1,1,1,1,1,2,3,4,5,6 -4,editor,,0,0,0,0,0,0,,,,,, -5,assed,,0,0,0,0,0,0,,,,,, +test_id,account,raises,todo_maned_stalled,todo_maned_follow_up_old,todo_maned_ready,todo_maned_completed,todo_maned_assign_pending,todo_maned_new_update_request,todo_maned_on_hold,todo_maned_new_update_request_order,todo_maned_ready_order,todo_maned_follow_up_old_order,todo_maned_stalled_order,todo_maned_assign_pending_order,todo_maned_completed_order,todo_maned_on_hold_order +1,none,ArgumentException,0,0,0,0,0,0,0,,,,,,, +2,no_role,,0,0,0,0,0,0,0,,,,,,, +3,admin,,1,1,1,1,1,1,1,1,2,3,4,5,6,7 +4,editor,,0,0,0,0,0,0,0,,,,,,, +5,assed,,0,0,0,0,0,0,0,,,,,,, diff --git a/doajtest/matrices/bll_todo_maned/top_todo_maned.settings.csv b/doajtest/matrices/bll_todo_maned/top_todo_maned.settings.csv index a8148032f..3d0f77656 100644 --- a/doajtest/matrices/bll_todo_maned/top_todo_maned.settings.csv +++ b/doajtest/matrices/bll_todo_maned/top_todo_maned.settings.csv @@ -1,36 +1,40 @@ -field,test_id,account,raises,todo_maned_stalled,todo_maned_follow_up_old,todo_maned_ready,todo_maned_completed,todo_maned_assign_pending,todo_maned_new_update_request,todo_maned_new_update_request_order,todo_maned_ready_order,todo_maned_follow_up_old_order,todo_maned_stalled_order,todo_maned_assign_pending_order,todo_maned_completed_order -type,index,generated,conditional,conditional,conditional,conditional,conditional,conditional,conditional,conditional,conditional,conditional,conditional,conditional,conditional -default,,,,,,,,,,,,,,, -,,,,,,,,,,,,,,, -values,,none,ArgumentException,,,,,,,,,,,, -values,,no_role,,,,,,,,,,,,, -values,,admin,,,,,,,,,,,,, -values,,editor,,,,,,,,,,,,, -values,,assed,,,,,,,,,,,,, -,,,,,,,,,,,,,,, -conditional raises,,none,ArgumentException,,,,,,,,,,,, -,,,,,,,,,,,,,,, -conditional todo_maned_stalled,,admin,,1,,,,,,,,,,, -conditional todo_maned_stalled,,!admin,,0,,,,,,,,,,, -,,,,,,,,,,,,,,, -conditional todo_maned_follow_up_old,,admin,,,1,,,,,,,,,, -conditional todo_maned_follow_up_old,,!admin,,,0,,,,,,,,,, -,,,,,,,,,,,,,,, -conditional todo_maned_ready,,admin,,,,1,,,,,,,,, -conditional todo_maned_ready,,!admin,,,,0,,,,,,,,, -,,,,,,,,,,,,,,, -conditional todo_maned_completed,,admin,,,,,1,,,,,,,, -conditional todo_maned_completed,,!admin,,,,,0,,,,,,,, -,,,,,,,,,,,,,,, -conditional todo_maned_assign_pending,,admin,,,,,,1,,,,,,, -conditional todo_maned_assign_pending,,!admin,,,,,,0,,,,,,, -,,,,,,,,,,,,,,, -conditional todo_maned_new_update_request,,admin,,,,,,,1,,,,,, -conditional todo_maned_new_update_request,,!admin,,,,,,,0,,,,,, -,,,,,,,,,,,,,,, -conditional todo_maned_new_update_request_order,,admin,,,,,,,,1,,,,, -conditional todo_maned_ready_order,,admin,,,,,,,,,2,,,, -conditional todo_maned_follow_up_old_order,,admin,,,,,,,,,,3,,, -conditional todo_maned_stalled_order,,admin,,,,,,,,,,,4,, -conditional todo_maned_assign_pending_order,,admin,,,,,,,,,,,,5, -conditional todo_maned_completed_order,,admin,,,,,,,,,,,,,6 \ No newline at end of file +field,test_id,account,raises,todo_maned_stalled,todo_maned_follow_up_old,todo_maned_ready,todo_maned_completed,todo_maned_assign_pending,todo_maned_new_update_request,todo_maned_on_hold,todo_maned_new_update_request_order,todo_maned_ready_order,todo_maned_follow_up_old_order,todo_maned_stalled_order,todo_maned_assign_pending_order,todo_maned_completed_order,todo_maned_on_hold_order +type,index,generated,conditional,conditional,conditional,conditional,conditional,conditional,conditional,conditional,conditional,conditional,conditional,conditional,conditional,conditional,conditional +default,,,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,, +values,,none,ArgumentException,,,,,,,,,,,,,, +values,,no_role,,,,,,,,,,,,,,, +values,,admin,,,,,,,,,,,,,,, +values,,editor,,,,,,,,,,,,,,, +values,,assed,,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,, +conditional raises,,none,ArgumentException,,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,, +conditional todo_maned_stalled,,admin,,1,,,,,,,,,,,,, +conditional todo_maned_stalled,,!admin,,0,,,,,,,,,,,,, +,,,,,,,,,,,,,,,,, +conditional todo_maned_follow_up_old,,admin,,,1,,,,,,,,,,,, +conditional todo_maned_follow_up_old,,!admin,,,0,,,,,,,,,,,, +,,,,,,,,,,,,,,,,, +conditional todo_maned_ready,,admin,,,,1,,,,,,,,,,, +conditional todo_maned_ready,,!admin,,,,0,,,,,,,,,,, +,,,,,,,,,,,,,,,,, +conditional todo_maned_completed,,admin,,,,,1,,,,,,,,,, +conditional todo_maned_completed,,!admin,,,,,0,,,,,,,,,, +,,,,,,,,,,,,,,,,, +conditional todo_maned_assign_pending,,admin,,,,,,1,,,,,,,,, +conditional todo_maned_assign_pending,,!admin,,,,,,0,,,,,,,,, +,,,,,,,,,,,,,,,,, +conditional todo_maned_new_update_request,,admin,,,,,,,1,,,,,,,, +conditional todo_maned_new_update_request,,!admin,,,,,,,0,,,,,,,, +,,,,,,,,,,,,,,,,, +conditional todo_maned_on_hold,,admin,,,,,,,,1,,,,,,, +conditional todo_maned_on_hold,,!admin,,,,,,,,0,,,,,,, +,,,,,,,,,,,,,,,,, +conditional todo_maned_new_update_request_order,,admin,,,,,,,,,1,,,,,, +conditional todo_maned_ready_order,,admin,,,,,,,,,,2,,,,, +conditional todo_maned_follow_up_old_order,,admin,,,,,,,,,,,3,,,, +conditional todo_maned_stalled_order,,admin,,,,,,,,,,,,4,,, +conditional todo_maned_assign_pending_order,,admin,,,,,,,,,,,,,5,, +conditional todo_maned_completed_order,,admin,,,,,,,,,,,,,,6, +conditional todo_maned_on_hold_order,,admin,,,,,,,,,,,,,,,7 \ No newline at end of file diff --git a/doajtest/matrices/bll_todo_maned/top_todo_maned.settings.json b/doajtest/matrices/bll_todo_maned/top_todo_maned.settings.json index 6625f298f..a7ef53dc1 100644 --- a/doajtest/matrices/bll_todo_maned/top_todo_maned.settings.json +++ b/doajtest/matrices/bll_todo_maned/top_todo_maned.settings.json @@ -207,6 +207,35 @@ } } }, + { + "name": "todo_maned_on_hold", + "type": "conditional", + "default": "", + "values": { + "1": { + "conditions": [ + { + "account": { + "or": [ + "admin" + ] + } + } + ] + }, + "0": { + "conditions": [ + { + "account": { + "nor": [ + "admin" + ] + } + } + ] + } + } + }, { "name": "todo_maned_new_update_request_order", "type": "conditional", @@ -314,6 +343,24 @@ ] } } + }, + { + "name": "todo_maned_on_hold_order", + "type": "conditional", + "default": "", + "values": { + "7": { + "conditions": [ + { + "account": { + "or": [ + "admin" + ] + } + } + ] + } + } } ] } \ No newline at end of file diff --git a/doajtest/unit/test_bll_todo_top_todo_maned.py b/doajtest/unit/test_bll_todo_top_todo_maned.py index 5322a8c9a..1c5ae4457 100644 --- a/doajtest/unit/test_bll_todo_top_todo_maned.py +++ b/doajtest/unit/test_bll_todo_top_todo_maned.py @@ -41,7 +41,8 @@ def test_top_todo(self, name, kwargs): "todo_maned_ready", "todo_maned_completed", "todo_maned_assign_pending", - "todo_maned_new_update_request" + "todo_maned_new_update_request", + "todo_maned_on_hold" ] category_args = { @@ -100,6 +101,9 @@ def assign_pending(ap): self.build_application("maned_update_request", 5 * w, 5 * w, constants.APPLICATION_STATUS_UPDATE_REQUEST, apps, update_request=True) + # an application that was modifed recently into the ready status (todo_maned_completed) + self.build_application("maned_on_hold", 2 * w, 2 * w, constants.APPLICATION_STATUS_ON_HOLD, apps) + # Applications that should never be reported ############################################ From 5bf71ad623d0c3fa15a6a1ff4113fe3abf21e8a8 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Fri, 19 Jul 2024 10:23:38 +0100 Subject: [PATCH 38/80] update maned todo functional test script --- doajtest/testbook/dashboards/maned_todo.yml | 23 +++++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/doajtest/testbook/dashboards/maned_todo.yml b/doajtest/testbook/dashboards/maned_todo.yml index 96c7b171c..017965bc7 100644 --- a/doajtest/testbook/dashboards/maned_todo.yml +++ b/doajtest/testbook/dashboards/maned_todo.yml @@ -27,7 +27,7 @@ tests: - step: Go to the maned dashboard page path: /dashboard results: - - You can see 16 applications in your priority list + - You can see 17 applications in your priority list - Your priority list contains a mixture of managing editor items (actions related to teams you are the managing editor for), editor items (actions related to teams you are the editor for) and associate items (actions related to applications which are assigned specifically to you for review). @@ -37,30 +37,31 @@ tests: - At least one of your priority items is for an application in the state ready (it should indicate that it is for your maned group) - At least one of your priority items is for an application in the completed state which has not been updated for more than 2 weeks (it should indicate that it is for your maned group) - At least one of your priority items is for an application in the pending state which has not been updated for more than 2 weeks (it should indicate that it is for your maned group) + - At least one of your priority items is for an application in the "on hold" state - Your lowest priority item (last in the list) is for an update request which was submitted this month - step: click on the managing editor's ready application - step: Change the application status to "Accepted" and save - step: close the tab, return to the dashboard and reload the page results: - - You can see 15 applications in your priority list + - You can see 16 applications in your priority list - The application you have just edited has disappeared from your priority list - step: click on the [in progress] stalled managing editor's application - step: make any minor adjustment to the metadata and save - step: close the tab, return to the dashboard and reload the page results: - - You can see 14 applications in your priority list + - You can see 15 applications in your priority list - The application you just edited has disappeared from your priority list - step: click on the "completed" maned application - step: Change the application to "ready" status - step: close the tab, return to the dashboard and reload the page results: - - You can still see 14 applications in your priority list + - You can still see 13 applications in your priority list - The completed application you just moved to ready is now in your priority list as a ready application - step: click on the pending managing editor's application - step: Assign the item to an editor in the selected group (there should be a test editor available to you to select) - step: close the tab, return to the dashboard and reload the page results: - - You have 13 applications left in your todo list + - You have 12 applications left in your todo list - The pending application you just edited is no longer visible - title: Filtering the todo list @@ -74,22 +75,26 @@ tests: - step: Go to the maned dashboard page path: /dashboard results: - - You can see 16 applications in your priority list + - You can see 17 applications in your priority list - Your highest priority item (first in the list) is for an update request which was submitted last month - Your lowest priority item (last in the list) is for an update request which was submitted this month - - On the top right of the todo list are a set of filter buttons "Show all", "New Applications" and "Update Requests" + - On the top right of the todo list are a set of filter buttons "Show all", "New Applications", "Update Requests" and "On Hold" - The "Show all" button is highlighted - step: click on the "New Applications" filter button results: - You can see 14 applications in your priority list - - The update requests which were on the previous screen are no longer visible + - The update requests and "on hold" items which were on the previous screen are no longer visible - The "New Applications" filter button is now highlighted - step: click on the "Update Request" filter button results: - - You can see 12application in your priority list + - You can see 2 applications in your priority list - Your highest priority item (first in the list) is for an update request which was submitted last month - Your lowest priority item (last in the list) is for an update request which was submitted this month - The "Update Request" filter button is now highlighted + - step: click on the "On Hold" filter button + results: + - You can see 1 application in your priority list + - The "On Hold" filter button is now highlighted - step: click the "Show all" filter button results: - You are back to the original display, containing both applications and update requests \ No newline at end of file From 3aa6803b57bf2b8f1e32dda55d0114e79c0e8dd5 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Fri, 19 Jul 2024 13:27:38 +0100 Subject: [PATCH 39/80] remove on hold constraint from update requests (should be irrelevant) --- portality/bll/services/todo.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/portality/bll/services/todo.py b/portality/bll/services/todo.py index 258094b0c..38a0225dd 100644 --- a/portality/bll/services/todo.py +++ b/portality/bll/services/todo.py @@ -274,8 +274,7 @@ def maned_last_month_update_requests(cls, size, maned_of): must_nots=[ TodoQuery.status([ constants.APPLICATION_STATUS_ACCEPTED, - constants.APPLICATION_STATUS_REJECTED, - constants.APPLICATION_STATUS_ON_HOLD + constants.APPLICATION_STATUS_REJECTED ]) # TodoQuery.exists("admin.editor") ], @@ -298,8 +297,7 @@ def maned_new_update_requests(cls, size, maned_of): must_nots=[ TodoQuery.status([ constants.APPLICATION_STATUS_ACCEPTED, - constants.APPLICATION_STATUS_REJECTED, - constants.APPLICATION_STATUS_ON_HOLD + constants.APPLICATION_STATUS_REJECTED ]) # TodoQuery.exists("admin.editor") ], From ad0c19c54b0ab230997c2158ca6c9e212453a8c6 Mon Sep 17 00:00:00 2001 From: philip Date: Tue, 30 Jul 2024 15:10:11 +0100 Subject: [PATCH 40/80] Reverted submodule edges to develop_edges1 --- portality/static/vendor/edges | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portality/static/vendor/edges b/portality/static/vendor/edges index 9639b871a..2ca0da93e 160000 --- a/portality/static/vendor/edges +++ b/portality/static/vendor/edges @@ -1 +1 @@ -Subproject commit 9639b871acb1f6590ee78e236f2c9333479c9fe8 +Subproject commit 2ca0da93e7bf345a7e529d1ed056c8dd0a328b6a From c12c2ab5366d4bf09c2e3a88d571dbf50fe9f27f Mon Sep 17 00:00:00 2001 From: philip Date: Wed, 31 Jul 2024 10:09:38 +0100 Subject: [PATCH 41/80] change edges HEAD to same as develop 990f4220163a3e18880f0bdc3ad5c80d234d22dd --- portality/static/vendor/edges | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portality/static/vendor/edges b/portality/static/vendor/edges index 2ca0da93e..990f42201 160000 --- a/portality/static/vendor/edges +++ b/portality/static/vendor/edges @@ -1 +1 @@ -Subproject commit 2ca0da93e7bf345a7e529d1ed056c8dd0a328b6a +Subproject commit 990f4220163a3e18880f0bdc3ad5c80d234d22dd From b7052394c1f3a7a1280a8dcc8ad0ac75b511a9b9 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Thu, 22 Aug 2024 16:33:53 +0100 Subject: [PATCH 42/80] add withdrawn journals as delete records in oai --- portality/crosswalks/oaipmh.py | 7 +++++++ portality/models/oaipmh.py | 15 ++++++++------- portality/view/oaipmh.py | 12 ++++++++---- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/portality/crosswalks/oaipmh.py b/portality/crosswalks/oaipmh.py index 26bab8229..a4c514988 100644 --- a/portality/crosswalks/oaipmh.py +++ b/portality/crosswalks/oaipmh.py @@ -246,6 +246,10 @@ def crosswalk(self, record): idel = etree.SubElement(oai_dc, self.DC + "identifier") set_text(idel, identifier.get("id")) + # beyond this point, only include the metadata if the record is not "deleted" + if not record.is_in_doaj(): + return metadata + # our internal identifier url = app.config["BASE_URL"] + "/toc/" + record.toc_id idel = etree.SubElement(oai_dc, self.DC + "identifier") @@ -293,6 +297,9 @@ def header(self, record): bibjson = record.bibjson() head = etree.Element(self.PMH + "header", nsmap=self.NSMAP) + if not record.is_in_doaj(): + head.set("status", "deleted") + identifier = etree.SubElement(head, self.PMH + "identifier") set_text(identifier, make_oai_identifier(record.id, "journal")) diff --git a/portality/models/oaipmh.py b/portality/models/oaipmh.py index 4350cb66e..39a6ee4f2 100644 --- a/portality/models/oaipmh.py +++ b/portality/models/oaipmh.py @@ -42,7 +42,7 @@ class OAIPMHRecord(object): "query": { "bool": { "must": [ - { "term": { "admin.in_doaj": True } } + # { "term": { "admin.in_doaj": True } } ] } }, @@ -126,15 +126,16 @@ def pull(self, identifier): return record return None + class OAIPMHJournal(OAIPMHRecord, Journal): def list_records(self, from_date=None, until_date=None, oai_set=None, list_size=None, start_after=None): total, results = super(OAIPMHJournal, self).list_records(from_date=from_date, until_date=until_date, oai_set=oai_set, list_size=list_size, start_after=start_after) return total, [Journal(**r) for r in results] - def pull(self, identifier): - # override the default pull, as we care about whether the item is in_doaj - record = super(OAIPMHJournal, self).pull(identifier) - if record is not None and record.is_in_doaj(): - return record - return None + # def pull(self, identifier): + # # override the default pull, as we care about whether the item is in_doaj + # record = super(OAIPMHJournal, self).pull(identifier) + # if record is not None and record.is_in_doaj(): + # return record + # return None diff --git a/portality/view/oaipmh.py b/portality/view/oaipmh.py index 947d5d5f2..3575c4cf1 100644 --- a/portality/view/oaipmh.py +++ b/portality/view/oaipmh.py @@ -288,13 +288,16 @@ def get_record(dao, base_url, specified_oai_endpoint, identifier=None, metadata_ return IdDoesNotExist(base_url) # do the crosswalk xwalk = get_crosswalk(f.get("metadataPrefix"), dao.__type__) - metadata = xwalk.crosswalk(record) + header = xwalk.header(record) - # make the response oai_id = make_oai_identifier(identifier, dao.__type__) gr = GetRecord(base_url, oai_id, metadata_prefix) - gr.metadata = metadata gr.header = header + + if record.is_in_doaj(): + metadata = xwalk.crosswalk(record) + gr.metadata = metadata + return gr # if we have not returned already, this means we can't disseminate this format @@ -556,7 +559,8 @@ def get_element(self): record = etree.SubElement(gr, self.PMH + "record") record.append(self.header) - record.append(self.metadata) + if self.metadata is not None: + record.append(self.metadata) return gr From c0eef4e6ad0d3879ead2bc9bba4cf3a4b01057c5 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Tue, 27 Aug 2024 10:37:56 +0100 Subject: [PATCH 43/80] add withdrawn articles as delete records in oai --- portality/crosswalks/oaipmh.py | 82 +++++++++++++++++++++------------- portality/models/oaipmh.py | 12 ++--- 2 files changed, 58 insertions(+), 36 deletions(-) diff --git a/portality/crosswalks/oaipmh.py b/portality/crosswalks/oaipmh.py index a4c514988..6c844a3c3 100644 --- a/portality/crosswalks/oaipmh.py +++ b/portality/crosswalks/oaipmh.py @@ -106,15 +106,28 @@ def crosswalk(self, record): oai_dc.set(self.XSI + "schemaLocation", "http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd") - if bibjson.title is not None: - title = etree.SubElement(oai_dc, self.DC + "title") - set_text(title, bibjson.title) - # all the external identifiers (ISSNs, etc) for identifier in bibjson.get_identifiers(): idel = etree.SubElement(oai_dc, self.DC + "identifier") set_text(idel, identifier.get("id")) + # beyond this point, only include the metadata if the record is not "deleted" + if not record.is_in_doaj(): + # include the ft url only, so the client can identify the article this way if needed + ftobj = bibjson.get_single_url('fulltext', unpack_urlobj=False) + if ftobj and "url" in ftobj: + urlel = etree.SubElement(oai_dc, self.DC + "relation") + set_text(urlel, ftobj.get("url")) + return metadata + + for identifier in bibjson.get_identifiers(idtype=bibjson.P_ISSN) + bibjson.get_identifiers(idtype=bibjson.E_ISSN): + journallink = etree.SubElement(oai_dc, self.DC + "relation") + set_text(journallink, app.config['BASE_URL'] + "/toc/" + identifier) + + if bibjson.title is not None: + title = etree.SubElement(oai_dc, self.DC + "title") + set_text(title, bibjson.title) + # our internal identifier url = app.config['BASE_URL'] + "/article/" + record.id idel = etree.SubElement(oai_dc, self.DC + "identifier") @@ -130,10 +143,6 @@ def crosswalk(self, record): urlel = etree.SubElement(oai_dc, self.DC + "relation") set_text(urlel, url.get("url")) - for identifier in bibjson.get_identifiers(idtype=bibjson.P_ISSN) + bibjson.get_identifiers(idtype=bibjson.E_ISSN): - journallink = etree.SubElement(oai_dc, self.DC + "relation") - set_text(journallink, app.config['BASE_URL'] + "/toc/" + identifier) - if bibjson.abstract is not None: abstract = etree.SubElement(oai_dc, self.DC + "description") set_text(abstract, bibjson.abstract) @@ -171,6 +180,9 @@ def header(self, record): bibjson = record.bibjson() head = etree.Element(self.PMH + "header", nsmap=self.NSMAP) + if not record.is_in_doaj(): + head.set("status", "deleted") + identifier = etree.SubElement(head, self.PMH + "identifier") set_text(identifier, make_oai_identifier(record.id, "article")) @@ -329,6 +341,34 @@ def crosswalk(self, record): oai_doaj_article.set(self.XSI + "schemaLocation", "http://www.openarchives.org/OAI/2.0/ http://www.openarchives.org/OAI/2.0/OAI-PMH.xsd http://doaj.org/features/oai_doaj/1.0/ https://doaj.org/static/doaj/doajArticles.xsd") + # all the external identifiers (ISSNs, etc) + if bibjson.get_one_identifier(bibjson.P_ISSN): + issn = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "issn") + set_text(issn, bibjson.get_one_identifier(bibjson.P_ISSN)) + + if bibjson.get_one_identifier(bibjson.E_ISSN): + eissn = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "eissn") + set_text(eissn, bibjson.get_one_identifier(bibjson.E_ISSN)) + + if bibjson.get_one_identifier(bibjson.DOI): + doi = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "doi") + set_text(doi, bibjson.get_one_identifier(bibjson.DOI)) + + ftobj = bibjson.get_single_url('fulltext', unpack_urlobj=False) + if ftobj: + attrib = {} + if "content_type" in ftobj: + attrib['format'] = ftobj['content_type'] + + fulltext_url_elem = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "fullTextUrl", **attrib) + + if "url" in ftobj: + set_text(fulltext_url_elem, ftobj['url']) + + # beyond this point, only include the metadata if the record is not "deleted" + if not record.is_in_doaj(): + return metadata + # look up the journal's language jlangs = bibjson.journal_language # first, if there are any languages recorded, get the 3-char code @@ -355,14 +395,6 @@ def crosswalk(self, record): journtitel = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "journalTitle") set_text(journtitel, bibjson.journal_title) - # all the external identifiers (ISSNs, etc) - if bibjson.get_one_identifier(bibjson.P_ISSN): - issn = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "issn") - set_text(issn, bibjson.get_one_identifier(bibjson.P_ISSN)) - - if bibjson.get_one_identifier(bibjson.E_ISSN): - eissn = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "eissn") - set_text(eissn, bibjson.get_one_identifier(bibjson.E_ISSN)) # work out the date of publication date = bibjson.get_publication_date() @@ -396,9 +428,7 @@ def crosswalk(self, record): end_page = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "endPage") set_text(end_page, bibjson.end_page) - if bibjson.get_one_identifier(bibjson.DOI): - doi = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "doi") - set_text(doi, bibjson.get_one_identifier(bibjson.DOI)) + if record.publisher_record_id(): pubrecid = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "publisherRecordId") @@ -443,17 +473,6 @@ def crosswalk(self, record): abstract = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "abstract") set_text(abstract, bibjson.abstract) - ftobj = bibjson.get_single_url('fulltext', unpack_urlobj=False) - if ftobj: - attrib = {} - if "content_type" in ftobj: - attrib['format'] = ftobj['content_type'] - - fulltext_url_elem = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "fullTextUrl", **attrib) - - if "url" in ftobj: - set_text(fulltext_url_elem, ftobj['url']) - if bibjson.keywords: keywords_elem = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + 'keywords') for keyword in bibjson.keywords: @@ -466,6 +485,9 @@ def header(self, record): bibjson = record.bibjson() head = etree.Element(self.PMH + "header", nsmap=self.NSMAP) + if not record.is_in_doaj(): + head.set("status", "deleted") + identifier = etree.SubElement(head, self.PMH + "identifier") set_text(identifier, make_oai_identifier(record.id, "article")) diff --git a/portality/models/oaipmh.py b/portality/models/oaipmh.py index 39a6ee4f2..461cd9759 100644 --- a/portality/models/oaipmh.py +++ b/portality/models/oaipmh.py @@ -119,12 +119,12 @@ def list_records(self, from_date=None, until_date=None, oai_set=None, list_size= until_date=until_date, oai_set=oai_set, list_size=list_size, start_after=start_after) return total, [Article(**r) for r in results] - def pull(self, identifier): - # override the default pull, as we care about whether the item is in_doaj - record = super(OAIPMHArticle, self).pull(identifier) - if record is not None and record.is_in_doaj(): - return record - return None + # def pull(self, identifier): + # # override the default pull, as we care about whether the item is in_doaj + # record = super(OAIPMHArticle, self).pull(identifier) + # if record is not None and record.is_in_doaj(): + # return record + # return None class OAIPMHJournal(OAIPMHRecord, Journal): From 6485715ac1e50f6bda62571e44e44c910cdeda3e Mon Sep 17 00:00:00 2001 From: philip Date: Wed, 28 Aug 2024 10:03:57 +0100 Subject: [PATCH 44/80] handle NoSuchObjectException (DOAJ/doajPM#3838) --- doajtest/unit/test_view_publisher.py | 21 +++++++++++++++++++++ portality/view/publisher.py | 8 ++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 doajtest/unit/test_view_publisher.py diff --git a/doajtest/unit/test_view_publisher.py b/doajtest/unit/test_view_publisher.py new file mode 100644 index 000000000..6ea8f1ec2 --- /dev/null +++ b/doajtest/unit/test_view_publisher.py @@ -0,0 +1,21 @@ +from doajtest import helpers +from doajtest.helpers import DoajTestCase +from portality import models, constants +from portality.util import url_for + + +class TestViewPublisher(DoajTestCase): + + def test_delete_application__no_such_object(self): + pwd = 'password' + un = 'publisher_a' + acc = models.Account.make_account(un + "@example.com", un, "Publisher " + un, [constants.ROLE_PUBLISHER]) + acc.set_password(pwd) + acc.save(blocking=True) + + with self.app_test.test_client() as t_client: + resp = helpers.login(t_client, acc.email, pwd) + assert resp.status_code == 200 + + resp = t_client.get(url_for("publisher.delete_application", application_id='no_such_id')) + assert resp.status_code == 400 diff --git a/portality/view/publisher.py b/portality/view/publisher.py index a3e328b28..fe92f542a 100644 --- a/portality/view/publisher.py +++ b/portality/view/publisher.py @@ -4,7 +4,8 @@ from portality.app_email import EmailException from portality import models, constants -from portality.bll.exceptions import AuthoriseException, ArticleMergeConflict, DuplicateArticleException, ArticleNotAcceptable +from portality.bll.exceptions import AuthoriseException, ArticleMergeConflict, DuplicateArticleException, \ + ArticleNotAcceptable, NoSuchObjectException from portality.decorators import ssl_required, restrict_to_role, write_required from portality.dao import ESMappingMissingError from portality.forms.application_forms import ApplicationFormFactory @@ -54,7 +55,10 @@ def delete_application(application_id): # otherwise delegate to the application service to sort this out appService = DOAJ.applicationService() - appService.delete_application(application_id, current_user._get_current_object()) + try: + appService.delete_application(application_id, current_user._get_current_object()) + except NoSuchObjectException: + abort(400) return redirect(url_for("publisher.deleted_thanks")) From 9f0540315971e03e6f76f50ef788df598c0edce5 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Thu, 29 Aug 2024 11:52:16 +0100 Subject: [PATCH 45/80] tighten up deleted record implementation for spec compliance --- portality/crosswalks/oaipmh.py | 89 +++++++++++++++------------------- portality/view/oaipmh.py | 3 +- 2 files changed, 41 insertions(+), 51 deletions(-) diff --git a/portality/crosswalks/oaipmh.py b/portality/crosswalks/oaipmh.py index 6c844a3c3..cd09e6089 100644 --- a/portality/crosswalks/oaipmh.py +++ b/portality/crosswalks/oaipmh.py @@ -99,6 +99,9 @@ class OAI_DC_Article(OAI_DC): ~~->OAIDC:Crosswalk~~ """ def crosswalk(self, record): + if not record.is_in_doaj(): + return None + bibjson = record.bibjson() metadata = etree.Element(self.PMH + "metadata") @@ -106,28 +109,15 @@ def crosswalk(self, record): oai_dc.set(self.XSI + "schemaLocation", "http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd") + if bibjson.title is not None: + title = etree.SubElement(oai_dc, self.DC + "title") + set_text(title, bibjson.title) + # all the external identifiers (ISSNs, etc) for identifier in bibjson.get_identifiers(): idel = etree.SubElement(oai_dc, self.DC + "identifier") set_text(idel, identifier.get("id")) - # beyond this point, only include the metadata if the record is not "deleted" - if not record.is_in_doaj(): - # include the ft url only, so the client can identify the article this way if needed - ftobj = bibjson.get_single_url('fulltext', unpack_urlobj=False) - if ftobj and "url" in ftobj: - urlel = etree.SubElement(oai_dc, self.DC + "relation") - set_text(urlel, ftobj.get("url")) - return metadata - - for identifier in bibjson.get_identifiers(idtype=bibjson.P_ISSN) + bibjson.get_identifiers(idtype=bibjson.E_ISSN): - journallink = etree.SubElement(oai_dc, self.DC + "relation") - set_text(journallink, app.config['BASE_URL'] + "/toc/" + identifier) - - if bibjson.title is not None: - title = etree.SubElement(oai_dc, self.DC + "title") - set_text(title, bibjson.title) - # our internal identifier url = app.config['BASE_URL'] + "/article/" + record.id idel = etree.SubElement(oai_dc, self.DC + "identifier") @@ -143,6 +133,10 @@ def crosswalk(self, record): urlel = etree.SubElement(oai_dc, self.DC + "relation") set_text(urlel, url.get("url")) + for identifier in bibjson.get_identifiers(idtype=bibjson.P_ISSN) + bibjson.get_identifiers(idtype=bibjson.E_ISSN): + journallink = etree.SubElement(oai_dc, self.DC + "relation") + set_text(journallink, app.config['BASE_URL'] + "/toc/" + identifier) + if bibjson.abstract is not None: abstract = etree.SubElement(oai_dc, self.DC + "description") set_text(abstract, bibjson.abstract) @@ -243,6 +237,9 @@ class OAI_DC_Journal(OAI_DC): ~~->OAIDC:Crosswalk~~ """ def crosswalk(self, record): + if not record.is_in_doaj(): + return None + bibjson = record.bibjson() metadata = etree.Element(self.PMH + "metadata") @@ -258,10 +255,6 @@ def crosswalk(self, record): idel = etree.SubElement(oai_dc, self.DC + "identifier") set_text(idel, identifier.get("id")) - # beyond this point, only include the metadata if the record is not "deleted" - if not record.is_in_doaj(): - return metadata - # our internal identifier url = app.config["BASE_URL"] + "/toc/" + record.toc_id idel = etree.SubElement(oai_dc, self.DC + "identifier") @@ -334,6 +327,9 @@ class OAI_DOAJ_Article(OAI_Crosswalk): NSMAP.update({"oai_doaj": OAI_DOAJ_NAMESPACE}) def crosswalk(self, record): + if not record.is_in_doaj(): + return None + bibjson = record.bibjson() metadata = etree.Element(self.PMH + "metadata") @@ -341,34 +337,6 @@ def crosswalk(self, record): oai_doaj_article.set(self.XSI + "schemaLocation", "http://www.openarchives.org/OAI/2.0/ http://www.openarchives.org/OAI/2.0/OAI-PMH.xsd http://doaj.org/features/oai_doaj/1.0/ https://doaj.org/static/doaj/doajArticles.xsd") - # all the external identifiers (ISSNs, etc) - if bibjson.get_one_identifier(bibjson.P_ISSN): - issn = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "issn") - set_text(issn, bibjson.get_one_identifier(bibjson.P_ISSN)) - - if bibjson.get_one_identifier(bibjson.E_ISSN): - eissn = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "eissn") - set_text(eissn, bibjson.get_one_identifier(bibjson.E_ISSN)) - - if bibjson.get_one_identifier(bibjson.DOI): - doi = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "doi") - set_text(doi, bibjson.get_one_identifier(bibjson.DOI)) - - ftobj = bibjson.get_single_url('fulltext', unpack_urlobj=False) - if ftobj: - attrib = {} - if "content_type" in ftobj: - attrib['format'] = ftobj['content_type'] - - fulltext_url_elem = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "fullTextUrl", **attrib) - - if "url" in ftobj: - set_text(fulltext_url_elem, ftobj['url']) - - # beyond this point, only include the metadata if the record is not "deleted" - if not record.is_in_doaj(): - return metadata - # look up the journal's language jlangs = bibjson.journal_language # first, if there are any languages recorded, get the 3-char code @@ -395,6 +363,14 @@ def crosswalk(self, record): journtitel = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "journalTitle") set_text(journtitel, bibjson.journal_title) + # all the external identifiers (ISSNs, etc) + if bibjson.get_one_identifier(bibjson.P_ISSN): + issn = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "issn") + set_text(issn, bibjson.get_one_identifier(bibjson.P_ISSN)) + + if bibjson.get_one_identifier(bibjson.E_ISSN): + eissn = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "eissn") + set_text(eissn, bibjson.get_one_identifier(bibjson.E_ISSN)) # work out the date of publication date = bibjson.get_publication_date() @@ -428,7 +404,9 @@ def crosswalk(self, record): end_page = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "endPage") set_text(end_page, bibjson.end_page) - + if bibjson.get_one_identifier(bibjson.DOI): + doi = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "doi") + set_text(doi, bibjson.get_one_identifier(bibjson.DOI)) if record.publisher_record_id(): pubrecid = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "publisherRecordId") @@ -473,6 +451,17 @@ def crosswalk(self, record): abstract = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "abstract") set_text(abstract, bibjson.abstract) + ftobj = bibjson.get_single_url('fulltext', unpack_urlobj=False) + if ftobj: + attrib = {} + if "content_type" in ftobj: + attrib['format'] = ftobj['content_type'] + + fulltext_url_elem = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + "fullTextUrl", **attrib) + + if "url" in ftobj: + set_text(fulltext_url_elem, ftobj['url']) + if bibjson.keywords: keywords_elem = etree.SubElement(oai_doaj_article, self.OAI_DOAJ + 'keywords') for keyword in bibjson.keywords: diff --git a/portality/view/oaipmh.py b/portality/view/oaipmh.py index 3575c4cf1..b1c5ec7ac 100644 --- a/portality/view/oaipmh.py +++ b/portality/view/oaipmh.py @@ -739,7 +739,8 @@ def get_element(self): for metadata, header in self.records: r = etree.SubElement(lr, self.PMH + "record") r.append(header) - r.append(metadata) + if metadata is not None: + r.append(metadata) if self.resumption is not None: rt = etree.SubElement(lr, self.PMH + "resumptionToken") From 0aaa3db0f83b3e64eee34efaf520a38dfb0b5625 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Thu, 29 Aug 2024 13:11:16 +0100 Subject: [PATCH 46/80] Update oai tests --- doajtest/unit/test_oaipmh.py | 71 +++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/doajtest/unit/test_oaipmh.py b/doajtest/unit/test_oaipmh.py index 0a6540eec..a12f9736b 100644 --- a/doajtest/unit/test_oaipmh.py +++ b/doajtest/unit/test_oaipmh.py @@ -43,7 +43,7 @@ def test_01_oai_ListMetadataFormats(self): assert t.xpath('/oai:OAI-PMH/oai:ListMetadataFormats/oai:metadataFormat/oai:metadataPrefix', namespaces=self.oai_ns)[0].text == 'oai_dc' def test_02_oai_journals(self): - """test if the OAI-PMH journal feed returns records and only displays journals accepted in DOAJ""" + """test if the OAI-PMH journal feed returns records and only displays journals accepted in DOAJ, marking withdrawn ones as deleted""" journal_sources = JournalFixtureFactory.make_many_journal_sources(2, in_doaj=True) j_public = models.Journal(**journal_sources[0]) j_public.save(blocking=True) @@ -52,6 +52,7 @@ def test_02_oai_journals(self): j_private = models.Journal(**journal_sources[1]) j_private.set_in_doaj(False) j_private.save(blocking=True) + deleted_id = j_private.id with self.app_test.test_request_context(): with self.app_test.test_client() as t_client: @@ -61,11 +62,24 @@ def test_02_oai_journals(self): t = etree.fromstring(resp.data) records = t.xpath('/oai:OAI-PMH/oai:ListRecords', namespaces=self.oai_ns) - # Check we only have one journal returned - assert len(records[0].xpath('//oai:record', namespaces=self.oai_ns)) == 1 - - # Check we have the correct journal - assert records[0].xpath('//dc:title', namespaces=self.oai_ns)[0].text == j_public.bibjson().title + # Check we only have two journals returned + assert len(records[0].xpath('//oai:record', namespaces=self.oai_ns)) == 2 + + seen_deleted = False + seen_public = False + records = records[0].getchildren() + for r in records: + header = r.xpath('oai:header', namespaces=self.oai_ns)[0] + status = header.get("status") + if status == "deleted": + seen_deleted = True + else: + # Check we have the correct journal + seen_public = True + assert r.xpath('//dc:title', namespaces=self.oai_ns)[0].text == j_public.bibjson().title + + assert seen_deleted + assert seen_public resp = t_client.get(url_for('oaipmh.oaipmh', verb='GetRecord', metadataPrefix='oai_dc') + '&identifier={0}'.format(public_id)) assert resp.status_code == 200 @@ -306,23 +320,22 @@ def test_08_list_sets(self): def test_09_article(self): - """test if the OAI-PMH journal feed returns records and only displays journals accepted in DOAJ""" + """test if the OAI-PMH article feed returns records and only displays articles accepted in DOAJ, showing the others as deleted""" article_source = ArticleFixtureFactory.make_article_source(eissn='1234-1234', pissn='5678-5678,', in_doaj=False) - """test if the OAI-PMH article feed returns records and only displays articles accepted in DOAJ""" a_private = models.Article(**article_source) + a_private.set_id(a_private.makeid()) ba = a_private.bibjson() ba.title = "Private Article" a_private.save(blocking=True) article_source = ArticleFixtureFactory.make_article_source(eissn='4321-4321', pissn='8765-8765,', in_doaj=True) a_public = models.Article(**article_source) + a_public.set_id(a_public.makeid()) ba = a_public.bibjson() ba.title = "Public Article" a_public.save(blocking=True) public_id = a_public.id - time.sleep(1) - with self.app_test.test_request_context(): with self.app_test.test_client() as t_client: resp = t_client.get(url_for('oaipmh.oaipmh', specified='article', verb='ListRecords', metadataPrefix='oai_dc')) @@ -331,23 +344,39 @@ def test_09_article(self): t = etree.fromstring(resp.data) records = t.xpath('/oai:OAI-PMH/oai:ListRecords', namespaces=self.oai_ns) - # Check we only have one journal returned + # Check we only have two articles returned r = records[0].xpath('//oai:record', namespaces=self.oai_ns) - assert len(r) == 1 - - # Check we have the correct journal - title = r[0].xpath('//dc:title', namespaces=self.oai_ns)[0].text - # check orcid_id xwalk - assert str(records[0].xpath('//dc:creator/@id', namespaces=self.oai_ns)[0]) == a_public.bibjson().author[0].get("orcid_id") - assert records[0].xpath('//dc:title', namespaces=self.oai_ns)[0].text == a_public.bibjson().title - - resp = t_client.get(url_for('oaipmh.oaipmh', specified='article', verb='GetRecord', metadataPrefix='oai_dc') + '&identifier=abcdefghijk_article') + assert len(r) == 2 + + seen_deleted = False + seen_public = False + records = records[0].getchildren() + for r in records: + header = r.xpath('oai:header', namespaces=self.oai_ns)[0] + status = header.get("status") + if status == "deleted": + seen_deleted = True + else: + seen_public = True + # Check we have the correct article + title = r[0].xpath('//dc:title', namespaces=self.oai_ns)[0].text + + # check orcid_id xwalk + assert str(records[0].xpath('//dc:creator/@id', namespaces=self.oai_ns)[0]) == \ + a_public.bibjson().author[0].get("orcid_id") + assert records[0].xpath('//dc:title', namespaces=self.oai_ns)[ + 0].text == a_public.bibjson().title + + assert seen_deleted + assert seen_public + + resp = t_client.get(url_for('oaipmh.oaipmh', specified='article', verb='GetRecord', metadataPrefix='oai_dc') + '&identifier=' + public_id) assert resp.status_code == 200 t = etree.fromstring(resp.data) records = t.xpath('/oai:OAI-PMH/oai:GetRecord', namespaces=self.oai_ns) - # Check we only have one journal returnedt + # Check we only have one article returned kids = records[0].getchildren() r = records[0].xpath('//oai:record', namespaces=self.oai_ns) assert len(r) == 1 From e4f03ffc3fad1aadacc57ce2684fc946b534468e Mon Sep 17 00:00:00 2001 From: philip Date: Thu, 29 Aug 2024 13:25:25 +0100 Subject: [PATCH 47/80] 404 --- doajtest/unit/test_view_publisher.py | 2 +- portality/view/publisher.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doajtest/unit/test_view_publisher.py b/doajtest/unit/test_view_publisher.py index 6ea8f1ec2..fbdf77054 100644 --- a/doajtest/unit/test_view_publisher.py +++ b/doajtest/unit/test_view_publisher.py @@ -18,4 +18,4 @@ def test_delete_application__no_such_object(self): assert resp.status_code == 200 resp = t_client.get(url_for("publisher.delete_application", application_id='no_such_id')) - assert resp.status_code == 400 + assert resp.status_code == 404 diff --git a/portality/view/publisher.py b/portality/view/publisher.py index fe92f542a..902be3960 100644 --- a/portality/view/publisher.py +++ b/portality/view/publisher.py @@ -58,7 +58,7 @@ def delete_application(application_id): try: appService.delete_application(application_id, current_user._get_current_object()) except NoSuchObjectException: - abort(400) + abort(404) return redirect(url_for("publisher.deleted_thanks")) From d6b2565821559b2b36c5d98509089c96deaddaff Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Thu, 29 Aug 2024 13:33:08 +0100 Subject: [PATCH 48/80] timing fix to oai test --- doajtest/unit/test_oaipmh.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doajtest/unit/test_oaipmh.py b/doajtest/unit/test_oaipmh.py index a12f9736b..d1f6133b4 100644 --- a/doajtest/unit/test_oaipmh.py +++ b/doajtest/unit/test_oaipmh.py @@ -336,6 +336,8 @@ def test_09_article(self): a_public.save(blocking=True) public_id = a_public.id + time.sleep(1) + with self.app_test.test_request_context(): with self.app_test.test_client() as t_client: resp = t_client.get(url_for('oaipmh.oaipmh', specified='article', verb='ListRecords', metadataPrefix='oai_dc')) From 8956178f8703b7a4a4746f2a6d0c72110bb63470 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Tue, 3 Sep 2024 15:47:46 +0100 Subject: [PATCH 49/80] put new dependency in alphabetical order --- setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 3fca37d02..870d178cb 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,7 @@ "feedparser==6.0.8", "itsdangerous==2.0.1", # fixme: unpinned dependency of flask, 2.1.0 is causing an import error 'json' "jinja2<3.1.0", # fixme: unpinned dependency of flask, import error on 'escape' + "jsonpath-ng~=1.6", "Flask~=2.1.2", "Flask-Cors==3.0.8", "Flask-DebugToolbar==0.13.1", @@ -64,8 +65,6 @@ 'gspread-dataframe~=3.3.1', 'gspread-formatting~=1.1.2', - 'jsonpath-ng~=1.6', - ] + (["setproctitle==1.1.10"] if "linux" in sys.platform else []), extras_require={ # prevent backtracking through all versions From d8974c9b3273315af524b1aa6a96fc129a348857 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Tue, 3 Sep 2024 16:53:06 +0100 Subject: [PATCH 50/80] update on hold dashboard rule to cover maned or assigned user --- doajtest/testbook/dashboards/maned_todo.yml | 18 +++++++------ .../testdrive/todo_maned_editor_associate.py | 16 +++++++++--- portality/bll/services/todo.py | 26 +++++++++++++------ 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/doajtest/testbook/dashboards/maned_todo.yml b/doajtest/testbook/dashboards/maned_todo.yml index 017965bc7..06ce8921c 100644 --- a/doajtest/testbook/dashboards/maned_todo.yml +++ b/doajtest/testbook/dashboards/maned_todo.yml @@ -27,7 +27,7 @@ tests: - step: Go to the maned dashboard page path: /dashboard results: - - You can see 17 applications in your priority list + - You can see 18 applications in your priority list - Your priority list contains a mixture of managing editor items (actions related to teams you are the managing editor for), editor items (actions related to teams you are the editor for) and associate items (actions related to applications which are assigned specifically to you for review). @@ -43,25 +43,25 @@ tests: - step: Change the application status to "Accepted" and save - step: close the tab, return to the dashboard and reload the page results: - - You can see 16 applications in your priority list + - You can see 17 applications in your priority list - The application you have just edited has disappeared from your priority list - step: click on the [in progress] stalled managing editor's application - step: make any minor adjustment to the metadata and save - step: close the tab, return to the dashboard and reload the page results: - - You can see 15 applications in your priority list + - You can see 16 applications in your priority list - The application you just edited has disappeared from your priority list - step: click on the "completed" maned application - step: Change the application to "ready" status - step: close the tab, return to the dashboard and reload the page results: - - You can still see 13 applications in your priority list + - You can still see 15 applications in your priority list - The completed application you just moved to ready is now in your priority list as a ready application - step: click on the pending managing editor's application - step: Assign the item to an editor in the selected group (there should be a test editor available to you to select) - step: close the tab, return to the dashboard and reload the page results: - - You have 12 applications left in your todo list + - You have 14 applications left in your todo list - The pending application you just edited is no longer visible - title: Filtering the todo list @@ -75,14 +75,14 @@ tests: - step: Go to the maned dashboard page path: /dashboard results: - - You can see 17 applications in your priority list + - You can see 18 applications in your priority list - Your highest priority item (first in the list) is for an update request which was submitted last month - Your lowest priority item (last in the list) is for an update request which was submitted this month - On the top right of the todo list are a set of filter buttons "Show all", "New Applications", "Update Requests" and "On Hold" - The "Show all" button is highlighted - step: click on the "New Applications" filter button results: - - You can see 14 applications in your priority list + - You can see 16 applications in your priority list - The update requests and "on hold" items which were on the previous screen are no longer visible - The "New Applications" filter button is now highlighted - step: click on the "Update Request" filter button @@ -93,8 +93,10 @@ tests: - The "Update Request" filter button is now highlighted - step: click on the "On Hold" filter button results: - - You can see 1 application in your priority list + - You can see 2 application in your priority list - The "On Hold" filter button is now highlighted + - One of the "On Hold" items is for an application which is not assigned to you, but belongs to a group you are the managing editor for + - The other "On Hold" item is for an application which is assigned to you, in a group for which you are not the managing editor - step: click the "Show all" filter button results: - You are back to the original display, containing both applications and update requests \ No newline at end of file diff --git a/doajtest/testdrive/todo_maned_editor_associate.py b/doajtest/testdrive/todo_maned_editor_associate.py index 0ca722c55..f6a4d6287 100644 --- a/doajtest/testdrive/todo_maned_editor_associate.py +++ b/doajtest/testdrive/todo_maned_editor_associate.py @@ -51,7 +51,7 @@ def setup(self) -> dict: aapps = build_associate_applications(un) eapps = build_editor_applications(un, eg2) - mapps = build_maned_applications(un, eg1, owner.id, eg3) + mapps = build_maned_applications(un, eg1, owner.id, eg3, eg2) return { @@ -96,7 +96,7 @@ def teardown(self, params) -> dict: return {"status": "success"} -def build_maned_applications(un, eg, owner, eponymous_group): +def build_maned_applications(un, eg, owner, eponymous_group, other_group): w = 7 * 24 * 60 * 60 apps = {} @@ -142,14 +142,22 @@ def build_maned_applications(un, eg, owner, eponymous_group): "title": un + " Maned Pending Application" }] - app = build_application(un + " Maned On Hold Application", 2 * w, 2 * w, constants.APPLICATION_STATUS_ON_HOLD, + app = build_application(un + " Maned (Group) On Hold Application", 2 * w, 2 * w, constants.APPLICATION_STATUS_ON_HOLD, editor_group=eg.name, owner=owner) app.save() apps["on_hold"] = [{ "id": app.id, - "title": un + " Maned On Hold Application" + "title": un + " Maned (Group) On Hold Application" }] + app = build_application(un + " Maned (Editor) On Hold Application", 2 * w, 2 * w, constants.APPLICATION_STATUS_ON_HOLD, + editor_group=other_group.name, editor=un, owner=owner) + app.save() + apps["on_hold"].append({ + "id": app.id, + "title": un + " Maned (Editor) On Hold Application" + }) + app = build_application(un + " Maned Low Priority Pending Application", 1 * w, 1 * w, constants.APPLICATION_STATUS_PENDING, editor_group=eponymous_group.name, owner=owner) diff --git a/portality/bll/services/todo.py b/portality/bll/services/todo.py index 38a0225dd..cd3723bb3 100644 --- a/portality/bll/services/todo.py +++ b/portality/bll/services/todo.py @@ -90,7 +90,7 @@ def top_todo(self, account, size=25, new_applications=True, update_requests=True queries.append(TodoRules.maned_last_month_update_requests(size, maned_of)) queries.append(TodoRules.maned_new_update_requests(size, maned_of)) if on_hold: - queries.append(TodoRules.maned_on_hold(size, maned_of)) + queries.append(TodoRules.maned_on_hold(size, account.id, maned_of)) if new_applications: # editor and associate editor roles only deal with new applications if account.has_role("editor"): @@ -307,14 +307,17 @@ def maned_new_update_requests(cls, size, maned_of): return constants.TODO_MANED_NEW_UPDATE_REQUEST, assign_pending, sort_date, -2 @classmethod - def maned_on_hold(cls, size, maned_of): + def maned_on_hold(cls, size, account, maned_of): sort_date = "created_date" on_holds = TodoQuery( musts=[ - TodoQuery.editor_group(maned_of), TodoQuery.is_new_application(), TodoQuery.status([constants.APPLICATION_STATUS_ON_HOLD]) ], + ors=[ + TodoQuery.editor_group(maned_of), + TodoQuery.editor(account) + ], sort=sort_date, size=size ) @@ -484,9 +487,10 @@ class TodoQuery(object): # therefore, we take a created_date sort to mean a date_applied sort cd_sort = {"admin.date_applied": {"order": "asc"}} - def __init__(self, musts=None, must_nots=None, sort="last_manual_update", size=10): + def __init__(self, musts=None, must_nots=None, ors=None, sort="last_manual_update", size=10): self._musts = [] if musts is None else musts self._must_nots = [] if must_nots is None else must_nots + self._ors = [] if ors is None else ors self._sort = sort self._size = size @@ -494,16 +498,22 @@ def query(self): sort = self.lmu_sort if self._sort == "last_manual_update" else self.cd_sort q = { "query" : { - "bool" : { - "must": self._musts, - "must_not": self._must_nots - } + "bool" : {} }, "sort" : [ sort ], "size" : self._size } + + if len(self._musts) > 0: + q["query"]["bool"]["must"] = self._musts + if len(self._must_nots) > 0: + q["query"]["bool"]["must_not"] = self._must_nots + if len(self._ors) > 0: + q["query"]["bool"]["should"] = self._ors + q["query"]["bool"]["minimum_should_match"] = 1 + return q @classmethod From eb7c05a42575c463e9f8e0496257c74556ce5001 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Mon, 16 Sep 2024 14:49:12 +0100 Subject: [PATCH 51/80] fully implement tests for pmh delete --- doajtest/helpers.py | 28 ++++++++++++++--- doajtest/unit/test_models.py | 55 ++++++++++++++++++++++++++++++++++ doajtest/unit/test_oaipmh.py | 22 ++++++++++---- portality/crosswalks/oaipmh.py | 6 ++-- portality/models/__init__.py | 2 +- portality/models/article.py | 42 ++++++++++++++++++++++++-- portality/models/oaipmh.py | 21 +++++++------ portality/settings.py | 1 + 8 files changed, 152 insertions(+), 25 deletions(-) diff --git a/doajtest/helpers.py b/doajtest/helpers.py index 1fcf47eba..19c4629b2 100644 --- a/doajtest/helpers.py +++ b/doajtest/helpers.py @@ -21,6 +21,7 @@ from portality.lib.thread_utils import wait_until from portality.tasks.redis_huey import main_queue, long_running from portality.util import url_for +from view.admin import index def patch_config(inst, properties): @@ -67,6 +68,8 @@ def setUp(self): for im in self.warm_mappings: if im == "article": self.warmArticle() + if im == "article_tombstone": + self.warmArticleTombstone() # add more types if they are necessary def tearDown(self): @@ -82,6 +85,16 @@ def warmArticle(self): article.delete() Article.blockdeleted(article.id) + def warmArticleTombstone(self): + # push an article to initialise the mappings + from doajtest.fixtures import ArticleFixtureFactory + from portality.models import ArticleTombstone + source = ArticleFixtureFactory.make_article_source() + article = ArticleTombstone(**source) + article.save(blocking=True) + article.delete() + ArticleTombstone.blockdeleted(article.id) + CREATED_INDICES = [] @@ -91,10 +104,17 @@ def initialise_index(): def create_index(index_type): - if index_type in CREATED_INDICES: - return - core.initialise_index(app, core.es_connection, only_mappings=[index_type]) - CREATED_INDICES.append(index_type) + if "," in index_type: + # this covers a DAO that has multiple index types for searching purposes + # expressed as a comma separated list + index_types = index_type.split(",") + else: + index_types = [index_type] + for it in index_types: + if it in CREATED_INDICES: + return + core.initialise_index(app, core.es_connection, only_mappings=[it]) + CREATED_INDICES.append(it) def dao_proxy(dao_method, type="class"): diff --git a/doajtest/unit/test_models.py b/doajtest/unit/test_models.py index af07a9285..d1bae191c 100644 --- a/doajtest/unit/test_models.py +++ b/doajtest/unit/test_models.py @@ -1722,6 +1722,61 @@ def test_40_autocheck_retrieves(self): ap2 = models.Autocheck.for_journal("9876") assert ap2.journal == "9876" + def test_41_article_tombstone(self): + t = models.ArticleTombstone() + t.set_id("1234") + t.bibjson().add_subject("LCC", "Medicine", "KM22") + t.set_in_doaj(True) # should have no effect + + t.save(blocking=True) + + t2 = models.ArticleTombstone.pull("1234") + assert t2.id == "1234" + assert t2.is_in_doaj() is False + assert t2.last_updated is not None + assert t2.bibjson().subjects()[0].get("scheme") == "LCC" + assert t2.bibjson().subjects()[0].get("term") == "Medicine" + assert t2.bibjson().subjects()[0].get("code") == "KM22" + + def test_42_make_article_tombstone(self): + a = models.Article(**ArticleFixtureFactory.make_article_source(in_doaj=True)) + a.set_id(a.makeid()) + + t = a._tombstone() + assert t.id == a.id + assert t.bibjson().subjects() == a.bibjson().subjects() + assert t.is_in_doaj() is False + + a = models.Article(**ArticleFixtureFactory.make_article_source(in_doaj=True)) + a.set_id(a.makeid()) + a.delete() + time.sleep(1) + + stone = models.ArticleTombstone.pull(a.id) + assert stone is not None + + a = models.Article(**ArticleFixtureFactory.make_article_source(in_doaj=True)) + a.set_id(a.makeid()) + a.save(blocking=True) + + query = { + "query": { + "bool": { + "must": [ + {"term": {"id.exact": a.id}} + ] + } + } + } + models.Article.delete_selected(query) + time.sleep(1) + + stone = models.ArticleTombstone.pull(a.id) + assert stone is not None + + + + class TestAccount(DoajTestCase): def test_get_name_safe(self): diff --git a/doajtest/unit/test_oaipmh.py b/doajtest/unit/test_oaipmh.py index d1f6133b4..55f27467c 100644 --- a/doajtest/unit/test_oaipmh.py +++ b/doajtest/unit/test_oaipmh.py @@ -10,12 +10,14 @@ from doajtest.fixtures import ArticleFixtureFactory from doajtest.fixtures import JournalFixtureFactory from doajtest.helpers import DoajTestCase +from models import ArticleTombstone from portality import models from portality.app import app from portality.lib import dates from portality.lib.dates import FMT_DATE_STD from portality.view.oaipmh import ResumptionTokenException, decode_resumption_token +from doajtest.helpers import with_es class TestClient(DoajTestCase): @classmethod @@ -336,7 +338,14 @@ def test_09_article(self): a_public.save(blocking=True) public_id = a_public.id - time.sleep(1) + stone = models.ArticleTombstone() + stone.set_id(stone.makeid()) + stone.bibjson().add_subject("LCC", "Economic theory. Demography", "AB22") + stone.save(blocking=True) + stone_id = stone.id + + models.Article.blockall([(a_private.id, a_private.last_updated), (a_public.id, a_public.last_updated)]) + models.ArticleTombstone.blockall([(stone.id, stone.last_updated)]) with self.app_test.test_request_context(): with self.app_test.test_client() as t_client: @@ -348,16 +357,16 @@ def test_09_article(self): # Check we only have two articles returned r = records[0].xpath('//oai:record', namespaces=self.oai_ns) - assert len(r) == 2 + assert len(r) == 3 - seen_deleted = False + seen_deleted = 0 seen_public = False records = records[0].getchildren() for r in records: header = r.xpath('oai:header', namespaces=self.oai_ns)[0] status = header.get("status") if status == "deleted": - seen_deleted = True + seen_deleted += 1 else: seen_public = True # Check we have the correct article @@ -369,7 +378,7 @@ def test_09_article(self): assert records[0].xpath('//dc:title', namespaces=self.oai_ns)[ 0].text == a_public.bibjson().title - assert seen_deleted + assert seen_deleted == 2 assert seen_public resp = t_client.get(url_for('oaipmh.oaipmh', specified='article', verb='GetRecord', metadataPrefix='oai_dc') + '&identifier=' + public_id) @@ -437,7 +446,8 @@ def test_10_subjects(self): # Check we have the correct journal assert records[0].xpath('//dc:title', namespaces=self.oai_ns)[0].text == j_public.bibjson().title - + @with_es(indices=[models.Article.__type__, models.ArticleTombstone.__type__], + warm_mappings=[models.Article.__type__, models.ArticleTombstone.__type__]) def test_11_oai_dc_attr(self): """test if the OAI-PMH article feed returns record with correct attributes in oai_dc element""" article_source = ArticleFixtureFactory.make_article_source(eissn='1234-1234', pissn='5678-5678,', in_doaj=True) diff --git a/portality/crosswalks/oaipmh.py b/portality/crosswalks/oaipmh.py index cd09e6089..dfc4efff9 100644 --- a/portality/crosswalks/oaipmh.py +++ b/portality/crosswalks/oaipmh.py @@ -490,10 +490,12 @@ def header(self, record): CROSSWALKS = { "oai_dc": { "article": OAI_DC_Article, - "journal": OAI_DC_Journal + "journal": OAI_DC_Journal, + "article,article_tombstone": OAI_DC_Article }, 'oai_doaj': { - "article": OAI_DOAJ_Article + "article": OAI_DOAJ_Article, + "article,article_tombstone": OAI_DOAJ_Article } } diff --git a/portality/models/__init__.py b/portality/models/__init__.py index 257092910..21f1f460a 100644 --- a/portality/models/__init__.py +++ b/portality/models/__init__.py @@ -14,7 +14,7 @@ from portality.models.uploads import FileUpload, ExistsFileQuery, OwnerFileQuery, ValidFileQuery, BulkArticles from portality.models.lock import Lock from portality.models.history import ArticleHistory, JournalHistory -from portality.models.article import Article, ArticleBibJSON, ArticleQuery, ArticleVolumesQuery, DuplicateArticleQuery, NoJournalException +from portality.models.article import Article, ArticleBibJSON, ArticleQuery, ArticleVolumesQuery, DuplicateArticleQuery, NoJournalException, ArticleTombstone from portality.models.oaipmh import OAIPMHRecord, OAIPMHJournal, OAIPMHArticle from portality.models.atom import AtomRecord from portality.models.search import JournalArticle, JournalStatsQuery, ArticleStatsQuery diff --git a/portality/models/article.py b/portality/models/article.py index d431bb5ae..80f56fb2d 100644 --- a/portality/models/article.py +++ b/portality/models/article.py @@ -95,19 +95,26 @@ def delete_by_issns(cls, issns, snapshot=True): cls.delete_selected(query=q.query(), snapshot=snapshot) @classmethod - def delete_selected(cls, query=None, owner=None, snapshot=True): + def delete_selected(cls, query=None, owner=None, snapshot=True, tombstone=True): if owner is not None: from portality.models import Journal issns = Journal.issns_by_owner(owner) q = ArticleQuery(issns=issns) query = q.query() - if snapshot: + if snapshot or tombstone: articles = cls.iterate(query, page_size=1000) for article in articles: - article.snapshot() + if snapshot: + article.snapshot() + if tombstone: + article._tombstone() return cls.delete_by_query(query) + def delete(self): + self._tombstone() + super(Article, self).delete() + def bibjson(self, **kwargs): if "bibjson" not in self.data: self.data["bibjson"] = {} @@ -142,6 +149,18 @@ def snapshot(self): hist.save() return hist.id + def _tombstone(self): + stone = ArticleTombstone() + stone.set_id(self.id) + sbj = stone.bibjson() + + subs = self.bibjson().subjects() + for s in subs: + sbj.add_subject(s.get("scheme"), s.get("term"), s.get("code")) + + stone.save() + return stone + def add_history(self, bibjson, date=None): """Deprecated""" bibjson = bibjson.bibjson if isinstance(bibjson, ArticleBibJSON) else bibjson @@ -565,6 +584,23 @@ def get_owner(self): return owners[0] + +class ArticleTombstone(Article): + __type__ = "article_tombstone" + + def snapshot(self): + return None + + def is_in_doaj(self): + return False + + def prep(self): + self.data['last_updated'] = dates.now_str() + + def save(self, *args, **kwargs): + return super(ArticleTombstone, self).save(*args, **kwargs) + + class ArticleBibJSON(GenericBibJSON): def __init__(self, bibjson=None, **kwargs): diff --git a/portality/models/oaipmh.py b/portality/models/oaipmh.py index 461cd9759..187426682 100644 --- a/portality/models/oaipmh.py +++ b/portality/models/oaipmh.py @@ -1,5 +1,6 @@ from copy import deepcopy -from portality.models import Journal, Article + +from portality.models import Journal, Article, ArticleTombstone from portality import constants class OAIPMHRecord(object): @@ -114,17 +115,19 @@ def list_records(self, from_date=None, until_date=None, oai_set=None, list_size= class OAIPMHArticle(OAIPMHRecord, Article): + __type__ = "article,article_tombstone" + def list_records(self, from_date=None, until_date=None, oai_set=None, list_size=None, start_after=None): total, results = super(OAIPMHArticle, self).list_records(from_date=from_date, until_date=until_date, oai_set=oai_set, list_size=list_size, start_after=start_after) - return total, [Article(**r) for r in results] - - # def pull(self, identifier): - # # override the default pull, as we care about whether the item is in_doaj - # record = super(OAIPMHArticle, self).pull(identifier) - # if record is not None and record.is_in_doaj(): - # return record - # return None + return total, [Article(**r) if r.get("es_type") == "article" else ArticleTombstone(**r) for r in results] + + def pull(self, identifier): + # override the default pull, as we must check the tombstone record too + article = Article.pull(identifier) + if article is None: + article = ArticleTombstone.pull(identifier) + return article class OAIPMHJournal(OAIPMHRecord, Journal): diff --git a/portality/settings.py b/portality/settings.py index c9abf564c..446fdcfa9 100644 --- a/portality/settings.py +++ b/portality/settings.py @@ -707,6 +707,7 @@ MAPPINGS['provenance'] = MAPPINGS["account"] #~~->Provenance:Model~~ MAPPINGS['preserve'] = MAPPINGS["account"] #~~->Preservation:Model~~ MAPPINGS['notification'] = MAPPINGS["account"] #~~->Notification:Model~~ +MAPPINGS['article_tombstone'] = MAPPINGS["account"] #~~->ArticleTombstone:Model~~ ######################################### # Query Routes From 01bf01175190e10d09f0f54886927f41e115ed04 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Mon, 16 Sep 2024 14:58:15 +0100 Subject: [PATCH 52/80] tidy up code for PR --- doajtest/helpers.py | 1 - doajtest/unit/test_oaipmh.py | 5 +---- portality/models/oaipmh.py | 11 +---------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/doajtest/helpers.py b/doajtest/helpers.py index 19c4629b2..fe2f585fa 100644 --- a/doajtest/helpers.py +++ b/doajtest/helpers.py @@ -21,7 +21,6 @@ from portality.lib.thread_utils import wait_until from portality.tasks.redis_huey import main_queue, long_running from portality.util import url_for -from view.admin import index def patch_config(inst, properties): diff --git a/doajtest/unit/test_oaipmh.py b/doajtest/unit/test_oaipmh.py index 55f27467c..d5a3291de 100644 --- a/doajtest/unit/test_oaipmh.py +++ b/doajtest/unit/test_oaipmh.py @@ -10,7 +10,6 @@ from doajtest.fixtures import ArticleFixtureFactory from doajtest.fixtures import JournalFixtureFactory from doajtest.helpers import DoajTestCase -from models import ArticleTombstone from portality import models from portality.app import app from portality.lib import dates @@ -54,7 +53,6 @@ def test_02_oai_journals(self): j_private = models.Journal(**journal_sources[1]) j_private.set_in_doaj(False) j_private.save(blocking=True) - deleted_id = j_private.id with self.app_test.test_request_context(): with self.app_test.test_client() as t_client: @@ -342,7 +340,6 @@ def test_09_article(self): stone.set_id(stone.makeid()) stone.bibjson().add_subject("LCC", "Economic theory. Demography", "AB22") stone.save(blocking=True) - stone_id = stone.id models.Article.blockall([(a_private.id, a_private.last_updated), (a_public.id, a_public.last_updated)]) models.ArticleTombstone.blockall([(stone.id, stone.last_updated)]) @@ -355,7 +352,7 @@ def test_09_article(self): t = etree.fromstring(resp.data) records = t.xpath('/oai:OAI-PMH/oai:ListRecords', namespaces=self.oai_ns) - # Check we only have two articles returned + # Check we only have three articles returned r = records[0].xpath('//oai:record', namespaces=self.oai_ns) assert len(r) == 3 diff --git a/portality/models/oaipmh.py b/portality/models/oaipmh.py index 187426682..113c6076e 100644 --- a/portality/models/oaipmh.py +++ b/portality/models/oaipmh.py @@ -42,9 +42,7 @@ class OAIPMHRecord(object): "track_total_hits": True, "query": { "bool": { - "must": [ - # { "term": { "admin.in_doaj": True } } - ] + "must": [] } }, "from": 0, @@ -135,10 +133,3 @@ def list_records(self, from_date=None, until_date=None, oai_set=None, list_size= total, results = super(OAIPMHJournal, self).list_records(from_date=from_date, until_date=until_date, oai_set=oai_set, list_size=list_size, start_after=start_after) return total, [Journal(**r) for r in results] - - # def pull(self, identifier): - # # override the default pull, as we care about whether the item is in_doaj - # record = super(OAIPMHJournal, self).pull(identifier) - # if record is not None and record.is_in_doaj(): - # return record - # return None From c65258e46db42e2bb4a6403cf898f4a6286749e7 Mon Sep 17 00:00:00 2001 From: leenashah73 <65942894+leenashah73@users.noreply.github.com> Date: Thu, 3 Oct 2024 11:56:46 +0100 Subject: [PATCH 53/80] Update publisher_application_received.jinja2 text edit --- portality/templates/email/publisher_application_received.jinja2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portality/templates/email/publisher_application_received.jinja2 b/portality/templates/email/publisher_application_received.jinja2 index deddced38..bc440dddd 100644 --- a/portality/templates/email/publisher_application_received.jinja2 +++ b/portality/templates/email/publisher_application_received.jinja2 @@ -20,7 +20,7 @@ Thank you for your application submitted to DOAJ on {{ application.date_applied| Our average review time is 3 months due to the large number of applications we receive. You may request a status update only after three months have passed since you submitted an application. -During the review, you may receive an email from us if we have questions. Note that you may be contacted by our volunteer editor without a DOAJ email address so do check your spam folder. +During the review, you may receive an email from us if we have questions. Note that you may be contacted by our volunteer editor who does not have a DOAJ email address so do check your spam folder. You can see our volunteers listed here: {{ url_root }}{{url_for("doaj.volunteers")}} If you need to make a change to your application, send an email to our Help desk: helpdesk@doaj.org. Please include your ISSN. From 4dddd20c55314579225c6ded352ec72f2a3a1916 Mon Sep 17 00:00:00 2001 From: leenashah73 <65942894+leenashah73@users.noreply.github.com> Date: Thu, 3 Oct 2024 15:19:00 +0100 Subject: [PATCH 54/80] Update notifications.yml Edited the text as per the document https://docs.google.com/spreadsheets/d/1p0nTZQZbWGdZa4XmVHuH59GS5Y3qcbwKjuiwsH_aNGs/edit?gid=1411848067#gid=1411848067 --- cms/data/notifications.yml | 45 +++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/cms/data/notifications.yml b/cms/data/notifications.yml index cde76f922..082d88203 100644 --- a/cms/data/notifications.yml +++ b/cms/data/notifications.yml @@ -1,13 +1,13 @@ # ~~Notifications:Data~~ application:assed:assigned:notify: long: | - An application for the journal **{journal_title}** has been assigned to you by the Editor of your group **{group_name}**. Please start work on this within 10 days. + An application for **{journal_title}** has been assigned to you by the Editor of **{group_name}**. Please start work on this within 1 week. short: New application ({issns}) assigned to you application:assed:inprogress:notify: long: | - The application for **{application_title}** has not passed review by an Editor or Managing Editor and has been assigned back to you with questions or changes. + The application for **{application_title}** that you marked as Completed has been assigned back to you with questions or changes. short: One of your applications ({issns}) has not passed review @@ -20,19 +20,19 @@ application:editor:completed:notify: application:editor_group:assigned:notify: long: | - A new application or an update request for the journal **{journal_name}** has been assigned to your group by a Managing Editor. Please assign this to an Associate Editor within 5 working days. + A new application for **{journal_name}** has been assigned to your group by a Managing Editor. Please assign this to an Associate Editor within 5 working days. short: New application ({issns}) assigned to your group application:editor:inprogress:notify: long: | - The application for **{application_title}** has not passed review by a Managing Editor and has been assigned back to your group with questions or changes. + The application for **{application_title}** has been assigned back to your group with questions or changes. short: Application ({issns}) reverted to 'In Progress' by Managing Editor application:maned:ready:notify: long: | - The application for **{application_title}** has been marked **Ready** by **{editor}**. Please review it as soon as possible. + The application for **{application_title}** has been marked **Ready** by **{editor}** from **{group_name}** . Please review it within 1 week. short: Application ({issns}) marked as ready @@ -42,11 +42,9 @@ application:publisher:accepted:notify: You may access the journal record from your Publisher dashboard: [{publisher_dashboard_url}]({publisher_dashboard_url}) using your DOAJ account ID or email address, and password. - If there are changes or updates to the information about your journal at any time after it has been accepted, please [submit an Update Request](https://doaj.org/publisher/journal) from your Publisher dashboard promptly. Failure to do this promptly may result in the withdrawal of your journal from DOAJ. + It is your rresponsibility to keep the information about your journal in DOAJ up to date. When there are changes or updates needed please [submit an Update Request](https://doaj.org/publisher/journal) from your Publisher dashboard promptly. Please be aware that failure to do this may result in removal of your journal from DOAJ. - [How to submit an Update Request](https://doaj.org/apply/publisher-responsibilities/#keeping-your-journal-records-up-to-date) - - To increase the visibility, impact, distribution and usage of your journal content, we encourage you to upload article metadata for this journal to DOAJ as soon as possible. + To increase the visibility, distribution and usage of your journal content, we encourage you to upload article metadata for this journal to DOAJ as soon as possible. [How to upload article metadata](https://doaj.org/docs/faq/#uploading-article-metadata) @@ -64,13 +62,17 @@ application:publisher:created:notify: long: | {title} ([{journal_url}]({journal_url})) - Thank you for your application on {application_date}. - - We receive hundreds of applications every month. We will review yours as soon as we can. You will receive an email from us when the review is complete or if we have questions. + Thank you for your application submitted to DOAJ on {application_date}. - If you need to make a change to your application, send an email to our Help desk: [helpdesk@doaj.org](mailto:helpdesk@doaj.org). Please include your ISSN. We cannot reply to emails requesting a status update for applications which are less than three months old. - - If you write to us, check first that there is nothing in your Spam folder from us or one of our volunteers. Our volunteers may not be emailing from a DOAJ email address so you can check that their name is there on this page [{volunteers_url}]({volunteers_url}) + Our average review time is 3 months due to the large number of applications we receive. + You may request a status update only after three months have passed since you submitted an application. + + During the review, you may receive an email from us if we have questions. You may be contacted by one of our volunteer editors who will not be using a DOAJ email address so please check your spam folder regularly. + You can see our volunteers listed here: [{volunteers_url}]({volunteers_url}) + + If you need to make a change to your application, send an email to our Help desk: [helpdesk@doaj.org](mailto:helpdesk@doaj.org). Please include your ISSN. + We cannot reply to emails requesting a status update within 3 months of application. + short: Your application ({issns}) to DOAJ has been received @@ -84,11 +86,11 @@ application:publisher:inprogress:notify: application:publisher:quickreject:notify: long: | - The application which you submitted for **{title}** on {date_applied} has been rejected as the journal does not meet our basic criteria ([{doaj_guide_url}]({doaj_guide_url})). + The application you submitted for **{title}** on {date_applied} has been rejected as the journal does not meet our criteria for inclusion. {note} - You may submit a new application 6 months after the date of this email unless advised otherwise by a member of the DOAJ Editorial Team. Before you apply again, make any necessary changes to ensure your journal adheres to our criteria: ([{doaj_guide_url}]({doaj_guide_url})) + You may submit a new application 6 months after the date of this email. Before you apply again, make the necessary changes to ensure your journal adheres to our criteria: ([{doaj_guide_url}]({doaj_guide_url})) short: Your application ({issns}) was rejected @@ -118,9 +120,9 @@ journal:editor_group:assigned:notify: update_request:publisher:accepted:notify: long: | - Congratulations! The changes which you sent us for **{application_title}** on {application_date} have been reviewed and the journal record updated. Please note that some of the changes you suggested may have been omitted and replaced with other values by the Managing Editor who carried out the review. Review the journal record here: [{publisher_dashboard_url}]({publisher_dashboard_url}). + The changes which you submitted for **{application_title}** on {application_date} have been reviewed and the journal record updated. Please note that some of the changes you suggested may have been edited by our editorial team. Review the journal record here: [{publisher_dashboard_url}]({publisher_dashboard_url}). - Thank you for updating this journal and helping to keep the DOAJ database up-to-date. + Thank you for helping to keep the DOAJ database up-to-date. short: Update request ({issns}) accepted @@ -134,11 +136,10 @@ update_request:publisher:assigned:notify: update_request:publisher:rejected:notify: long: | - The update which you submitted for **{title}** on {date_applied} has been rejected. This is either because: + The update which you submitted for **{title}** on {date_applied} has been rejected. - - We were unable to verify the information which you provided with the information stated on your website. Please double check your website and submit a new update when you are ready; or + If you have any questions about this or require further details, please contact our Help Desk: helpdesk@doaj.org. - - We already have one active update in the system. Additional updates are rejected without review. short: Your update request ({issns}) was rejected From 9e33aa6f7b1064d10edb20e69cccf6e6bb2ae1b5 Mon Sep 17 00:00:00 2001 From: Dom Mitchell Date: Thu, 3 Oct 2024 17:38:32 +0200 Subject: [PATCH 55/80] Added new links --- cms/pages/about/index.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cms/pages/about/index.md b/cms/pages/about/index.md index ba714de17..c519871b2 100644 --- a/cms/pages/about/index.md +++ b/cms/pages/about/index.md @@ -66,6 +66,8 @@ DOAJ partners with many organisations. The nature of the partnerships varies and {:.stretch-list__item} + [Helsinki Initiative on Multilingualism](https://www.helsinki-initiative.org/) {:.stretch-list__item} ++ [IOI](https://infrafinder.investinopen.org/solutions/doaj-directory-of-open-access-journals) + {:.stretch-list__item} + [ISSN](https://www.issn.org/) {:.stretch-list__item} + [Library Publishing Coalition](https://librarypublishing.org/) @@ -76,6 +78,8 @@ DOAJ partners with many organisations. The nature of the partnerships varies and {:.stretch-list__item} + [OpenAIRE](https://www.openaire.eu/) {:.stretch-list__item} ++ [Principles of Open Scholarly Infrastructure (POSI)](https://openscholarlyinfrastructure.org/) + {:.stretch-list__item} + [Redalyc](https://www.redalyc.org/) {:.stretch-list__item} + [Research4Life](https://www.research4life.org/) @@ -85,7 +89,7 @@ DOAJ partners with many organisations. The nature of the partnerships varies and ## Think. Check. Submit. -DOAJ is a proud founder of [Think. Check. Submit.](https://thinkchecksubmit.org/) as well as a contributing organisation and long-standing committee member. +DOAJ is a proud founder of [Think. Check. Submit.](https://thinkchecksubmit.org/) as well as a contributing organisation and long-standing committee member. DOAJ currently chairs the TCS Committee. Established in 2015, Think. Check. Submit. was developed with the support of an international coalition of organisations from across scholarly communications in response to discussions about predatory publishing and amid a growing number of new and unfamiliar publishing options available for researchers. From cc479b03821e7e2e44be2f78c2a1fd0a63febb6c Mon Sep 17 00:00:00 2001 From: Dom Mitchell Date: Thu, 3 Oct 2024 17:42:50 +0200 Subject: [PATCH 56/80] Update index.md --- cms/pages/about/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/pages/about/index.md b/cms/pages/about/index.md index c519871b2..f677f83f1 100644 --- a/cms/pages/about/index.md +++ b/cms/pages/about/index.md @@ -66,7 +66,7 @@ DOAJ partners with many organisations. The nature of the partnerships varies and {:.stretch-list__item} + [Helsinki Initiative on Multilingualism](https://www.helsinki-initiative.org/) {:.stretch-list__item} -+ [IOI](https://infrafinder.investinopen.org/solutions/doaj-directory-of-open-access-journals) ++ [IOI Infra Finder](https://infrafinder.investinopen.org/solutions/doaj-directory-of-open-access-journals) {:.stretch-list__item} + [ISSN](https://www.issn.org/) {:.stretch-list__item} From fce2d92d5116b0d87202114f72b168f863564746 Mon Sep 17 00:00:00 2001 From: katrinesund <141714527+katrinesund@users.noreply.github.com> Date: Thu, 3 Oct 2024 19:32:57 +0200 Subject: [PATCH 57/80] Added Tanzil's photo --- cms/assets/img/ambassadors/Tanzil.jpg | Bin 0 -> 8552 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 cms/assets/img/ambassadors/Tanzil.jpg diff --git a/cms/assets/img/ambassadors/Tanzil.jpg b/cms/assets/img/ambassadors/Tanzil.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f3d9a7dc06ce37a57c1ed408182caa2ce0e1b628 GIT binary patch literal 8552 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 Date: Thu, 3 Oct 2024 19:33:30 +0200 Subject: [PATCH 58/80] Added Tanzil --- cms/data/ambassadors.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cms/data/ambassadors.yml b/cms/data/ambassadors.yml index 52c3cdc29..61329326f 100644 --- a/cms/data/ambassadors.yml +++ b/cms/data/ambassadors.yml @@ -22,6 +22,13 @@ photo: "ikhwan.jpg" coi: 2022: https://drive.google.com/file/d/1bmVVvMAPToLQCHGOfNz0D80xi4WyZhwn/view?usp=sharing + +- name: Mochammad Tanzil Multazam + region: Indonesia + bio: "Mochammad Tanzil Multazam is a prominent academic and legal expert at Universitas Muhammadiyah Sidoarjo, known for his significant contributions to scientific publication and library management. Since 2016, he has played a pivotal role in enhancing the quality of Indonesian journals through his leadership at Relawan Jurnal Indonesia, managing over 13,000 journals as the largest Crossref Sponsoring Organization globally. In 2023, he became Head of the UMSIDA Library, continuing his efforts to integrate digital technology into academic publishing. His influence extends internationally, particularly through the Silkroad Research Network, fostering collaboration among journal managers along the Silk Road Region" + photo: "Tanzil.jpg" + coi: + 2024: https://drive.google.com/file/d/1q3h3T45-VRbvJxf8e_HacEDnvJhzg5yY/view?usp=sharing - name: Ina Smith region: Southern Africa From 620f92acabdad762a6fb9a3da7964bf92bea358b Mon Sep 17 00:00:00 2001 From: Dom Mitchell Date: Fri, 4 Oct 2024 09:07:03 +0200 Subject: [PATCH 59/80] Edit link l.191 --- cms/pages/apply/transparency.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/pages/apply/transparency.md b/cms/pages/apply/transparency.md index 022bb6f9f..10c8682b2 100644 --- a/cms/pages/apply/transparency.md +++ b/cms/pages/apply/transparency.md @@ -188,7 +188,7 @@ COPE provides advice to editors and publishers on all aspects of publication eth The mission of the DOAJ is: to curate, maintain and develop a source of reliable information about open access scholarly journals on the web; to verify that entries on the list comply with reasonable standards; to increase the visibility, dissemination, discoverability and attraction of open access journals; to enable scholars, libraries, universities, research funders and other stakeholders to benefit from the information and services provided; to facilitate the integration of open access journals into library and aggregator services; to assist, where possible, publishers and their journals to meet reasonable digital publishing standards; and to thereby support the transition of the system of scholarly communication and publishing into a model that serves science, higher education, industry, innovation, societies and the people. Through this work, DOAJ will cooperate and collaborate with all interested parties working toward these objectives. -### [Open Access Scholarly Publishing Association](https://publicationethics.org/) (OASPA) +### [Open Access Scholarly Publishing Association](https://www.oaspa.org/) (OASPA) OASPA is a trade association that was established in 2008 in order to represent the interests of Open Access (OA) publishers globally across all disciplines. By encouraging collaboration in developing appropriate business models, tools and standards to support OA publishing, OASPA aims to help ensure a prosperous and sustainable future for the benefit of its members and the scholarly communities they serve. This mission is carried out through exchanging information, setting standards, advancing models, advocacy, education, and the promotion of innovation. From b437581e5928dd0aebc1317a1a0b58240a5ac2c6 Mon Sep 17 00:00:00 2001 From: philip Date: Fri, 4 Oct 2024 12:04:35 +0100 Subject: [PATCH 60/80] convert DOI to DO --- doajtest/unit/test_crosswalks_article_ris.py | 2 +- portality/crosswalks/article_ris.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doajtest/unit/test_crosswalks_article_ris.py b/doajtest/unit/test_crosswalks_article_ris.py index 5d58655f4..90a4e3e6d 100644 --- a/doajtest/unit/test_crosswalks_article_ris.py +++ b/doajtest/unit/test_crosswalks_article_ris.py @@ -28,7 +28,7 @@ def test_article2ris(self): AB - abstract KW - word KW - key -DOI - 10.0000/SOME.IDENTIFIER +DO - 10.0000/SOME.IDENTIFIER ER - """.split() diff --git a/portality/crosswalks/article_ris.py b/portality/crosswalks/article_ris.py index 214720084..c15cfcffa 100644 --- a/portality/crosswalks/article_ris.py +++ b/portality/crosswalks/article_ris.py @@ -24,7 +24,7 @@ def extra_author_names(article) -> list: 'UR': '$.bibjson.link[*].url', 'AB': '$.bibjson.abstract', 'KW': '$.bibjson.keywords[*]', - 'DOI': '$.bibjson.identifier[?(@.type == "doi")].id', + 'DO': '$.bibjson.identifier[?(@.type == "doi")].id', 'SN': '$.bibjson.journal.issns[*]', 'LA': '$.language.language', } From bdac4cc640774d452e3f245151d26988723c02ee Mon Sep 17 00:00:00 2001 From: philip Date: Fri, 4 Oct 2024 12:50:48 +0100 Subject: [PATCH 61/80] update test cases for languages --- doajtest/unit/test_crosswalks_article_ris.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doajtest/unit/test_crosswalks_article_ris.py b/doajtest/unit/test_crosswalks_article_ris.py index 90a4e3e6d..760d819e1 100644 --- a/doajtest/unit/test_crosswalks_article_ris.py +++ b/doajtest/unit/test_crosswalks_article_ris.py @@ -29,6 +29,8 @@ def test_article2ris(self): KW - word KW - key DO - 10.0000/SOME.IDENTIFIER +LA - EN +LA - FR ER - """.split() From c3be7d7957361867742e832820b27a8f4f25db88 Mon Sep 17 00:00:00 2001 From: Dom Mitchell Date: Tue, 8 Oct 2024 10:16:52 +0200 Subject: [PATCH 62/80] Corrected Subhani links --- cms/data/ambassadors.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/data/ambassadors.yml b/cms/data/ambassadors.yml index 61329326f..bf3c82615 100644 --- a/cms/data/ambassadors.yml +++ b/cms/data/ambassadors.yml @@ -59,7 +59,7 @@ - name: Muhammad Imtiaz Subhani region: Pakistan - bio: "Dr. Subhani is an [award-winning editor by Thomson Reuters in 2015](https://news.pakistantimes.com/2015/05/05/sajms-secures-third-position-in-scholarone-vision-award-2015-367073.html). He holds a PhD from Iqra University, Pakistan, in Financial Econometrics. Dr. Subahni is a Professor and Dean of the Business School at ILMA University, Pakistan. He is currently [working voluntarily for Society for Scholarly Publishing (SSP)](https://customer.sspnet.org/ssp/AboutUs/Committee-Roster.aspx?Code=EDUCATION) as an education committee member, while he is also a member of Creative Commons Global Network, CC Open GLAM, and Research Data Publishing Ethics, FORCE 11. He is a scientific publishing consultant & a technical committee member of HEC Journals’ Recognition System, at the Higher Education Commission, Government of Pakistan." + bio: "Dr. Subhani is an award-winning editor by Thomson Reuters in 2015. He holds a PhD in Financial Econometrics from Iqra University, Pakistan. Dr. Subahni is a Professor and Dean of the Business School at ILMA University, Pakistan. He is currently working voluntarily for Society for Scholarly Publishing (SSP) as an education committee member. He is also a member of Creative Commons Global Network, CC Open GLAM, and Research Data Publishing Ethics, FORCE 11. He is a scientific publishing consultant & a technical committee member of HEC Journals’ Recognition System at the Higher Education Commission, Government of Pakistan." photo: "subhani-1.jpeg" coi: 2022: https://drive.google.com/file/d/1nYQZ8h766UsNY_gWwCbRyVQ_yxdCyQN5/view?usp=sharing From 8caeab6241e681e4e70e8496d63094ce004dbec2 Mon Sep 17 00:00:00 2001 From: katrinesund <141714527+katrinesund@users.noreply.github.com> Date: Wed, 9 Oct 2024 12:51:38 +0200 Subject: [PATCH 63/80] Updated Ina's COI and bio --- cms/data/ambassadors.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cms/data/ambassadors.yml b/cms/data/ambassadors.yml index bf3c82615..8f23554ca 100644 --- a/cms/data/ambassadors.yml +++ b/cms/data/ambassadors.yml @@ -32,10 +32,11 @@ - name: Ina Smith region: Southern Africa - bio: "Ina holds a Master’s Degree from the University of Pretoria (South Africa) in Computer-Integrated Education, a Higher Education Teaching Diploma, and two degrees (BBibl and BBibl Honors) in Library and Information Science. She is Planning Manager at the Academy of Science of South Africa, and has vast experience of Open Access in general, scholarly research activities, repositories, and Open Access journal management and publishing." + bio: "Ina holds a Master’s Degree from the University of Pretoria (South Africa) in Computer-Integrated Education, a Higher Education Teaching Diploma, and two degrees (BBibl and BBibl Honors) in Library and Information Science. She is Planning Manager at the Academy of Science of South Africa, and has vast experience of Open Science incl. Open Access, scholarly research activities, repositories, and Open Access journal management and publishing." photo: "ina-smith.png" coi: 2022: https://drive.google.com/file/d/1bnxk0NzQDU5QzdUdaFWD85A3lEA4Iu8a/view?usp=sharing + 2024: https://drive.google.com/file/d/1cjkOAuYhyt_8m5EkKxoDe4A596emb9Wb/view?usp=sharing - name: Ivonne Lujano region: Latin America From b5af79903077475f62653ba83be1fde1aca52e27 Mon Sep 17 00:00:00 2001 From: Dom Mitchell Date: Wed, 9 Oct 2024 14:39:22 +0200 Subject: [PATCH 64/80] Updates to job title and job application data --- cms/pages/legal/privacy.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cms/pages/legal/privacy.md b/cms/pages/legal/privacy.md index e0ceca6d4..c62a81e59 100644 --- a/cms/pages/legal/privacy.md +++ b/cms/pages/legal/privacy.md @@ -18,7 +18,7 @@ The Directory of Open Access Journals ("DOAJ"), managed independently by [IS4OA] ### Who to contact at DOAJ about protecting your privacy -The DOAJ Operations Manager, Dominic Mitchell, has assumed responsibility for the DOAJ data policy and implementing the changes required by the GDPR, which came into effect on 25th May 2018. If you have any questions or concerns about the information in this Notice or any other question about how DOAJ protects or uses your data, please [email Dominic](mailto:dominic@doaj.org). Alternatively, you can write to him: Dominic Mitchell, IS4OA Denmark, c/o Joanna Ball, Bøgevej 33, DK-4000 Roskilde, DENMARK. +The DOAJ Deputy Director, Dominic Mitchell, has assumed responsibility for the DOAJ data policy and implementing the changes required by the GDPR, which came into effect on 25th May 2018. If you have any questions or concerns about the information in this Notice or any other question about how DOAJ protects or uses your data, please [email Dominic](mailto:dominic@doaj.org). Alternatively, you can write to him: Dominic Mitchell, IS4OA Denmark, c/o Joanna Ball, Bøgevej 33, DK-4000 Roskilde, DENMARK. ### The policy @@ -161,7 +161,7 @@ Users may request at any time that we delete all their personal data from our sy #### 6c Volunteer applications -DOAJ stores the personal data of applicants in a Google Sheet until we assess if a person is a suitable candidate. Old applications are struck through and archived in a secure Google Drive folder only accessible by the Operations Manager. Old applications are deleted after seven years. Volunteer applicants may request at any time that we delete all their personal data from Google Drive by submitting a Subject Access Request (SAR) to us\*\*. +DOAJ stores the personal data of applicants in a Google Sheet until we have finished assessing the candidates. Old applications are struck through and archived in a secure Google Drive folder only accessible by the Executive Team. Old applications are deleted after two months. Volunteer applicants may request at any time that we delete all their personal data from Google Drive by submitting a Subject Access Request (SAR) to us\*\*. \*\*see section 9 below. @@ -177,7 +177,7 @@ Individuals may request that DOAJ delete their user account from the DOAJ Admin #### 8b How to request that all personal data be deleted -To request that DOAJ delete all of the personal data we hold about you, please email the Operations Manager, Dominic Mitchell: [dominic@doaj.org](mailto:dominic@doaj.org). +To request that DOAJ delete all of the personal data we hold about you, please email the Deputy Director, Dominic Mitchell: [dominic@doaj.org](mailto:dominic@doaj.org). ### 9) Subject access request (SAR) @@ -187,7 +187,7 @@ An SAR is the name given to the process by which a user can request to know deta #### 9b How to make a SAR to DOAJ -You may submit a SAR to DOAJ by contacting the Operations Manager, Dominic Mitchell, directly: [dominic@doaj.org](mailto:dominic@doaj.org). Any request in writing will be considered valid, whatever the format. +You may submit a SAR to DOAJ by contacting the Deputy Director, Dominic Mitchell, directly: [dominic@doaj.org](mailto:dominic@doaj.org). Any request in writing will be considered valid, whatever the format. ### 10) Withdrawing consent @@ -197,4 +197,4 @@ You may also explicitly indicate that you do not want DOAJ to use your email add ### 11) How to complain -If you need to complain about how DOAJ has handled an SAR or your request to withdraw consent, or any other aspect related to the information detailed in this Privacy Information Notice, please send an email to the DOAJ Operations Manager, Dominic Mitchell: [dominic@doaj.org](mailto:dominic@doaj.org) +If you need to complain about how DOAJ has handled an SAR or your request to withdraw consent, or any other aspect related to the information detailed in this Privacy Information Notice, please send an email to the DOAJ Deputy Director, Dominic Mitchell: [dominic@doaj.org](mailto:dominic@doaj.org) From d9fae9233a342d1607a8995c3292a9c25def31ef Mon Sep 17 00:00:00 2001 From: Dom Mitchell Date: Thu, 10 Oct 2024 14:11:50 +0200 Subject: [PATCH 65/80] Typos --- cms/data/notifications.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/data/notifications.yml b/cms/data/notifications.yml index 082d88203..80925ab47 100644 --- a/cms/data/notifications.yml +++ b/cms/data/notifications.yml @@ -42,7 +42,7 @@ application:publisher:accepted:notify: You may access the journal record from your Publisher dashboard: [{publisher_dashboard_url}]({publisher_dashboard_url}) using your DOAJ account ID or email address, and password. - It is your rresponsibility to keep the information about your journal in DOAJ up to date. When there are changes or updates needed please [submit an Update Request](https://doaj.org/publisher/journal) from your Publisher dashboard promptly. Please be aware that failure to do this may result in removal of your journal from DOAJ. + It is your responsibility to keep the information about your journal in DOAJ up to date. When there are changes or updates needed please [submit an Update Request](https://doaj.org/publisher/journal) from your Publisher dashboard promptly. Please be aware that failure to do this may result in removal of your journal from DOAJ. To increase the visibility, distribution and usage of your journal content, we encourage you to upload article metadata for this journal to DOAJ as soon as possible. @@ -96,7 +96,7 @@ application:publisher:quickreject:notify: application:publisher:revision:notify: long: | - The update which you submitted for **{application_title}** on {date_applied} requires some revisions before it can be accepted. The Managing Editor reviewing your update will contact you to explain the changes that are needed. + The update you submitted for **{application_title}** on {date_applied} requires some revisions before it can be accepted. The Managing Editor reviewing your update will contact you to explain the changes that are needed. short: Your update request ({issns}) needs revisions From e455638115aec4b97a68e7ff20a021d690e1c909 Mon Sep 17 00:00:00 2001 From: Dom Mitchell Date: Fri, 11 Oct 2024 12:57:31 +0200 Subject: [PATCH 66/80] Sidenav Active sidenav helps with ambassador pics' size --- cms/pages/about/ambassadors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/pages/about/ambassadors.md b/cms/pages/about/ambassadors.md index 882596f77..a584b0ab9 100644 --- a/cms/pages/about/ambassadors.md +++ b/cms/pages/about/ambassadors.md @@ -1,5 +1,5 @@ --- -layout: no-sidenav +layout: sidenav include: /public/includes/_ambassadors.html title: Ambassadors section: About From 525172b02d46d837354a74f0facf0b8e7d92f745 Mon Sep 17 00:00:00 2001 From: katrinesund <141714527+katrinesund@users.noreply.github.com> Date: Mon, 14 Oct 2024 08:28:26 +0200 Subject: [PATCH 67/80] Add files via upload --- cms/assets/img/ambassadors/Kamel.jpg | Bin 0 -> 105786 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 cms/assets/img/ambassadors/Kamel.jpg diff --git a/cms/assets/img/ambassadors/Kamel.jpg b/cms/assets/img/ambassadors/Kamel.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5323d0da656a9ec4d94169b5b045c7b7921cebd1 GIT binary patch literal 105786 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

    PQ}jthX~DP6;uE5sxwXW1=xoq2 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%0Djk9n)$OY2mEi*xeEr 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 Q#@YG7$51+-Ow%O)*#_gU$p8QV literal 0 HcmV?d00001 From 86e40ae49fba06ad1170d36510ac20b2b1440fc0 Mon Sep 17 00:00:00 2001 From: katrinesund <141714527+katrinesund@users.noreply.github.com> Date: Mon, 14 Oct 2024 08:30:22 +0200 Subject: [PATCH 68/80] Added Kamel --- cms/data/ambassadors.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cms/data/ambassadors.yml b/cms/data/ambassadors.yml index 8f23554ca..43db8a5c9 100644 --- a/cms/data/ambassadors.yml +++ b/cms/data/ambassadors.yml @@ -51,6 +51,13 @@ photo: "mahmoud-new.jpg" coi: 2022: https://drive.google.com/file/d/10s7B0WeTPqpaafhThv-i8q03uIFaAAue/view?usp=sharing + +- name: Kamel Belhamel + region: North Africa and Middle East + bio: "Kamel Belhamel is a Full Professor of Chemistry at the University of Bejaia, Algeria (ORCID). He is the DOAJ Ambassador and Managing Editor for North Africa and the Middle East. His scientific activity is focused on scholarly communications, new developments in academic publishing and the chemistry of Natural Products. Kamel has strongly advocated for open access and open science. He is the Creative Commons Representative to the Global Network Council and editor-in-chief of the Algerian Journal of Natural Products. He is father to three daughters and likes travelling, healthy local foods and home-made snacks." + photo: "Kamel.jpg" + coi: + 2024: https://drive.google.com/file/d/1yvemD_TWgNRy7RYA0JFiVME8wxVrkk8m/view?usp=sharing - name: Melkamu Beyene region: East Africa From 9a0ce05914f157ed082d31ba0aa46b5d3860da5d Mon Sep 17 00:00:00 2001 From: katrinesund <141714527+katrinesund@users.noreply.github.com> Date: Mon, 14 Oct 2024 08:33:07 +0200 Subject: [PATCH 69/80] Updated Kamel COI link --- cms/data/ambassadors.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/data/ambassadors.yml b/cms/data/ambassadors.yml index 43db8a5c9..4c235ad81 100644 --- a/cms/data/ambassadors.yml +++ b/cms/data/ambassadors.yml @@ -57,7 +57,7 @@ bio: "Kamel Belhamel is a Full Professor of Chemistry at the University of Bejaia, Algeria (ORCID). He is the DOAJ Ambassador and Managing Editor for North Africa and the Middle East. His scientific activity is focused on scholarly communications, new developments in academic publishing and the chemistry of Natural Products. Kamel has strongly advocated for open access and open science. He is the Creative Commons Representative to the Global Network Council and editor-in-chief of the Algerian Journal of Natural Products. He is father to three daughters and likes travelling, healthy local foods and home-made snacks." photo: "Kamel.jpg" coi: - 2024: https://drive.google.com/file/d/1yvemD_TWgNRy7RYA0JFiVME8wxVrkk8m/view?usp=sharing + 2024: https://drive.google.com/file/d/1UTUIqeAx-4Q3_gC4sZ7MG3pa0Z3hOsKP/view?usp=sharing - name: Melkamu Beyene region: East Africa From f14efea804c014e331459f59dad4bdbe7a21852d Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Wed, 23 Oct 2024 12:16:25 +0100 Subject: [PATCH 70/80] add explicit cast to string for error messages --- portality/bll/services/application.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/portality/bll/services/application.py b/portality/bll/services/application.py index 3f46cc14c..bf25e82ee 100644 --- a/portality/bll/services/application.py +++ b/portality/bll/services/application.py @@ -731,14 +731,14 @@ def validate_update_csv(self, file_path, account: models.Account): was = [v for q, v in journal_questions if q == question][0] if isinstance(v[0], dict): for sk, sv in v[0].items(): - validation.value(validation.ERROR, row_ix, pos, ". ".join(sv), + validation.value(validation.ERROR, row_ix, pos, ". ".join([str(x) for x in sv]), was=was, now=now) elif isinstance(v[0], list): # If we have a list, we must go a level deeper - validation.value(validation.ERROR, row_ix, pos, ". ".join(v[0]), + validation.value(validation.ERROR, row_ix, pos, ". ".join([str(x) for x in v[0]]), was=was, now=now) else: - validation.value(validation.ERROR, row_ix, pos, ". ".join(v), + validation.value(validation.ERROR, row_ix, pos, ". ".join([str(x) for x in v]), was=was, now=now) return validation From d57f619c74f04ef8adde354ed4db6929a69d64d1 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Thu, 24 Oct 2024 13:32:08 +0100 Subject: [PATCH 71/80] exclude on-hold items from all rules, not just maned rules, as these sometimes cause maneds to see applications under 'new application' which are also 'on hold' --- portality/bll/services/todo.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/portality/bll/services/todo.py b/portality/bll/services/todo.py index cd3723bb3..25a62a2c7 100644 --- a/portality/bll/services/todo.py +++ b/portality/bll/services/todo.py @@ -336,7 +336,8 @@ def editor_stalled(cls, groups, size): TodoQuery.status([ constants.APPLICATION_STATUS_ACCEPTED, constants.APPLICATION_STATUS_REJECTED, - constants.APPLICATION_STATUS_READY + constants.APPLICATION_STATUS_READY, + constants.APPLICATION_STATUS_ON_HOLD ]) ], sort=sort_date, @@ -357,7 +358,8 @@ def editor_follow_up_old(cls, groups, size): TodoQuery.status([ constants.APPLICATION_STATUS_ACCEPTED, constants.APPLICATION_STATUS_REJECTED, - constants.APPLICATION_STATUS_READY + constants.APPLICATION_STATUS_READY, + constants.APPLICATION_STATUS_ON_HOLD ]) ], sort=sort_date, @@ -410,7 +412,8 @@ def associate_stalled(cls, acc_id, size): constants.APPLICATION_STATUS_ACCEPTED, constants.APPLICATION_STATUS_REJECTED, constants.APPLICATION_STATUS_READY, - constants.APPLICATION_STATUS_COMPLETED + constants.APPLICATION_STATUS_COMPLETED, + constants.APPLICATION_STATUS_ON_HOLD ]) ], sort=sort_field, @@ -432,7 +435,8 @@ def associate_follow_up_old(cls, acc_id, size): constants.APPLICATION_STATUS_ACCEPTED, constants.APPLICATION_STATUS_REJECTED, constants.APPLICATION_STATUS_READY, - constants.APPLICATION_STATUS_COMPLETED + constants.APPLICATION_STATUS_COMPLETED, + constants.APPLICATION_STATUS_ON_HOLD ]) ], sort=sort_field, @@ -467,7 +471,8 @@ def associate_all_applications(cls, acc_id, size): constants.APPLICATION_STATUS_ACCEPTED, constants.APPLICATION_STATUS_REJECTED, constants.APPLICATION_STATUS_READY, - constants.APPLICATION_STATUS_COMPLETED + constants.APPLICATION_STATUS_COMPLETED, + constants.APPLICATION_STATUS_ON_HOLD ]) ], sort=sort_field, From 90186595e746a4e4c5ae78890398bfb9ce373483 Mon Sep 17 00:00:00 2001 From: Steven Eardley Date: Tue, 29 Oct 2024 15:05:44 +0000 Subject: [PATCH 72/80] Fixed some event consumers for their updated notifications --- cms/data/notifications.yml | 4 ++-- .../events/consumers/application_assed_inprogress_notify.py | 4 +++- portality/events/consumers/application_maned_ready_notify.py | 3 ++- .../consumers/application_publisher_inprogress_notify.py | 2 +- portality/events/consumers/bg_job_finished_notify.py | 5 ++++- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/cms/data/notifications.yml b/cms/data/notifications.yml index 80925ab47..f514d7e4e 100644 --- a/cms/data/notifications.yml +++ b/cms/data/notifications.yml @@ -46,7 +46,7 @@ application:publisher:accepted:notify: To increase the visibility, distribution and usage of your journal content, we encourage you to upload article metadata for this journal to DOAJ as soon as possible. - [How to upload article metadata](https://doaj.org/docs/faq/#uploading-article-metadata) + [How to upload article metadata]({faq_url}#uploading-article-metadata) We are delighted to welcome this journal into DOAJ. Do not hesitate to contact us at [helpdesk@doaj.org](mailto:helpdesk@doaj.org) if you have any questions. short: @@ -80,7 +80,7 @@ application:publisher:inprogress:notify: long: | Your submission for **{title}** submitted on {date_applied} is now being reviewed by an Associate Editor. - The Associate Editor ([{volunteers}]({volunteers})) may contact you by email with questions. They may not be using a doaj.org email address. These emails can end up in your Spam folder so please check your Spam folder regularly. + The Associate Editor ([{volunteers_url}]({volunteers_url})) may contact you by email with questions. They may not be using a doaj.org email address. These emails can end up in your Spam folder so please check your Spam folder regularly. short: Your submission ({issns}) is under review diff --git a/portality/events/consumers/application_assed_inprogress_notify.py b/portality/events/consumers/application_assed_inprogress_notify.py index 68520dd6d..d79c32c32 100644 --- a/portality/events/consumers/application_assed_inprogress_notify.py +++ b/portality/events/consumers/application_assed_inprogress_notify.py @@ -32,7 +32,9 @@ def consume(cls, event): notification.who = application.editor notification.created_by = cls.ID notification.classification = constants.NOTIFICATION_CLASSIFICATION_STATUS_CHANGE - notification.long = svc.long_notification(cls.ID).format(application_title=application.bibjson().title) + notification.long = svc.long_notification(cls.ID).format( + application_title=application.bibjson().title) + notification.short = svc.short_notification(cls.ID).format( issns=application.bibjson().issns_as_text() ) diff --git a/portality/events/consumers/application_maned_ready_notify.py b/portality/events/consumers/application_maned_ready_notify.py index ac4b70d9c..00723a83e 100644 --- a/portality/events/consumers/application_maned_ready_notify.py +++ b/portality/events/consumers/application_maned_ready_notify.py @@ -44,7 +44,8 @@ def consume(cls, event): notification.classification = constants.NOTIFICATION_CLASSIFICATION_STATUS_CHANGE notification.long = svc.long_notification(cls.ID).format( application_title=application.bibjson().title, - editor=editor + editor=editor, + group_name=application.editor_group ) notification.short = svc.short_notification(cls.ID).format( issns=application.bibjson().issns_as_text() diff --git a/portality/events/consumers/application_publisher_inprogress_notify.py b/portality/events/consumers/application_publisher_inprogress_notify.py index 04bc7b26b..09203f21b 100644 --- a/portality/events/consumers/application_publisher_inprogress_notify.py +++ b/portality/events/consumers/application_publisher_inprogress_notify.py @@ -45,7 +45,7 @@ def consume(cls, event): notification.long = svc.long_notification(cls.ID).format( title=title, date_applied=date_applied, - volunteers=volunteers + volunteers_url=volunteers ) notification.short = svc.short_notification(cls.ID).format( issns=application.bibjson().issns_as_text() diff --git a/portality/events/consumers/bg_job_finished_notify.py b/portality/events/consumers/bg_job_finished_notify.py index 32535eaf0..a6b72a58f 100644 --- a/portality/events/consumers/bg_job_finished_notify.py +++ b/portality/events/consumers/bg_job_finished_notify.py @@ -44,7 +44,10 @@ def consume(cls, event): notification.who = acc.id notification.created_by = cls.ID notification.classification = constants.NOTIFICATION_CLASSIFICATION_FINISHED - notification.long = svc.long_notification(cls.ID).format(job_id=job.id, action=job.action, status=job.status) + notification.long = svc.long_notification(cls.ID).format( + job_id=job.id, + action=job.action, + status=job.status) notification.short = svc.short_notification(cls.ID) notification.action = url From b8dd4d24021c2f7572083a18d5873e7e9a5633c2 Mon Sep 17 00:00:00 2001 From: Steven Eardley Date: Tue, 5 Nov 2024 12:22:35 +0000 Subject: [PATCH 73/80] reinstate CI on release branches --- .circleci/config.yml | 1 - portality/settings.py | 2 +- setup.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f08608285..a0a154163 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -74,5 +74,4 @@ workflows: filters: branches: ignore: - - /release\/.*/ - static_pages diff --git a/portality/settings.py b/portality/settings.py index 201d65edf..213961706 100644 --- a/portality/settings.py +++ b/portality/settings.py @@ -9,7 +9,7 @@ # Application Version information # ~~->API:Feature~~ -DOAJ_VERSION = "7.0.0" +DOAJ_VERSION = "7.0.1" API_VERSION = "4.0.0" ###################################### diff --git a/setup.py b/setup.py index 7682c79b2..8fbc87f66 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='doaj', - version='7.0.0', + version='7.0.1', packages=find_packages(), install_requires=[ "awscli==1.20.50", From ebc40714f1c7b4cb20e9c4e37cb8b743d48b419c Mon Sep 17 00:00:00 2001 From: Steven Eardley Date: Tue, 5 Nov 2024 14:53:20 +0000 Subject: [PATCH 74/80] Fix leaky test for OAI-PMH endpoint --- doajtest/unit/test_oaipmh.py | 92 +++++++++++++++++++++++------------- portality/models/oaipmh.py | 15 +++--- 2 files changed, 68 insertions(+), 39 deletions(-) diff --git a/doajtest/unit/test_oaipmh.py b/doajtest/unit/test_oaipmh.py index d5a3291de..f74569ad6 100644 --- a/doajtest/unit/test_oaipmh.py +++ b/doajtest/unit/test_oaipmh.py @@ -18,10 +18,15 @@ from doajtest.helpers import with_es + class TestClient(DoajTestCase): @classmethod def setUpClass(cls): app.testing = True + + # Preserve default value of OAI record page size + cls.DEFAULT_OAIPMH_LIST_IDENTIFIERS_PAGE_SIZE = app.config.get("OAIPMH_LIST_IDENTIFIERS_PAGE_SIZE", 25) + super(TestClient, cls).setUpClass() def setUp(self): @@ -31,7 +36,11 @@ def setUp(self): self.oai_ns = {'oai': 'http://www.openarchives.org/OAI/2.0/', 'oai_dc': 'http://www.openarchives.org/OAI/2.0/oai_dc/', 'dc': 'http://purl.org/dc/elements/1.1/', - 'xsi' : 'http://www.w3.org/2001/XMLSchema-instance'} + 'xsi': 'http://www.w3.org/2001/XMLSchema-instance'} + + def tearDown(self): + app.config['OAIPMH_LIST_IDENTIFIERS_PAGE_SIZE'] = self.DEFAULT_OAIPMH_LIST_IDENTIFIERS_PAGE_SIZE + super(TestClient, self).tearDown() def test_01_oai_ListMetadataFormats(self): """ Check we get the correct response from the OAI endpoint ListMetdataFormats request""" @@ -41,7 +50,8 @@ def test_01_oai_ListMetadataFormats(self): assert resp.status_code == 200 t = etree.fromstring(resp.data) - assert t.xpath('/oai:OAI-PMH/oai:ListMetadataFormats/oai:metadataFormat/oai:metadataPrefix', namespaces=self.oai_ns)[0].text == 'oai_dc' + assert t.xpath('/oai:OAI-PMH/oai:ListMetadataFormats/oai:metadataFormat/oai:metadataPrefix', + namespaces=self.oai_ns)[0].text == 'oai_dc' def test_02_oai_journals(self): """test if the OAI-PMH journal feed returns records and only displays journals accepted in DOAJ, marking withdrawn ones as deleted""" @@ -81,7 +91,9 @@ def test_02_oai_journals(self): assert seen_deleted assert seen_public - resp = t_client.get(url_for('oaipmh.oaipmh', verb='GetRecord', metadataPrefix='oai_dc') + '&identifier={0}'.format(public_id)) + resp = t_client.get( + url_for('oaipmh.oaipmh', verb='GetRecord', metadataPrefix='oai_dc') + '&identifier={0}'.format( + public_id)) assert resp.status_code == 200 t = etree.fromstring(resp.data) @@ -123,7 +135,7 @@ def test_03_oai_resumption_token(self): with self.app_test.test_client() as t_client: resp = t_client.get(url_for('oaipmh.oaipmh', verb='ListIdentifiers', metadataPrefix='oai_dc')) t = etree.fromstring(resp.data) - #print etree.tostring(t, pretty_print=True) + # print etree.tostring(t, pretty_print=True) rt = t.xpath('//oai:resumptionToken', namespaces=self.oai_ns)[0] assert rt.get('completeListSize') == '5' assert rt.get('cursor') == '2' @@ -131,7 +143,7 @@ def test_03_oai_resumption_token(self): # Get the next result resp2 = t_client.get(url_for('oaipmh.oaipmh', verb='ListIdentifiers', resumptionToken=rt.text)) t = etree.fromstring(resp2.data) - #print etree.tostring(t, pretty_print=True) + # print etree.tostring(t, pretty_print=True) rt2 = t.xpath('//oai:resumptionToken', namespaces=self.oai_ns)[0] assert rt2.get('completeListSize') == '5' assert rt2.get('cursor') == '4' @@ -139,17 +151,18 @@ def test_03_oai_resumption_token(self): # And the final result - check we get an empty resumptionToken resp3 = t_client.get(url_for('oaipmh.oaipmh', verb='ListIdentifiers', resumptionToken=rt2.text)) t = etree.fromstring(resp3.data) - #print etree.tostring(t, pretty_print=True) + # print etree.tostring(t, pretty_print=True) rt3 = t.xpath('//oai:resumptionToken', namespaces=self.oai_ns)[0] assert rt3.get('completeListSize') == '5' assert rt3.get('cursor') == '5' assert rt3.text is None # We should get an error if we request again with an empty resumptionToken - resp4 = t_client.get(url_for('oaipmh.oaipmh', verb='ListIdentifiers') + '&resumptionToken={0}'.format(rt3.text)) - assert resp4.status_code == 200 # fixme: should this be a real error code? + resp4 = t_client.get( + url_for('oaipmh.oaipmh', verb='ListIdentifiers') + '&resumptionToken={0}'.format(rt3.text)) + assert resp4.status_code == 200 # fixme: should this be a real error code? t = etree.fromstring(resp4.data) - #print etree.tostring(t, pretty_print=True) + # print etree.tostring(t, pretty_print=True) err = t.xpath('//oai:error', namespaces=self.oai_ns)[0] assert 'the resumptionToken argument is invalid or expired' in err.text @@ -171,9 +184,11 @@ def test_04_oai_changing_index(self): yesterday = (dates.now() - timedelta(days=1)).strftime(FMT_DATE_STD) with self.app_test.test_request_context(): with self.app_test.test_client() as t_client: - resp = t_client.get(url_for('oaipmh.oaipmh', verb='ListRecords', metadataPrefix='oai_dc') + '&from={0}'.format(yesterday)) + resp = t_client.get( + url_for('oaipmh.oaipmh', verb='ListRecords', metadataPrefix='oai_dc') + '&from={0}'.format( + yesterday)) t = etree.fromstring(resp.data) - #print etree.tostring(t, pretty_print=True) + # print etree.tostring(t, pretty_print=True) rt = t.xpath('//oai:resumptionToken', namespaces=self.oai_ns)[0] assert rt.get('completeListSize') == '3' assert rt.get('cursor') == '2' @@ -187,15 +202,17 @@ def test_04_oai_changing_index(self): resp2 = t_client.get('/oai?verb=ListRecords&resumptionToken={0}'.format(rt.text)) resp2 = t_client.get(url_for('oaipmh.oaipmh', verb='ListRecords', resumptionToken=rt.text)) t = etree.fromstring(resp2.data) - #print etree.tostring(t, pretty_print=True) + # print etree.tostring(t, pretty_print=True) rt2 = t.xpath('//oai:resumptionToken', namespaces=self.oai_ns)[0] assert rt2.get('completeListSize') == '3' assert rt2.get('cursor') == '3' # Start a new request - we should see the new journal - resp3 = t_client.get(url_for('oaipmh.oaipmh', verb='ListRecords', metadataPrefix='oai_dc') + '&from={0}'.format(yesterday)) + resp3 = t_client.get( + url_for('oaipmh.oaipmh', verb='ListRecords', metadataPrefix='oai_dc') + '&from={0}'.format( + yesterday)) t = etree.fromstring(resp3.data) - #print etree.tostring(t, pretty_print=True) + # print etree.tostring(t, pretty_print=True) rt = t.xpath('//oai:resumptionToken', namespaces=self.oai_ns)[0] assert rt.get('completeListSize') == '4' @@ -227,9 +244,11 @@ def test_05_date_ranges(self): with self.app_test.test_request_context(): with self.app_test.test_client() as t_client: # Request OAI journals since yesterday (looking for today's results only) - resp = t_client.get(url_for('oaipmh.oaipmh', verb='ListRecords', metadataPrefix='oai_dc') + '&from={0}'.format(yesterday.strftime(FMT_DATE_STD))) + resp = t_client.get( + url_for('oaipmh.oaipmh', verb='ListRecords', metadataPrefix='oai_dc') + '&from={0}'.format( + yesterday.strftime(FMT_DATE_STD))) t = etree.fromstring(resp.data) - #print etree.tostring(t, pretty_print=True) + # print etree.tostring(t, pretty_print=True) rt = t.xpath('//oai:resumptionToken', namespaces=self.oai_ns)[0] assert rt.get('completeListSize') == '2' assert rt.get('cursor') == '1' @@ -238,10 +257,11 @@ def test_05_date_ranges(self): assert title.text in [journals[2]['bibjson']['title'], journals[3]['bibjson']['title']] # Request OAI journals from 3 days ago to yesterday (expecting the 2 days ago results) - resp = t_client.get(url_for('oaipmh.oaipmh', verb='ListRecords', metadataPrefix='oai_dc') + '&from={0}&until={1}'.format( + resp = t_client.get(url_for('oaipmh.oaipmh', verb='ListRecords', + metadataPrefix='oai_dc') + '&from={0}&until={1}'.format( two_days_before_yesterday.strftime(FMT_DATE_STD), yesterday.strftime(FMT_DATE_STD))) t = etree.fromstring(resp.data) - #print etree.tostring(t, pretty_print=True) + # print etree.tostring(t, pretty_print=True) rt = t.xpath('//oai:resumptionToken', namespaces=self.oai_ns)[0] assert rt.get('completeListSize') == '2' assert rt.get('cursor') == '1' @@ -262,7 +282,8 @@ def test_06_identify(self): t = etree.fromstring(resp.data) records = t.xpath('/oai:OAI-PMH/oai:Identify', namespaces=self.oai_ns) assert len(records) == 1 - assert records[0].xpath('//oai:repositoryName', namespaces=self.oai_ns)[0].text == 'Directory of Open Access Journals' + assert records[0].xpath('//oai:repositoryName', namespaces=self.oai_ns)[ + 0].text == 'Directory of Open Access Journals' assert records[0].xpath('//oai:adminEmail', namespaces=self.oai_ns)[0].text == 'helpdesk+oai@doaj.org' assert records[0].xpath('//oai:granularity', namespaces=self.oai_ns)[0].text == 'YYYY-MM-DDThh:mm:ssZ' @@ -278,15 +299,17 @@ def test_07_bad_verb(self): assert resp.status_code == 200 t = etree.fromstring(resp.data) records = t.xpath('/oai:OAI-PMH', namespaces=self.oai_ns) - assert records[0].xpath('//oai:error', namespaces=self.oai_ns)[0].text == 'Value of the verb argument is not a legal OAI-PMH verb, the verb argument is missing, or the verb argument is repeated.' + assert records[0].xpath('//oai:error', namespaces=self.oai_ns)[ + 0].text == 'Value of the verb argument is not a legal OAI-PMH verb, the verb argument is missing, or the verb argument is repeated.' assert records[0].xpath('//oai:error', namespaces=self.oai_ns)[0].get("code") == 'badVerb' - #invalid verb + # invalid verb resp = t_client.get(url_for('oaipmh.oaipmh', verb='InvalidVerb', metadataPrefix='oai_dc')) assert resp.status_code == 200 t = etree.fromstring(resp.data) records = t.xpath('/oai:OAI-PMH', namespaces=self.oai_ns) - assert records[0].xpath('//oai:error', namespaces=self.oai_ns)[0].text == 'Value of the verb argument is not a legal OAI-PMH verb, the verb argument is missing, or the verb argument is repeated.' + assert records[0].xpath('//oai:error', namespaces=self.oai_ns)[ + 0].text == 'Value of the verb argument is not a legal OAI-PMH verb, the verb argument is missing, or the verb argument is repeated.' assert records[0].xpath('//oai:error', namespaces=self.oai_ns)[0].get("code") == 'badVerb' def test_08_list_sets(self): @@ -311,14 +334,14 @@ def test_08_list_sets(self): # check that we can retrieve a record with one of those sets with self.app_test.test_client() as t_client: - resp = t_client.get(url_for('oaipmh.oaipmh', verb='ListRecords', metadataPrefix='oai_dc', set=set0[0].text)) + resp = t_client.get( + url_for('oaipmh.oaipmh', verb='ListRecords', metadataPrefix='oai_dc', set=set0[0].text)) assert resp.status_code == 200 t = etree.fromstring(resp.data) records = t.xpath('/oai:OAI-PMH/oai:ListRecords', namespaces=self.oai_ns) results = records[0].getchildren() assert len(results) == 1 - def test_09_article(self): """test if the OAI-PMH article feed returns records and only displays articles accepted in DOAJ, showing the others as deleted""" article_source = ArticleFixtureFactory.make_article_source(eissn='1234-1234', pissn='5678-5678,', in_doaj=False) @@ -346,7 +369,8 @@ def test_09_article(self): with self.app_test.test_request_context(): with self.app_test.test_client() as t_client: - resp = t_client.get(url_for('oaipmh.oaipmh', specified='article', verb='ListRecords', metadataPrefix='oai_dc')) + resp = t_client.get( + url_for('oaipmh.oaipmh', specified='article', verb='ListRecords', metadataPrefix='oai_dc')) assert resp.status_code == 200 t = etree.fromstring(resp.data) @@ -378,7 +402,8 @@ def test_09_article(self): assert seen_deleted == 2 assert seen_public - resp = t_client.get(url_for('oaipmh.oaipmh', specified='article', verb='GetRecord', metadataPrefix='oai_dc') + '&identifier=' + public_id) + resp = t_client.get(url_for('oaipmh.oaipmh', specified='article', verb='GetRecord', + metadataPrefix='oai_dc') + '&identifier=' + public_id) assert resp.status_code == 200 t = etree.fromstring(resp.data) @@ -402,7 +427,8 @@ def test_10_subjects(self): with self.app_test.test_request_context(): # Check whether the journal is found for its specific set: Veterinary Medicine (TENDOlZldGVyaW5hcnkgbWVkaWNpbmU) with self.app_test.test_client() as t_client: - resp = t_client.get(url_for('oaipmh.oaipmh', verb='ListRecords', metadataPrefix='oai_dc', set='TENDOlZldGVyaW5hcnkgbWVkaWNpbmU~')) + resp = t_client.get(url_for('oaipmh.oaipmh', verb='ListRecords', metadataPrefix='oai_dc', + set='TENDOlZldGVyaW5hcnkgbWVkaWNpbmU~')) assert resp.status_code == 200 t = etree.fromstring(resp.data) @@ -414,7 +440,7 @@ def test_10_subjects(self): # Check we have the correct journal assert records[0].xpath('//dc:title', namespaces=self.oai_ns)[0].text == j_public.bibjson().title - #check we have expected subjects (Veterinary Medicine but not Agriculture) + # check we have expected subjects (Veterinary Medicine but not Agriculture) subjects = records[0].xpath('//dc:subject', namespaces=self.oai_ns) assert len(subjects) != 0 @@ -426,7 +452,8 @@ def test_10_subjects(self): with self.app_test.test_request_context(): # Check whether the journal is found for more general set: Agriculture (TENDOkFncmljdWx0dXJl) with self.app_test.test_client() as t_client: - resp = t_client.get(url_for('oaipmh.oaipmh', verb='ListRecords', metadataPrefix='oai_dc', set='TENDOkFncmljdWx0dXJl~')) + resp = t_client.get( + url_for('oaipmh.oaipmh', verb='ListRecords', metadataPrefix='oai_dc', set='TENDOkFncmljdWx0dXJl~')) assert resp.status_code == 200 t = etree.fromstring(resp.data) @@ -457,7 +484,8 @@ def test_11_oai_dc_attr(self): with self.app_test.test_request_context(): with self.app_test.test_client() as t_client: - resp = t_client.get(url_for('oaipmh.oaipmh', specified='article', verb='ListRecords', metadataPrefix='oai_dc')) + resp = t_client.get( + url_for('oaipmh.oaipmh', specified='article', verb='ListRecords', metadataPrefix='oai_dc')) assert resp.status_code == 200 t = etree.fromstring(resp.data) @@ -483,7 +511,7 @@ def test_11_oai_dc_attr(self): t = etree.fromstring(resp.data) # find metadata element of our record elem = t.xpath('/oai:OAI-PMH/oai:ListRecords/oai:record/oai:metadata', namespaces=self.oai_ns) - #metadata element should have only one child, "dc" with correct nsmap + # metadata element should have only one child, "dc" with correct nsmap oai_dc = elem[0].getchildren() assert len(oai_dc) == 1 assert oai_dc[0].tag == "{%s}" % self.oai_ns["oai_dc"] + "dc" @@ -499,4 +527,4 @@ def test_decode_resumption_token__fail(self): def test_decode_resumption_token(self): params = decode_resumption_token(base64.urlsafe_b64encode(b'{"m":1}').decode('utf-8')) - assert params == {"metadata_prefix": 1} \ No newline at end of file + assert params == {"metadata_prefix": 1} diff --git a/portality/models/oaipmh.py b/portality/models/oaipmh.py index 113c6076e..e7c050fe6 100644 --- a/portality/models/oaipmh.py +++ b/portality/models/oaipmh.py @@ -3,17 +3,18 @@ from portality.models import Journal, Article, ArticleTombstone from portality import constants + class OAIPMHRecord(object): earliest = { "query": { "bool": { "must": [ - { "term": { "admin.in_doaj": True } } + {"term": {"admin.in_doaj": True}} ] } }, "size": 1, - "sort" : [ + "sort": [ {"last_updated": {"order": "asc"}} ] } @@ -22,7 +23,7 @@ class OAIPMHRecord(object): "query": { "bool": { "must": [ - { "term": { "admin.in_doaj": True } } + {"term": {"admin.in_doaj": True}} ] } }, @@ -31,7 +32,7 @@ class OAIPMHRecord(object): "sets": { "terms": { "field": "index.schema_subject.exact", - "order": {"_key" : "asc"}, + "order": {"_key": "asc"}, "size": 100000 } } @@ -49,9 +50,9 @@ class OAIPMHRecord(object): "size": 25 } - set_limit = {"term" : { "index.classification.exact" : "" }} - range_limit = { "range" : { "last_updated" : {"gte" : "", "lte" : ""} } } - created_sort = [{"last_updated" : {"order" : "desc"}}, {"id.exact" : "desc"}] + set_limit = {"term": {"index.classification.exact": ""}} + range_limit = {"range": {"last_updated": {"gte": "", "lte": ""}}} + created_sort = [{"last_updated": {"order": "desc"}}, {"id.exact": "desc"}] def earliest_datestamp(self): result = self.query(q=self.earliest) From 270a655a7a2a30cb6292bad6262860ce7fef20f0 Mon Sep 17 00:00:00 2001 From: Steven Eardley Date: Tue, 5 Nov 2024 20:49:17 +0000 Subject: [PATCH 75/80] add CI badge to readme --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index bb04445ec..7b21709af 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ This repository provides the software which drives the DOAJ website and the DOAJ directory. +## CI Status + +**develop** [![CircleCI](https://dl.circleci.com/status-badge/img/gh/DOAJ/doaj/tree/develop.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/DOAJ/doaj/tree/develop) +**master** [![CircleCI](https://dl.circleci.com/status-badge/img/gh/DOAJ/doaj/tree/master.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/DOAJ/doaj/tree/master) + ## Reporting issues Please feel free to use the issue tracker on https://github.com/DOAJ/doaj/issues for any bug From 74ee3a4c0b5f2488bfd0644bf14fbee0c89c521a Mon Sep 17 00:00:00 2001 From: Steven Eardley Date: Tue, 5 Nov 2024 20:51:54 +0000 Subject: [PATCH 76/80] A bit for formatting for README --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7b21709af..e8c8c4877 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ directory. ## CI Status -**develop** [![CircleCI](https://dl.circleci.com/status-badge/img/gh/DOAJ/doaj/tree/develop.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/DOAJ/doaj/tree/develop) -**master** [![CircleCI](https://dl.circleci.com/status-badge/img/gh/DOAJ/doaj/tree/master.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/DOAJ/doaj/tree/master) +**develop**   [![CircleCI](https://dl.circleci.com/status-badge/img/gh/DOAJ/doaj/tree/develop.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/DOAJ/doaj/tree/develop) + +**master**   [![CircleCI](https://dl.circleci.com/status-badge/img/gh/DOAJ/doaj/tree/master.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/DOAJ/doaj/tree/master) ## Reporting issues From 3747ce94df5c98167247b66645544650d50ca73d Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Tue, 12 Nov 2024 11:50:58 +0000 Subject: [PATCH 77/80] disable notifications requested in https://github.com/DOAJ/doajPM/issues/3974 --- doajtest/functional/make_notifications.py | 81 ++++++++++++----------- portality/bll/services/events.py | 26 ++++---- portality/forms/application_processors.py | 10 --- portality/ui/messages.py | 4 -- 4 files changed, 55 insertions(+), 66 deletions(-) diff --git a/doajtest/functional/make_notifications.py b/doajtest/functional/make_notifications.py index 3d5aeea0c..64aa044b5 100644 --- a/doajtest/functional/make_notifications.py +++ b/doajtest/functional/make_notifications.py @@ -6,6 +6,7 @@ from portality import constants from portality import models, app_email from portality.core import app +from portality.bll import DOAJ from portality.events.consumers import application_assed_assigned_notify, \ application_assed_inprogress_notify, \ application_editor_completed_notify, \ @@ -30,34 +31,36 @@ USER = "richard" -NOTIFICATIONS = [ - "application_assed_assigned_notify", - "application_assed_inprogress_notify", - "application_editor_completed_notify", - "application_editor_group_assigned_notify", - "application_editor_inprogress_notify", - "application_maned_ready_notify", - "application_publisher_accepted_notify", - "application_publisher_assigned_notify", - "application_publisher_created_notify", - "application_publisher_inprogress_notify", - "application_publisher_quickreject_notify", - "application_publisher_revision_notify", - "bg_job_finished_notify", - "journal_assed_assigned_notify", - "journal_editor_group_assigned_notify", - "update_request_publisher_accepted_notify", - "update_request_publisher_assigned_notify", - "update_request_publisher_rejected_notify", - UpdateRequestPublisherSubmittedNotify.ID, -] +NOTIFICATIONS = [ec.ID for ec in DOAJ.eventsService().EVENT_CONSUMERS] + +# NOTIFICATIONS = [ +# "application_assed_assigned_notify", +# "application_assed_inprogress_notify", +# "application_editor_completed_notify", +# "application_editor_group_assigned_notify", +# "application_editor_inprogress_notify", +# "application_maned_ready_notify", +# "application_publisher_accepted_notify", +# "application_publisher_assigned_notify", +# "application_publisher_created_notify", +# "application_publisher_inprogress_notify", +# "application_publisher_quickreject_notify", +# "application_publisher_revision_notify", +# "bg_job_finished_notify", +# "journal_assed_assigned_notify", +# "journal_editor_group_assigned_notify", +# "update_request_publisher_accepted_notify", +# "update_request_publisher_assigned_notify", +# "update_request_publisher_rejected_notify", +# UpdateRequestPublisherSubmittedNotify.ID, +# ] app.config["ENABLE_EMAIL"] = True app_email.Mail = MockMail ############################################## ## ApplicationAssedAssignedNotify -if "application_assed_assigned_notify" in NOTIFICATIONS: +if "application:assed:assigned:notify" in NOTIFICATIONS: aaan_application = ApplicationFixtureFactory.make_application_source() aaan_application["admin"]["editor"] = USER aaan_application["bibjson"]["title"] = "Application Assed Assigned Notify" @@ -71,7 +74,7 @@ ############################################## ## ApplicationAssedAssignedNotify -if "application_assed_inprogress_notify" in NOTIFICATIONS: +if "application:assed:inprogress:notify" in NOTIFICATIONS: aain_application = ApplicationFixtureFactory.make_application_source() aain_application["admin"]["editor"] = USER aain_application["bibjson"]["title"] = "Application Assed In Progress Notify" @@ -85,7 +88,7 @@ ############################################## ## ApplicationEditorCompletedNotify -if "application_editor_completed_notify" in NOTIFICATIONS: +if "application:editor:completed:notify" in NOTIFICATIONS: def editor_group_mock_pull(editor_group_id): return EditorGroup(**{ "editor": USER @@ -109,7 +112,7 @@ def editor_group_mock_pull(editor_group_id): ############################################## ## ApplicationEditorGroupAssignedNotify -if "application_editor_group_assigned_notify" in NOTIFICATIONS: +if "application:editor_group:assigned:notify" in NOTIFICATIONS: def editor_group_mock_pull(key, value): return EditorGroup(**{ "editor": USER @@ -133,7 +136,7 @@ def editor_group_mock_pull(key, value): ############################################## ## ApplicationEditorInprogressNotify -if "application_editor_inprogress_notify" in NOTIFICATIONS: +if "application:editor:inprogress:notify" in NOTIFICATIONS: def editor_group_mock_pull(editor_group_id): return EditorGroup(**{ "editor": USER @@ -157,7 +160,7 @@ def editor_group_mock_pull(editor_group_id): ############################################## ## ApplicationManedReadyNotify -if "application_maned_ready_notify" in NOTIFICATIONS: +if "application:maned:ready:notify" in NOTIFICATIONS: def editor_group_mock_pull(key, value): return EditorGroup(**{ "maned": USER @@ -181,7 +184,7 @@ def editor_group_mock_pull(key, value): ############################################## ## ApplicationPublisherAcceptedNotify -if "application_publisher_accepted_notify" in NOTIFICATIONS: +if "application:publisher:accepted:notify" in NOTIFICATIONS: application = ApplicationFixtureFactory.make_application_source() application["admin"]["owner"] = USER application["bibjson"]["title"] = "Application Publisher Accepted Notify" @@ -195,7 +198,7 @@ def editor_group_mock_pull(key, value): ############################################## ## ApplicationPublisherAssignedNotify -if "application_publisher_assigned_notify" in NOTIFICATIONS: +if "application:publisher:assigned:notify" in NOTIFICATIONS: application = ApplicationFixtureFactory.make_application_source() application["admin"]["owner"] = USER application["bibjson"]["title"] = "Application Publisher Assigned Notify" @@ -209,7 +212,7 @@ def editor_group_mock_pull(key, value): ############################################## ## ApplicationPublisherCreatedNotify -if "application_publisher_created_notify" in NOTIFICATIONS: +if "application:publisher:created:notify" in NOTIFICATIONS: application = ApplicationFixtureFactory.make_application_source() application["admin"]["owner"] = USER application["bibjson"]["title"] = "Application Publisher Created Notify" @@ -223,7 +226,7 @@ def editor_group_mock_pull(key, value): ############################################## ## ApplicationPublisherInprogressNotify -if "application_publisher_inprogress_notify" in NOTIFICATIONS: +if "application:publisher:inprogress:notify" in NOTIFICATIONS: application = ApplicationFixtureFactory.make_application_source() application["admin"]["owner"] = USER application["bibjson"]["title"] = "Application Publisher In Progress Notify" @@ -237,7 +240,7 @@ def editor_group_mock_pull(key, value): ############################################## ## ApplicationPublisherQuickRejectNotify -if "application_publisher_quickreject_notify" in NOTIFICATIONS: +if "application:publisher:quickreject:notify" in NOTIFICATIONS: application = ApplicationFixtureFactory.make_application_source() application["admin"]["owner"] = USER application["bibjson"]["title"] = "Application Publisher Quick Reject Notify" @@ -251,7 +254,7 @@ def editor_group_mock_pull(key, value): ############################################## ## ApplicationPublisherQuickRejectNotify -if "application_publisher_revision_notify" in NOTIFICATIONS: +if "application:publisher:revision:notify" in NOTIFICATIONS: application = ApplicationFixtureFactory.make_application_source() application["admin"]["owner"] = USER application["bibjson"]["title"] = "Application Publisher Revision Notify" @@ -267,7 +270,7 @@ def editor_group_mock_pull(key, value): ## BGJobFinishedNotify if "bg_job_finished_notify" in NOTIFICATIONS: job = models.BackgroundJob(**{ - "id": "bg_job_finished_notify", + "id": "bg:job_finished:notify", "user": USER, "action": "bg_job_finished_notify", "status": "complete" @@ -281,7 +284,7 @@ def editor_group_mock_pull(key, value): ############################################## ## JournalAssedAssignedNotify -if "journal_assed_assigned_notify" in NOTIFICATIONS: +if "journal:assed:assigned:notify" in NOTIFICATIONS: journal = JournalFixtureFactory.make_journal_source(in_doaj=True) journal["admin"]["editor"] = USER journal["bibjson"]["title"] = "Journal Assed Assigned Notify" @@ -295,7 +298,7 @@ def editor_group_mock_pull(key, value): ############################################## ## JournalEditorGroupAssignedNotify -if "journal_editor_group_assigned_notify" in NOTIFICATIONS: +if "journal:editor_group:assigned:notify" in NOTIFICATIONS: def editor_group_mock_pull(key, value): return EditorGroup(**{ "editor": USER @@ -320,7 +323,7 @@ def editor_group_mock_pull(key, value): ############################################## ## UpdateRequestPublisherAcceptedNotify -if "update_request_publisher_accepted_notify" in NOTIFICATIONS: +if "update_request:publisher:accepted:notify" in NOTIFICATIONS: application = ApplicationFixtureFactory.make_application_source() application["admin"]["owner"] = USER application["bibjson"]["title"] = "Update Request Publisher Accepted Notify" @@ -334,7 +337,7 @@ def editor_group_mock_pull(key, value): ############################################## ## UpdateRequestPublisherAssignedNotify -if "update_request_publisher_assigned_notify" in NOTIFICATIONS: +if "update_request:publisher:assigned:notify" in NOTIFICATIONS: application = ApplicationFixtureFactory.make_application_source() application["admin"]["owner"] = USER application["bibjson"]["title"] = "Update Request Publisher Assigned Notify" @@ -348,7 +351,7 @@ def editor_group_mock_pull(key, value): ############################################## ## UpdateRequestPublisherRejectedNotify -if "update_request_publisher_rejected_notify" in NOTIFICATIONS: +if "update_request:publisher:rejected:notify" in NOTIFICATIONS: application = ApplicationFixtureFactory.make_application_source() application["admin"]["owner"] = USER application["bibjson"]["title"] = "Update Request Publisher Rejected Notify" diff --git a/portality/bll/services/events.py b/portality/bll/services/events.py index aa7a937f1..6ca7677e8 100644 --- a/portality/bll/services/events.py +++ b/portality/bll/services/events.py @@ -26,32 +26,32 @@ class EventsService(object): - # disabled events - to enable move the event to EVENT_CONSUMENRS array + # disabled events - to enable move the event to EVENT_CONSUMERS array DISABLED_EVENTS = [ - ApplicationPublisherRevisionNotify + ApplicationPublisherAssignedNotify, # https://github.com/DOAJ/doajPM/issues/3974 + ApplicationPublisherInprogressNotify, # https://github.com/DOAJ/doajPM/issues/3974 + ApplicationPublisherRevisionNotify, + JournalEditorGroupAssignedNotify, # https://github.com/DOAJ/doajPM/issues/3974 + JournalAssedAssignedNotify, # https://github.com/DOAJ/doajPM/issues/3974 + UpdateRequestPublisherAssignedNotify, # https://github.com/DOAJ/doajPM/issues/3974 ] EVENT_CONSUMERS = [ - ApplicationPublisherQuickRejectNotify, AccountCreatedEmail, AccountPasswordResetEmail, - ApplicationAssedInprogressNotify, ApplicationAssedAssignedNotify, + ApplicationAssedInprogressNotify, ApplicationEditorCompletedNotify, - ApplicationEditorInProgressNotify, ApplicationEditorGroupAssignedNotify, + ApplicationEditorInProgressNotify, ApplicationManedReadyNotify, - ApplicationPublisherCreatedNotify, - ApplicationPublisherInprogressNotify, ApplicationPublisherAcceptedNotify, - ApplicationPublisherAssignedNotify, + ApplicationPublisherCreatedNotify, + ApplicationPublisherQuickRejectNotify, BGJobFinishedNotify, - JournalAssedAssignedNotify, - JournalEditorGroupAssignedNotify, + JournalDiscontinuingSoonNotify, UpdateRequestPublisherAcceptedNotify, - UpdateRequestPublisherAssignedNotify, UpdateRequestPublisherRejectedNotify, - UpdateRequestPublisherSubmittedNotify, - JournalDiscontinuingSoonNotify, + UpdateRequestPublisherSubmittedNotify ] def __init__(self): diff --git a/portality/forms/application_processors.py b/portality/forms/application_processors.py index a7f027144..6afe65491 100644 --- a/portality/forms/application_processors.py +++ b/portality/forms/application_processors.py @@ -456,11 +456,6 @@ def finalise(self, account, save_target=True, email_alert=True): # self.add_alert("Problem sending email to associate editor - probably address is invalid") # app.logger.exception("Email to associate failed.") - # If this is the first time this application has been assigned to an editor, notify the publisher. - old_ed = self.source.editor - if (old_ed is None or old_ed == '') and self.target.editor is not None: - self.add_alert(Messages.SENT_PUBLISHER_ASSIGNED_EMAIL) - # Inform editor and associate editor if this application was 'ready' or 'completed', but has been changed to 'in progress' if (self.source.application_status == constants.APPLICATION_STATUS_READY or self.source.application_status == constants.APPLICATION_STATUS_COMPLETED) and self.target.application_status == constants.APPLICATION_STATUS_IN_PROGRESS: # First, the editor @@ -586,11 +581,6 @@ def finalise(self): # self.add_alert("Problem sending email to associate editor - probably address is invalid") # app.logger.exception('Error sending associate assigned email') - # If this is the first time this application has been assigned to an editor, notify the publisher. - old_ed = self.source.editor - if (old_ed is None or old_ed == '') and self.target.editor is not None: - self.add_alert(Messages.SENT_PUBLISHER_ASSIGNED_EMAIL) - # Email the assigned associate if the application was reverted from 'completed' to 'in progress' (failed review) if self.source.application_status == constants.APPLICATION_STATUS_COMPLETED and self.target.application_status == constants.APPLICATION_STATUS_IN_PROGRESS: if self.target.editor: diff --git a/portality/ui/messages.py b/portality/ui/messages.py index 5702b45aa..1487a2ca7 100644 --- a/portality/ui/messages.py +++ b/portality/ui/messages.py @@ -29,8 +29,6 @@ class Messages(object): SENT_JOURNAL_CONTACT_ACCEPTED_UPDATE_REQUEST_EMAIL = """Sent email to journal contact '{email}' to tell that an update to their journal was accepted.""" SENT_JOURNAL_CONTACT_IN_PROGRESS_EMAIL = """An email has been sent to the Journal Contact alerting them that you are working on their application.""" SENT_JOURNAL_CONTACT_ASSIGNED_EMAIL = """An email has been sent to the Journal Contact alerting them that an editor has been assigned to their application.""" - SENT_PUBLISHER_IN_PROGRESS_EMAIL = """An email has been sent to the Owner alerting them that you are working on their application.""" - SENT_PUBLISHER_ASSIGNED_EMAIL = """A notification has been sent to the Owner alerting them that an editor has been assigned to their application.""" NOT_SENT_ACCEPTED_APPLICATION_EMAIL = """Did not send notification to '{user}' to tell them that their journal was accepted. Email may be disabled, or there is a problem with the email address.""" NOT_SENT_REJECTED_APPLICATION_EMAILS = """Did not send email to user '{user}' or application suggester to tell them that their journal was rejected Email may be disabled, or there is a problem with the email address.""" @@ -40,8 +38,6 @@ class Messages(object): NOT_SENT_JOURNAL_CONTACT_ACCEPTED_APPLICATION_EMAIL = """Did not send email to '{email}' to tell them that their application/update request was accepted. Email may be disabled, or there is a problem with the email address""" NOT_SENT_JOURNAL_CONTACT_IN_PROGRESS_EMAIL = """An email could not be sent to the Journal Contact alerting them that you are working on their application. Email may be disabled, or there is a problem with the email address""" NOT_SENT_JOURNAL_CONTACT_ASSIGNED_EMAIL = """An email could not be sent to the Journal Contact alerting them that an editor has been assigned to their application. Email may be disabled, or there is a problem with the email address""" - NOT_SENT_PUBLISHER_IN_PROGRESS_EMAIL = """An email could not be sent to the Owner alerting them that you are working on their application. Email may be disabled, or there is a problem with the email address. """ - NOT_SENT_PUBLISHER_ASSIGNED_EMAIL = """An email could not be sent to the Owner alerting them that an editor has been assigned to their application. Email may be disabled, or there is a problem with the email address""" IN_PROGRESS_NOT_SENT_EMAIL_DISABLED = """Did not send email to Owner or Journal Contact about the status change, as publisher emails are disabled.""" From 0b0eea6d149a4b52b431a2f6ae938b9963bb4f55 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Tue, 12 Nov 2024 13:19:09 +0000 Subject: [PATCH 78/80] Update form email sending expectations --- .../test_application_processor_emails.py | 107 ++---------------- 1 file changed, 12 insertions(+), 95 deletions(-) diff --git a/doajtest/unit/application_processors/test_application_processor_emails.py b/doajtest/unit/application_processors/test_application_processor_emails.py index cc106942e..228d09405 100644 --- a/doajtest/unit/application_processors/test_application_processor_emails.py +++ b/doajtest/unit/application_processors/test_application_processor_emails.py @@ -354,16 +354,7 @@ def test_01_maned_review_emails(self): re.DOTALL) assert bool(assEd_email_matched), info_stream_contents.strip('\x00') - publisher_template = re.escape(templates.EMAIL_NOTIFICATION) - publisher_to = re.escape(owner.email) - publisher_subject = re.escape('Directory of Open Access Journals - Your application ({}) has been assigned to an editor for review'.format(', '.join(issn for issn in processor.source.bibjson().issns()))) - - publisher_email_matched = re.search(email_log_regex % (publisher_template, publisher_to, publisher_subject), - info_stream_contents, - re.DOTALL) - - assert bool(publisher_email_matched) - assert len(re.findall(email_count_string, info_stream_contents)) == 2 + assert len(re.findall(email_count_string, info_stream_contents)) == 1 # Clear the stream for the next part self.info_stream.truncate(0) @@ -553,16 +544,7 @@ def test_02_ed_review_emails(self): info_stream_contents, re.DOTALL) assert bool(assEd_email_matched), info_stream_contents.strip('\x00') - - publisher_template = templates.EMAIL_NOTIFICATION - publisher_to = re.escape(owner.email) - publisher_subject = re.escape('Your update request ({}) has been assigned to an editor for review'.format(', '.join(issn for issn in processor.source.bibjson().issns()))) - - publisher_email_matched = re.search(email_log_regex % (publisher_template, publisher_to, publisher_subject), - info_stream_contents, - re.DOTALL) - assert bool(publisher_email_matched) - assert len(re.findall(email_count_string, info_stream_contents)) == 2 + assert len(re.findall(email_count_string, info_stream_contents)) == 1 # Clear the stream for the next part self.info_stream.truncate(0) @@ -665,17 +647,8 @@ def test_03_assoc_ed_review_emails(self): processor.finalise() info_stream_contents = self.info_stream.getvalue() - # We expect one email to be sent here: - # * to the publisher, notifying that an editor is viewing their application - publisher_template = re.escape(templates.EMAIL_NOTIFICATION) - publisher_to = re.escape(owner.email) - publisher_subject = re.escape('Directory of Open Access Journals - Your submission ({}) is under review'.format(', '.join(issn for issn in processor.source.bibjson().issns()))) - - publisher_email_matched = re.search(email_log_regex % (publisher_template, publisher_to, publisher_subject), - info_stream_contents, - re.DOTALL) - assert bool(publisher_email_matched) - assert len(re.findall(email_count_string, info_stream_contents)) == 1 + # We expect no emails + assert len(re.findall(email_count_string, info_stream_contents)) == 0 # Clear the stream for the next part self.info_stream.truncate(0) @@ -936,17 +909,7 @@ def test_01_maned_review_emails(self): info_stream_contents, re.DOTALL) assert bool(assEd_email_matched), info_stream_contents.strip('\x00') - - publisher_template = templates.EMAIL_NOTIFICATION - publisher_to = re.escape(owner.email) - publisher_subject = re.escape('Your update request ({}) has been assigned to an editor for review'.format(', '.join(issn for issn in processor.source.bibjson().issns()))) - - publisher_email_matched = re.search(email_log_regex % (publisher_template, publisher_to, publisher_subject), - info_stream_contents, - re.DOTALL) - - assert bool(publisher_email_matched) - assert len(re.findall(email_count_string, info_stream_contents)) == 2 + assert len(re.findall(email_count_string, info_stream_contents)) == 1 # Clear the stream for the next part self.info_stream.truncate(0) @@ -1125,16 +1088,7 @@ def test_02_ed_review_emails(self): info_stream_contents, re.DOTALL) assert bool(assEd_email_matched), info_stream_contents.strip('\x00') - - publisher_template = templates.EMAIL_NOTIFICATION - publisher_to = re.escape(owner.email) - publisher_subject = re.escape('Your update request ({}) has been assigned to an editor for review'.format(', '.join(issn for issn in processor.source.bibjson().issns()))) - - publisher_email_matched = re.search(email_log_regex % (publisher_template, publisher_to, publisher_subject), - info_stream_contents, - re.DOTALL) - assert bool(publisher_email_matched) - assert len(re.findall(email_count_string, info_stream_contents)) == 2 + assert len(re.findall(email_count_string, info_stream_contents)) == 1 # Clear the stream for the next part self.info_stream.truncate(0) @@ -1240,17 +1194,8 @@ def test_03_assoc_ed_review_emails(self): processor.finalise() info_stream_contents = self.info_stream.getvalue() - # We expect one email to be sent here: - # * to the publisher, notifying that an editor is viewing their application - publisher_template = re.escape(templates.EMAIL_NOTIFICATION) - publisher_to = re.escape(owner.email) - publisher_subject = re.escape('Your submission ({}) is under review'.format(', '.join(issn for issn in processor.source.bibjson().issns()))) - - publisher_email_matched = re.search(email_log_regex % (publisher_template, publisher_to, publisher_subject), - info_stream_contents, - re.DOTALL) - assert bool(publisher_email_matched) - assert len(re.findall(email_count_string, info_stream_contents)) == 1 + # We expect no email to be sent + assert len(re.findall(email_count_string, info_stream_contents)) == 0 # Clear the stream for the next part self.info_stream.truncate(0) @@ -1328,27 +1273,8 @@ def test_01_maned_review_emails(self): # check the associate was changed assert processor.target.editor == "associate_3" - # We expect 2 emails to be sent: - # * to the editor of the assigned group, - # * to the AssEd who's been assigned, - editor_template = re.escape(templates.EMAIL_NOTIFICATION) - editor_to = re.escape('eddie@example.com') - editor_subject = re.escape('Directory of Open Access Journals - New journal ({}) assigned to your group'.format(', '.join(issn for issn in processor.source.bibjson().issns()))) - - editor_email_matched = re.search(email_log_regex % (editor_template, editor_to, editor_subject), - info_stream_contents, - re.DOTALL) - assert bool(editor_email_matched) - - assEd_template = re.escape(templates.EMAIL_NOTIFICATION) - assEd_to = re.escape(models.Account.pull('associate_3').email) - assEd_subject = re.escape('Directory of Open Access Journals - New journal ({}) assigned to you'.format(', '.join(issn for issn in processor.source.bibjson().issns()))) - - assEd_email_matched = re.search(email_log_regex % (assEd_template, assEd_to, assEd_subject), - info_stream_contents, - re.DOTALL) - assert bool(assEd_email_matched), info_stream_contents.strip('\x00') - assert len(re.findall(email_count_string, info_stream_contents)) == 2 + # We expect no emails to be sent + assert len(re.findall(email_count_string, info_stream_contents)) == 0 ctx.pop() def test_02_ed_review_emails(self): @@ -1369,16 +1295,7 @@ def test_02_ed_review_emails(self): # check the associate was changed assert processor.target.editor == "associate_2" - # We expect 1 email to be sent: - # * to the AssEd who's been assigned - assEd_template = re.escape(templates.EMAIL_NOTIFICATION) - assEd_to = re.escape(models.Account.pull('associate_2').email) - assEd_subject = re.escape('Directory of Open Access Journals - New journal ({}) assigned to you'.format(', '.join(issn for issn in processor.source.bibjson().issns()))) - - assEd_email_matched = re.search(email_log_regex % (assEd_template, assEd_to, assEd_subject), - info_stream_contents, - re.DOTALL) - assert bool(assEd_email_matched), info_stream_contents.strip('\x00') - assert len(re.findall(email_count_string, info_stream_contents)) == 1 + # We no email to be sent + assert len(re.findall(email_count_string, info_stream_contents)) == 0 ctx.pop() From 5ea6b51becb976ebd6ebedabd868af0c7f0afcb5 Mon Sep 17 00:00:00 2001 From: Steven Eardley Date: Tue, 12 Nov 2024 16:02:34 +0000 Subject: [PATCH 79/80] correct the edges version in develop --- portality/static/vendor/edges | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portality/static/vendor/edges b/portality/static/vendor/edges index 9639b871a..990f42201 160000 --- a/portality/static/vendor/edges +++ b/portality/static/vendor/edges @@ -1 +1 @@ -Subproject commit 9639b871acb1f6590ee78e236f2c9333479c9fe8 +Subproject commit 990f4220163a3e18880f0bdc3ad5c80d234d22dd From bc293fff5b8dc0b5da6c65e41087e5511dfbf0ff Mon Sep 17 00:00:00 2001 From: Steven Eardley Date: Tue, 12 Nov 2024 16:06:25 +0000 Subject: [PATCH 80/80] version bump for release --- portality/settings.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/portality/settings.py b/portality/settings.py index 213961706..dbeddc63e 100644 --- a/portality/settings.py +++ b/portality/settings.py @@ -9,7 +9,7 @@ # Application Version information # ~~->API:Feature~~ -DOAJ_VERSION = "7.0.1" +DOAJ_VERSION = "7.0.2" API_VERSION = "4.0.0" ###################################### diff --git a/setup.py b/setup.py index ddc27e972..bfebfeed0 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name='doaj', - version='7.0.1', + version='7.0.2', packages=find_packages(), install_requires=[ "awscli==1.20.50",