diff --git a/.python-version b/.python-version deleted file mode 100644 index 7c7a975f4..000000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.10 \ No newline at end of file diff --git a/cms/sass/components/_input-group.scss b/cms/sass/components/_input-group.scss index fb699bbc7..4e58f8a9f 100644 --- a/cms/sass/components/_input-group.scss +++ b/cms/sass/components/_input-group.scss @@ -1,31 +1,54 @@ /* Input groups */ .input-group { + width: 100%; margin: 0 0 1rem 0; position: relative; display: flex; flex-direction: row; - border-collapse: separate; - width: 100%; + align-items: stretch; + flex-wrap: wrap; + + input, select, .select2-container { + flex: 1 1 0; + min-width: 37.5%; + margin-top: 0; + margin-bottom: $spacing-02; + overflow: hidden; + text-overflow: ellipsis; - input, select, button, .select2-container { + button, a.button { + flex: 0 1 auto; + min-width: 10%; margin-top: 0; margin-bottom: $spacing-02; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } &:not(:first-child) { margin-left: -1px; } } - input, select, .select2-container { - width: 50%; - } - &:last-of-type { margin: 0; } } +@media (max-width: 600px) { + .container { + flex-direction: column; + } + + .container input, + .container button { + flex: 1 1 auto; + width: 100%; + } +} + .input-group-authors { flex-flow: column; } diff --git a/cms/sass/themes/_dashboard.scss b/cms/sass/themes/_dashboard.scss index 5d68af637..c67cd3160 100644 --- a/cms/sass/themes/_dashboard.scss +++ b/cms/sass/themes/_dashboard.scss @@ -239,7 +239,6 @@ } .remove_field__button { - margin-bottom: $spacing-03; white-space: nowrap; } } diff --git a/cms/sass/themes/_editorial-form.scss b/cms/sass/themes/_editorial-form.scss index db3df0850..1ca5d8720 100644 --- a/cms/sass/themes/_editorial-form.scss +++ b/cms/sass/themes/_editorial-form.scss @@ -90,7 +90,6 @@ } .remove_field__button { - margin-bottom: $spacing-03; white-space: nowrap; } diff --git a/doajtest/fixtures/v2/common.py b/doajtest/fixtures/v2/common.py index 30baf7884..bc5941b74 100644 --- a/doajtest/fixtures/v2/common.py +++ b/doajtest/fixtures/v2/common.py @@ -89,6 +89,7 @@ def build_flags_form_expanded(assignee=None, deadline="", setter=None, created_d "keywords": ["word", "key"], "labels": ["s2o"], "language": ["EN", "FR"], + "language_editions": [{"pissn": "000X-000X", "eissn": "111X-111X", "language": "fr", "id": "frlanguageedition"}], "license": [ { "type": "Publisher's own license", diff --git a/doajtest/unit/test_models.py b/doajtest/unit/test_models.py index d11d3a131..9bef9cf11 100644 --- a/doajtest/unit/test_models.py +++ b/doajtest/unit/test_models.py @@ -6,6 +6,7 @@ from doajtest.helpers import DoajTestCase, patch_history_dir, save_all_block_last from portality import constants from portality import models +from portality.bll.exceptions import NoSuchObjectException, ArgumentException from portality.constants import BgjobOutcomeStatus from portality.lib import dataobj from portality.lib import seamless @@ -71,6 +72,10 @@ def test_02_journal_model_rw(self): j.add_contact("richard", "richard@email.com") j.add_note("testing", "2005-01-01T00:00:00Z") j.set_bibjson({"title": "test"}) + jbib = j.bibjson() + jbib.language_editions = [{"id": "polishedition", "language": "pl"}, + {"id": "englishedition", "language": "en"}, + {"id": "germanedition", "language": "de"}] assert j.id == "abcd" assert j.created_date == "2001-01-01T00:00:00Z" @@ -88,11 +93,25 @@ def test_02_journal_model_rw(self): assert j.get_latest_contact_email() == "richard@email.com" assert len(j.notes) == 1 assert j.bibjson().title == "test" + assert len(jbib.language_editions) == 3 j.remove_owner() j.remove_editor_group() j.remove_editor() j.remove_contact() + with self.assertRaises(ValueError): + jbib.remove_language_edition("frenchedition") + + jbib.remove_language_edition("polishedition") + assert len(jbib.language_editions) == 2 + + jbib.clear_language_editions() + assert len(jbib.language_editions) == 0 + + jbib.add_language_edition({"id": "originaljournalrecord", "language": "fr"}) + assert len(jbib.language_editions) == 1 + assert jbib.language_editions[0]["id"] == "originaljournalrecord" + assert jbib.language_editions[0]["language"] == "fr" assert j.owner is None assert j.editor_group is None diff --git a/portality/crosswalks/journal_form.py b/portality/crosswalks/journal_form.py index 95b1ec969..f8b4ca0b2 100644 --- a/portality/crosswalks/journal_form.py +++ b/portality/crosswalks/journal_form.py @@ -242,6 +242,13 @@ def form2bibjson(cls, form, bibjson): if getattr(form, "discontinued_date", None): bibjson.discontinued_date = form.discontinued_date.data + # language editions + if form.language_editions.data: + editions = [] + for le in form.language_editions.data: + editions.append({"id":le["lang_edition_id"], "language":le["lang_edition_language"]}) + bibjson.language_editions = editions + # subject information if getattr(form, "subject", None): new_subjects = [] @@ -259,6 +266,7 @@ def form2bibjson(cls, form, bibjson): @classmethod def form2admin(cls, form, obj): + if getattr(form, "notes", None): for formnote in form.notes.data: if formnote["note"]: @@ -440,6 +448,14 @@ def bibjson2form(cls, bibjson, forminfo): forminfo["continued_by"] = bibjson.is_replaced_by forminfo["discontinued_date"] = bibjson.discontinued_date + # language editions information + forminfo["language_editions"] = [] + for le in bibjson.language_editions: + forminfo["language_editions"].append({ + "lang_edition_id": le.get("id"), + "lang_edition_language": le.get("language") + }) + # subject classifications forminfo['subject'] = [] for s in bibjson.subject: diff --git a/portality/dao.py b/portality/dao.py index c879f69c9..3e9241572 100644 --- a/portality/dao.py +++ b/portality/dao.py @@ -414,12 +414,26 @@ def pull(cls, id_): @classmethod def pull_by_key(cls, key, value): + """ + attr: key, value + returns: + - if 1 result: the record; + - if <1 or >1 results: None + """ res = cls.query(q={"query": {"term": {key + app.config['FACET_FIELD']: value}}}) if res.get('hits', {}).get('total', {}).get('value', 0) == 1: return cls.pull(res['hits']['hits'][0]['_source']['id']) else: return None + @classmethod + def search_by_key(cls, key: str, value: str) -> list: + """ + returns: [ids] of the found records + """ + res = cls.query(q={"query": {"term": {key + app.config['FACET_FIELD']: value}}}) + return [cls(**r.get("_source")) for r in res.get("hits", {}).get("hits", [])] + @classmethod def object_query(cls, q=None, **kwargs): result = cls.query(q, **kwargs) diff --git a/portality/forms/application_forms.py b/portality/forms/application_forms.py index 178c36fbb..d0f42ee5e 100644 --- a/portality/forms/application_forms.py +++ b/portality/forms/application_forms.py @@ -39,11 +39,12 @@ NoScriptTag, Year, CurrentISOCurrency, - CurrentISOLanguage + CurrentISOLanguage, + JournalsByOwner ) from portality.lib import dates from portality.lib.formulaic import Formulaic, WTFormsBuilder, FormulaicContext, FormulaicField -from portality.models import EditorGroup +from portality.models import EditorGroup, Journal from portality.regex import ISSN, ISSN_COMPILED from portality.ui.messages import Messages from portality.ui import templates @@ -486,6 +487,80 @@ class FieldDefinitions: } } + # ~~->$ Langugage_editions:FormField~~ + LANGUAGE_EDITIONS = { + "name": "language_editions", + "label": "Language Editions", + "input": "group", + "optional": True, + "subfields": [ + "lang_edition_language", + "lang_edition_id" + ], + "template": templates.AF_LIST, + "entry_template": templates.AF_ENTRY_GROUP_HORIZONTAL, + "widgets": [ + "multiple_field", + {"clickable_journal": {"val_field": "lang_edition_id"}} + ], + "repeatable": { + "minimum": 0, + "initial": 5 + }, + "help": { + "short_help": "Add language edition(s) of this journal.", + "long_help": ["To link a language edition, enter the language details and choose the corresponding DOAJ record. " + "Only records owned by the same publisher as the original journal can be linked. " + "You can filter the records by title or ISSN(s)." + ] + } + } + + LANG_EDITIONS_ID = { + "name": "lang_edition_id", + "subfield": True, + "group": "language_editions", + "label": "Linked record", + "input": "select", + "default": "", + "options_fn": "journals_by_owner", + "validate": [ + "journals_by_owner", + {"required_if": { + "field": "lang_edition_language", + "message": "Choose the DOAJ record from the list to link a language edition" + }}, + ], + "widgets": [ + {"select": {}}, + ], + "help": { + "placeholder": "Linked record - search by title, id or issns" + } + } + + LANG_EDITIONS_LANGUAGE = { + "name": "lang_edition_language", + "subfield": True, + "group": "language_editions", + "label": "Language", + "input": "select", + "options_fn": "iso_language_list", + "validate": [ + "current_iso_language", + {"required_if": { + "field": "lang_edition_id", + "message": "Language is required to link a language edition" + }}, + ], + "widgets": [ + {"select": {}} + ], + "help": { + "placeholder": "Language" + } + } + # ~~->$ PublisherName:FormField~~ PUBLISHER_NAME = { "name": "publisher_name", @@ -1584,7 +1659,6 @@ class FieldDefinitions: "label": "Persistent article identifiers used by the journal", "input": "checkbox", "multiple": True, - "hint": "Select at least one", "options": [ {"display": "DOIs", "value": "DOI"}, {"display": "ARKs", "value": "ARK"}, @@ -1595,6 +1669,7 @@ class FieldDefinitions: "exclusive": True} ], "help": { + "short_help": "Select at least one", "long_help": ["A persistent article identifier (PID) is used to find the article no matter where it is " "located. The most common type of PID is the digital object identifier (DOI). ", "Read more about PIDs."], @@ -2300,7 +2375,17 @@ class FieldSetDefinitions: "fields": [ FieldDefinitions.CONTINUES["name"], FieldDefinitions.CONTINUED_BY["name"], - FieldDefinitions.DISCONTINUED_DATE["name"] + FieldDefinitions.DISCONTINUED_DATE["name"], + ] + } + + RELATED_JOURNALS = { + "name": "related_journals", + "label": "Related journals", + "fields": [ + FieldDefinitions.LANGUAGE_EDITIONS["name"], + FieldDefinitions.LANG_EDITIONS_ID["name"], + FieldDefinitions.LANG_EDITIONS_LANGUAGE["name"], ] } @@ -2539,6 +2624,7 @@ class JournalContextDefinitions: FieldSetDefinitions.OPTIONAL_VALIDATION["name"], FieldSetDefinitions.LABELS["name"], FieldSetDefinitions.CONTINUATIONS["name"], + FieldSetDefinitions.RELATED_JOURNALS["name"], FieldSetDefinitions.FLAGS["name"] ] MANED["processor"] = application_processors.ManEdJournalReview @@ -2595,10 +2681,42 @@ class JournalContextDefinitions: "fields": APPLICATION_FORMS["fields"] } - ####################################################### # Options lists ####################################################### +def journals_by_owner(field, formulaic_context): + """ + Set the journal choices from a given owner name + ~~->EditorGroup:Model~~ + """ + egf = formulaic_context.get("owner") + wtf = egf.wtfield + options = [{"display":"", "value":""}] + if wtf is None: + return options + + owner_name = wtf.data + if owner_name is None: + return options + else: + options.append({"display": "", "value": ""}) + journals = Journal.search_by_key("admin.owner", owner_name) + for j in journals: + title = j.bibjson().title + this_title = formulaic_context.get("title").wtfield.data + if title != this_title: + pissn = j.bibjson().pissn + eissn = j.bibjson().eissn + + parts = [] + if pissn: + parts.append(f"P: {pissn}") + if eissn: + parts.append(f"E: {eissn}") + + display_value = ", ".join(parts) + f" ( {title} )" + options.append({"display": display_value, "value": j.id}) + return options def iso_country_list(field, formualic_context_name): # ~~-> Countries:Data~~ @@ -3143,6 +3261,15 @@ def render(settings, html_attrs): def wtforms(field, settings): return CurrentISOLanguage(settings.get("message")) +class JournalsByOwnerBuilder: + @staticmethod + def render(settings, html_attrs): + pass + + @staticmethod + def wtforms(field, settings): + return JournalsByOwner(settings.get("message")) + ######################################################### # Crosswalks @@ -3155,7 +3282,8 @@ def wtforms(field, settings): "iso_currency_list": iso_currency_list, "quick_reject": quick_reject, "application_statuses": application_statuses, - "editor_choices": editor_choices + "editor_choices": editor_choices, + "journals_by_owner": journals_by_owner }, "disabled": { "application_status_disabled": application_status_disabled, @@ -3210,7 +3338,8 @@ def wtforms(field, settings): "no_script_tag": NoScriptTagBuilder.wtforms, "year": YearBuilder.wtforms, "current_iso_currency": CurrentISOCurrencyBuilder.wtforms, - "current_iso_language": CurrentISOLanguageBuilder.wtforms + "current_iso_language": CurrentISOLanguageBuilder.wtforms, + "journals_by_owner": JournalsByOwnerBuilder.wtforms } } } @@ -3234,6 +3363,7 @@ def wtforms(field, settings): "issn_link": "formulaic.widgets.newIssnLink", # ~~-> IssnLink:FormWidget~~, "article_info": "formulaic.widgets.newArticleInfo", # ~~-> ArticleInfo:FormWidget~~ "flag_manager": "formulaic.widgets.newFlagManager", # ~~-> FlagManager:FormWidget~~ + "clickable_journal": "formulaic.widgets.newClickableJournal" # ~~-> RecordLink:FormWidget~~ } @@ -3353,7 +3483,7 @@ def match(field): @staticmethod def wtform(formulaic_context, field, wtfargs): - sf = SelectField(**wtfargs) + sf = SelectField(validate_choice=False, **wtfargs) if "repeatable" in field: sf = FieldList(sf, min_entries=field.get("repeatable", {}).get("initial", 1)) diff --git a/portality/forms/validate.py b/portality/forms/validate.py index 363a9cba7..5250d089e 100644 --- a/portality/forms/validate.py +++ b/portality/forms/validate.py @@ -355,14 +355,15 @@ def __call__(self, form, field): class RequiredIfOtherValue(MultiFieldValidator): """ - Makes a field required, if the user has selected a specific value in another field + Makes a field required, if the user has selected a specific value (or any value if "value" is not provided) + in another field ~~RequiredIfOtherValue:FormValidator~~ """ - def __init__(self, other_field_name, other_value, message=None, *args, **kwargs): - self.other_value = other_value - self.message = message if message is not None else "This field is required when {x} is {y}".format(x=other_field_name, y=other_value) + def __init__(self, other_field_name, other_value=None, message=None, *args, **kwargs): + self.other_value = other_value if other_value else None + self.message = message if message is not None else "This field is required when {x} is {y}".format(x=other_field_name, y=other_value if other_value else "provided") super(RequiredIfOtherValue, self).__init__(other_field_name, *args, **kwargs) def __call__(self, form, field): @@ -378,6 +379,10 @@ def __call__(self, form, field): self._match_single(form, field, other_field) def _match_single(self, form, field, other_field): + if self.other_value is None: + if other_field.data: + dr = validators.DataRequired(self.message) + dr(form, field) if isinstance(other_field.data, list): match = self.other_value in other_field.data else: @@ -670,3 +675,21 @@ def __call__(self, form, field): check = isolang.find(field.data) if check is None: raise validators.ValidationError(self.message) + +class JournalsByOwner(MultiFieldValidator): + """ + A field is valid if it contains an id of another journal of the same owner as the current one. + """ + def __init__(self, message=None): + super(JournalsByOwner, self).__init__(other_field="owner") + if not message: + message = "Only journals of the same owner as the current record can be linked." + self.message = message + + def __call__(self, form, field): + owner = self.get_other_field(self.other_field_name, form).data + if field.data is not None and field.data != '': + j = Journal.pull(field.data) + check = j.owner == owner + if not check: + raise validators.ValidationError(self.message) diff --git a/portality/models/v2/bibjson.py b/portality/models/v2/bibjson.py index 83af25944..3495aa0f8 100644 --- a/portality/models/v2/bibjson.py +++ b/portality/models/v2/bibjson.py @@ -119,6 +119,27 @@ def is_replaced_by(self): def add_is_replaced_by(self, val): self.__seamless__.add_to_list_with_struct("is_replaced_by", val) + @property + def language_editions(self): + return self.__seamless__.get_list("language_editions") + + @language_editions.setter + def language_editions(self, language_editions): + self.__seamless__.set_list("language_editions", language_editions) + + def add_language_edition(self, language_edition): + self.__seamless__.add_to_list_with_struct("language_editions", language_edition) + + def remove_language_edition(self, id): + match_on_list = next((le for le in self.language_editions if le["id"] == id), None) + if match_on_list: + self.__seamless__.delete_from_list("language_editions", match_on_list) + else: + raise ValueError + + def clear_language_editions(self): + self.__seamless__.set_list("language_editions", []) + @property def keywords(self): return self.__seamless__.get_list("keywords") diff --git a/portality/models/v2/journal.py b/portality/models/v2/journal.py index 73d8ecb71..6ab456171 100644 --- a/portality/models/v2/journal.py +++ b/portality/models/v2/journal.py @@ -8,7 +8,6 @@ from unidecode import unidecode -import portality.lib.dates from portality.core import app from portality.dao import DomainObject from portality.lib import es_data_mapping, dates, coerce @@ -64,7 +63,6 @@ class ContinuationException(Exception): pass - class JournalLikeObject(SeamlessMixin, DomainObject): # During migration from the old data model to the new data model for journal-like objects, this allows @@ -280,8 +278,7 @@ def set_contact(self, name, email): def remove_contact(self): self.__seamless__.delete("admin.contact") - #### Notes methods - + #### Notes methods def add_note(self, note, date=None, id=None, author_id=None, assigned_to=None, deadline=None): if not date: date = dates.now_str() diff --git a/portality/models/v2/shared_structs.py b/portality/models/v2/shared_structs.py index 4b484d973..1cc5983d3 100644 --- a/portality/models/v2/shared_structs.py +++ b/portality/models/v2/shared_structs.py @@ -22,6 +22,7 @@ "replaces" : {"contains" : "field", "coerce" : "issn", "set__allow_coerce_failure" : True}, "subject" : {"contains" : "object"}, "labels": {"contains": "field", "coerce": "unicode", "allowed_values": ["s2o"]}, + "language_editions": {"contains": "object"} }, "objects" : [ "apc", @@ -162,6 +163,12 @@ "has_waiver" : {"coerce" : "bool"}, "url" : {"coerce" : "url", "set__allow_coerce_failure" : True} } + }, + "language_editions": { + "fields": { + "id": {"coerce": "unicode"}, + "language": {"coerce": "unicode"} + } } } } diff --git a/portality/static/js/formulaic.js b/portality/static/js/formulaic.js index 5dfaccf36..dd4b92d6b 100644 --- a/portality/static/js/formulaic.js +++ b/portality/static/js/formulaic.js @@ -1368,6 +1368,54 @@ var formulaic = { this.init(); }, + newClickableJournal: function (params) { + return edges.instantiate(formulaic.widgets.ClickableJournal, params) + }, + ClickableJournal: function (params) { + this.fieldDef = params.fieldDef; + this.valueField = params.args.val_field; + this.form = params.formulaic; + this.ns = "formulaic-clickablejournal"; + const max = "repeatable" in this.fieldDef ? this.fieldDef["repeatable"]["initial"] : 1 ; + this.$link = new Array(max); + + this.linkPath = "/toc/" + + this.namespace = "formulaic-clickablejournal"; + + this.init = function () { + const elements = $("select[id$='" + this.valueField + "']") + edges.on(elements, "change", this, "updateJournal"); + for (let i = 0; i < elements.length; i++) { + this.updateJournal(elements[i]); + } + }; + + this.updateJournal = function (element) { + const $that = $(element); + const val = $that.val(); + const classes = edges.css_classes(this.ns, "visit"); + const id = edges.css_id(this.ns, $(element).attr("id")+"--clickable-link"); + if (val) { + if ($that.attr("id") in this.$link && this.$link[$that.attr("id")]) { + $('#'+ id).attr("href", this.linkPath + val) + } + else { + $that.after('\ + '); + } + this.$link[$that.attr("id")] = true; + } else { + if ($that.attr("id") in this.$link && this.$link[$that.attr("id")]) { + $('#' + id).parent().remove(); + } + this.$link[$that.attr("id")] = false; + } + feather.replace(); + }; + + this.init(); + }, newClickToCopy: function (params) { return edges.instantiate(formulaic.widgets.ClickToCopy, params) }, @@ -1421,7 +1469,6 @@ var formulaic = { this.init(); }, - newClickableUrl: function (params) { return edges.instantiate(formulaic.widgets.ClickableUrl, params) }, @@ -1623,7 +1670,7 @@ var formulaic = { this.divs = $("div[name='" + this.fieldDef["name"] + "__group']"); for (var i = 0; i < this.divs.length; i++) { var div = $(this.divs[i]); - div.append($('')); + div.append($('')); feather.replace(); } @@ -1750,7 +1797,7 @@ var formulaic = { let f = this.fields[idx]; let s2_input = $($(f).select2()); $(f).on("focus", formulaic.widgets._select2_shift_focus); - s2_input.after($('')); + s2_input.after($('')); if (idx !== 0) { s2_input.attr("required", false); s2_input.attr("data-parsley-validate-if-empty", "true"); @@ -1809,7 +1856,7 @@ var formulaic = { for (var idx = 0; idx < this.fields.length; idx++) { let f = this.fields[idx]; let jqf = $(f); - jqf.after($('')); + jqf.after($('')); if (idx !== 0) { jqf.attr("required", false); jqf.attr("data-parsley-validate-if-empty", "true"); @@ -1886,7 +1933,7 @@ var formulaic = { for (var idx = 0; idx < this.divs.length; idx++) { let div = $(this.divs[idx]); - div.append($('')); + div.append($('')); if (idx !== 0) { let inputs = div.find(":input"); diff --git a/portality/templates-v2/_application-form/includes/_list.html b/portality/templates-v2/_application-form/includes/_list.html index 656c3d118..ac4bf9df7 100644 --- a/portality/templates-v2/_application-form/includes/_list.html +++ b/portality/templates-v2/_application-form/includes/_list.html @@ -13,7 +13,10 @@ {% endif %} {% if f.optional %}(Optional){% endif %} - {% if f.get("hint") %}

{{ f.hint | safe }}

{% endif %} + + {% if f.help("short_help") %} +

{{ f.help("short_help") | safe }}

+ {% endif %} {% if f.has_widget("multiple_field") or f.has_widget("infinite_repeat") %} {% set add_button %}

@@ -55,9 +58,5 @@ {% endif %} {% endif %} - {% if f.help("short_help") %} -

{{ f.help("short_help") | safe }}

- {% endif %} - {% include "_application-form/includes/_modal.html" %} diff --git a/portality/templates-v2/management/_application-form/includes/_editorial_form_fields.html b/portality/templates-v2/management/_application-form/includes/_editorial_form_fields.html index cb17741d0..79498b951 100644 --- a/portality/templates-v2/management/_application-form/includes/_editorial_form_fields.html +++ b/portality/templates-v2/management/_application-form/includes/_editorial_form_fields.html @@ -42,6 +42,15 @@

{{ fs.label }}

{% endfor %} {% endif %} + {% set fs = formulaic_context.fieldset("related_journals") %} + {% if fs %} +

{{ fs.label }}

+ {% for f in fs.fields() %} + {% set field_template = f.template %} + {% include field_template %} + {% endfor %} + {% endif %} + {% set fs = formulaic_context.fieldset("subject") %} {% if fs %}

{{ fs.label }}

diff --git a/portality/ui/messages.py b/portality/ui/messages.py index 038848d9c..dd4d94cdb 100644 --- a/portality/ui/messages.py +++ b/portality/ui/messages.py @@ -64,6 +64,7 @@ class Messages(object): EXCEPTION_IDENTICAL_PISSN_AND_EISSN = "The Print and Online ISSNs supplied are identical. If you supply two ISSNs, they must be different." EXCEPTION_NO_ISSNS = "Neither the Print ISSN nor Online ISSN have been supplied. DOAJ requires at least one ISSN." EXCEPTION_INVALID_BIBJSON = "Invalid article bibjson: " # + Dataobj exception message + EXCEPTION_RECORD_NOT_FOUND = "Record not found." EXCEPTION_IDENTIFIER_CHANGE_CLASH = "DOI or Fulltext URL has been changed to match another article that already exists in DOAJ" EXCEPTION_IDENTIFIER_CHANGE = "Either the DOI or Fulltext URL has been changed. This operation is not permitted; please contact an administrator for help."