Coverage for portality / api / current / data_objects / application.py: 60%
187 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 00:09 +0100
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 00:09 +0100
1import uuid
2from datetime import datetime
4from portality.api.current.data_objects.common import _check_for_script
5from portality.lib import swagger, seamless, coerce, dates, dataobj
6from portality import models
7from copy import deepcopy
9from portality.api.current.data_objects.common_journal_application import OutgoingCommonJournalApplication, _SHARED_STRUCT
11# both incoming and outgoing applications share this struct
12# "required" fields are only put on incoming applications
13from portality.lib.coerce import COERCE_MAP
14from portality.lib.seamless import SeamlessMixin
15from portality.models import JournalLikeBibJSON
16from portality.ui.messages import Messages
18OUTGOING_APPLICATION_STRUCT = {
19 "fields": {
20 "id": {"coerce": "unicode"}, # Note that we'll leave these in for ease of use by the
21 "created_date": {"coerce": "utcdatetime"}, # caller, but we'll need to ignore them on the conversion
22 "last_updated": {"coerce": "utcdatetime"}, # to the real object
23 "last_manual_update": {"coerce": "utcdatetime"}
24 },
25 "objects": ["admin", "bibjson"],
26 "structs": {
27 "admin" : {
28 "fields" : {
29 "application_status" : {"coerce" : "unicode"},
30 "current_journal" : {"coerce" : "unicode"},
31 "date_applied" : {"coerce" : "unicode"},
32 "owner" : {"coerce" : "unicode"}
33 }
34 }
35 }
36}
38INTERNAL_APPLICATION_STRUCT = {
39 "fields": {
40 "id": {"coerce": "unicode"}, # Note that we'll leave these in for ease of use by the
41 "created_date": {"coerce": "utcdatetime"}, # caller, but we'll need to ignore them on the conversion
42 "last_updated": {"coerce": "utcdatetime"}, # to the real object
43 "last_manual_update": {"coerce": "utcdatetime"},
44 "es_type": {"coerce": "unicode"}
45 },
46 "objects": ["admin", "bibjson"],
47 "structs": {
48 "admin" : {
49 "fields" : {
50 "related_journal" : {"coerce" : "unicode"},
51 "editor_group" : {"coerce" : "unicode"},
52 "editor" : {"coerce" : "unicode"},
53 "owner" : {"coerce" : "unicode"}
54 },
55 "lists": {
56 "notes" : {"contains" : "object"},
57 }
58 }
59 }
60}
63INCOMING_APPLICATION_REQUIREMENTS = {
64 "required" : ["admin", "bibjson"],
66 "structs": {
67 "bibjson": {
68 "lists": {
69 # override for lax language enforcement in the core, making it strict for incoming applications
70 "language": {"contains": "field", "coerce": "isolang_2letter_strict"}
71 },
72 "required": [
73 "copyright",
74 "deposit_policy",
75 "editorial",
76 "eissn",
77 "keywords",
78 "language",
79 "license",
80 "ref",
81 "pid_scheme",
82 "pissn",
83 "plagiarism",
84 "preservation",
85 "publication_time_weeks",
86 "publisher",
87 "oa_start",
88 "other_charges",
89 "waiver",
90 "title"
91 ],
92 "structs": {
93 "copyright": {
94 "required" : ["url"]
95 },
96 "editorial": {
97 "required" : ["review_process", "review_url"]
98 },
99 "plagiarism": {
100 "required": ["detection","url"]
101 },
102 "publisher": {
103 "required": ["name"]
104 },
105 "ref": {
106 "required" : ["journal"]
107 },
108 # override for lax currency code enforcement in the core, making it strict for incoming applications
109 "apc" : {
110 "lists" : {
111 "max" : {"contains" : "object"}
112 },
113 "structs" : {
114 "max" : {
115 "fields" : {
116 "currency" : {"coerce" : "currency_code_strict"},
117 "price" : {"coerce" : "integer"}
118 }
119 }
120 }
121 }
122 }
123 }
124 }
125}
128class IncomingApplication(SeamlessMixin, swagger.SwaggerSupport):
129 """
130 ~~APIIncomingApplication:Model->Seamless:Library~~
131 """
132 __type__ = "application"
133 __SEAMLESS_COERCE__ = dict(COERCE_MAP)
134 __SEAMLESS_STRUCT__ = [
135 # FIXME: Struct merge isn't an OVERRIDE, so we apply the strict checks first since they'll persist
136 # FIXME: can we live without specifying required fields, since the form validation will handle this?
137 INCOMING_APPLICATION_REQUIREMENTS,
138 OUTGOING_APPLICATION_STRUCT,
139 # FIXME: should this be here? It looks like it allows users to send administrative data to the system
140 # I have removed it as it was exposing incorrect data in the auto-generated documentation
141 # INTERNAL_APPLICATION_STRUCT,
142 _SHARED_STRUCT
143 ]
145 def __init__(self, raw=None, **kwargs):
146 if raw is None:
147 super(IncomingApplication, self).__init__(silent_prune=False, check_required_on_init=False, **kwargs)
148 else:
149 super(IncomingApplication, self).__init__(raw=raw, silent_prune=False, **kwargs)
151 @property
152 def data(self):
153 return self.__seamless__.data
155 def custom_validate(self):
156 # only attempt to validate if this is not a blank object
157 if len(list(self.__seamless__.data.keys())) == 0:
158 return
160 if _check_for_script(self.data):
161 raise dataobj.ScriptTagFoundException(Messages.EXCEPTION_SCRIPT_TAG_FOUND)
163 # extract the p/e-issn identifier objects
164 pissn = self.data["bibjson"]["pissn"]
165 eissn = self.data["bibjson"]["eissn"]
167 # check that at least one of them appears and if they are different
168 if pissn is None and eissn is None or pissn == eissn:
169 raise seamless.SeamlessException("You must specify at least one of bibjson.pissn and bibjson.eissn, and they must be different")
171 # normalise the ids
172 if pissn is not None:
173 pissn = self._normalise_issn(pissn)
174 if eissn is not None:
175 eissn = self._normalise_issn(eissn)
177 # check they are not the same
178 if pissn is not None and eissn is not None:
179 if pissn == eissn:
180 raise seamless.SeamlessException("Print ISSN and Online ISSN should be different")
182 # A link to the journal homepage is required
183 #
184 if self.data["bibjson"]["ref"]["journal"] is None or self.data["bibjson"]["ref"]["journal"] == "":
185 raise seamless.SeamlessException("You must specify the journal homepage in bibjson.ref.journal")
187 # if plagiarism detection is done, then the url is a required field
188 if self.data["bibjson"]["plagiarism"]["detection"] is True:
189 url = self.data["bibjson"]["plagiarism"]["url"]
190 if url is None:
191 raise seamless.SeamlessException("In this context bibjson.plagiarism.url is required")
193 # if licence_display is "embed", then the url is a required field #TODO: what with "display"
194 art = self.data["bibjson"]["article"]
195 if "embed" in art["license_display"] or "display" in art["license_display"]:
196 if art["license_display_example_url"] is None or art["license_display_example_url"] == "":
197 raise seamless.SeamlessException("In this context bibjson.article.license_display_example_url is required")
199 # if the author does not hold the copyright the url is optional, otherwise it is required
200 if self.data["bibjson"]["copyright"]["author_retains"] is not False:
201 if self.data["bibjson"]["copyright"]["url"] is None or self.data["bibjson"]["copyright"]["url"] == "":
202 raise seamless.SeamlessException("In this context bibjson.copyright.url is required")
204 # check the number of keywords is no more than 6
205 if len(self.data["bibjson"]["keywords"]) > 6:
206 raise seamless.SeamlessException("bibjson.keywords may only contain a maximum of 6 keywords")
208 def _normalise_issn(self, issn):
209 issn = issn.upper()
210 if len(issn) > 8: return issn
211 if len(issn) == 8:
212 if "-" in issn: return "0" + issn
213 else: return issn[:4] + "-" + issn[4:]
214 if len(issn) < 8:
215 if "-" in issn: return ("0" * (9 - len(issn))) + issn
216 else:
217 issn = ("0" * (8 - len(issn))) + issn
218 return issn[:4] + "-" + issn[4:]
220 def to_application_model(self, existing=None):
221 nd = deepcopy(self.data)
223 if existing is None:
224 return models.Suggestion(**nd)
225 else:
226 nnd = seamless.SeamlessMixin.extend_struct(self._struct, nd)
227 return models.Suggestion(**nnd)
229 @property
230 def id(self):
231 return self.__seamless__.get_single("id")
233 def set_id(self, id=None):
234 if id is None:
235 id = self.makeid()
236 self.__seamless__.set_with_struct("id", id)
238 def set_created(self, date=None):
239 if date is None:
240 date = dates.now_str()
241 self.__seamless__.set_with_struct("created_date", date)
243 @property
244 def created_date(self):
245 return self.__seamless__.get_single("created_date")
247 @property
248 def created_timestamp(self):
249 return self.__seamless__.get_single("created_date", coerce=coerce.to_datestamp())
251 def set_last_updated(self, date=None):
252 if date is None:
253 date = dates.now_str()
254 self.__seamless__.set_with_struct("last_updated", date)
256 @property
257 def last_updated(self):
258 return self.__seamless__.get_single("last_updated")
260 @property
261 def last_updated_timestamp(self):
262 return self.__seamless__.get_single("last_updated", coerce=coerce.to_datestamp())
264 def set_last_manual_update(self, date=None):
265 if date is None:
266 date = dates.now_str()
267 self.__seamless__.set_with_struct("last_manual_update", date)
269 @property
270 def last_manual_update(self):
271 return self.__seamless__.get_single("last_manual_update")
273 @property
274 def last_manual_update_timestamp(self):
275 return self.__seamless__.get_single("last_manual_update", coerce=coerce.to_datestamp())
277 def has_been_manually_updated(self):
278 lmut = self.last_manual_update_timestamp
279 if lmut is None:
280 return False
281 return lmut > datetime.utcfromtimestamp(0)
283 @property
284 def owner(self):
285 return self.__seamless__.get_single("admin.owner")
287 def set_owner(self, owner):
288 self.__seamless__.set_with_struct("admin.owner", owner)
290 def remove_owner(self):
291 self.__seamless__.delete("admin.owner")
293 @property
294 def editor_group(self):
295 return self.__seamless__.get_single("admin.editor_group")
297 def set_editor_group(self, eg):
298 self.__seamless__.set_with_struct("admin.editor_group", eg)
300 def remove_editor_group(self):
301 self.__seamless__.delete("admin.editor_group")
303 @property
304 def editor(self):
305 return self.__seamless__.get_single("admin.editor")
307 def set_editor(self, ed):
308 self.__seamless__.set_with_struct("admin.editor", ed)
310 def remove_editor(self):
311 self.__seamless__.delete('admin.editor')
313 def add_note(self, note, date=None, id=None, author_id=None,):
314 if date is None:
315 date = dates.now_str()
316 obj = {"date": date, "note": note, "id": id, "author_id": author_id}
317 self.__seamless__.delete_from_list("admin.notes", matchsub=obj)
318 if id is None:
319 obj["id"] = uuid.uuid4()
320 self.__seamless__.add_to_list_with_struct("admin.notes", obj)
322 def remove_note(self, note):
323 self.__seamless__.delete_from_list("admin.notes", matchsub=note)
325 def set_notes(self, notes):
326 self.__seamless__.set_with_struct("admin.notes", notes)
328 def remove_notes(self):
329 self.__seamless__.delete("admin.notes")
331 @property
332 def notes(self):
333 return self.__seamless__.get_list("admin.notes")
335 @property
336 def ordered_notes(self):
337 notes = self.notes
338 clusters = {}
339 for note in notes:
340 if note["date"] not in clusters:
341 clusters[note["date"]] = [note]
342 else:
343 clusters[note["date"]].append(note)
344 ordered_keys = sorted(list(clusters.keys()), reverse=True)
345 ordered = []
346 for key in ordered_keys:
347 clusters[key].reverse()
348 ordered += clusters[key]
349 return ordered
351 def bibjson(self):
352 bj = self.__seamless__.get_single("bibjson")
353 if bj is None:
354 self.__seamless__.set_single("bibjson", {})
355 bj = self.__seamless__.get_single("bibjson")
356 return JournalLikeBibJSON(bj)
358 def set_bibjson(self, bibjson):
359 bibjson = bibjson.data if isinstance(bibjson, JournalLikeBibJSON) else bibjson
360 self.__seamless__.set_with_struct("bibjson", bibjson)
363class OutgoingApplication(OutgoingCommonJournalApplication):
364 """
365 ~~APIOutgoingApplication:Model->APIOutgoingCommonJournalApplication:Model~~
366 ~~->Seamless:Library~~
367 """
368 __SEAMLESS_COERCE__ = dict(COERCE_MAP)
369 __SEAMLESS_STRUCT__ = [
370 OUTGOING_APPLICATION_STRUCT,
371 _SHARED_STRUCT
372 ]
374 def __init__(self, raw=None, **kwargs):
375 super(OutgoingApplication, self).__init__(raw, silent_prune=True, **kwargs)
377 @classmethod
378 def from_model(cls, application):
379 assert isinstance(application, models.Suggestion)
380 return super(OutgoingApplication, cls).from_model(application)
382 @property
383 def data(self):
384 return self.__seamless__.data