diff --git a/cms/data/notifications.yml b/cms/data/notifications.yml index e918780d80..99a9561210 100644 --- a/cms/data/notifications.yml +++ b/cms/data/notifications.yml @@ -128,12 +128,30 @@ bg:job:admin_reports:export_available: short: Report "{name}" ready for download +bg:job:approaching_flag_deadline:notify: + long: | + "The deadline for the flag in "{journal_title}" (id: {id}) is in 7 days." + short: + Deadline reminder + +flag:assigned:notify: + long: | + "A new flag in "{journal_title}" (id: {id}) has been assigned to you" + short: + New flag + journal:assed:assigned:notify: long: | The journal **{journal_name}** has been assigned to you by the Editor of your group **{group_name}**. Please start work on this within 10 days. short: New journal ({issns}) assigned to you +journal:maned:discontinuing_soon:notify: + long: | + Journal "{title}" (id: {id}) will discontinue in {days} days. + short: + Journal discontinuing + journal:editor_group:assigned:notify: long: | 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. @@ -186,9 +204,3 @@ update_request:publisher:submitted:notify: **This is an automated message.** short: Your update request ({issns}) has been submitted - -journal:maned:discontinuing_soon:notify: - long: | - Journal "{title}" (id: {id}) will discontinue in {days} days. - short: - Journal discontinuing diff --git a/dev.template.cfg b/dev.template.cfg index dd70ea82bb..32381ad6a8 100644 --- a/dev.template.cfg +++ b/dev.template.cfg @@ -87,7 +87,8 @@ HUEY_SCHEDULE = { "datalog_journal_added_update": CRON_NEVER, "auto_assign_editor_group_data": CRON_NEVER, "ris_export": CRON_NEVER, - "site_statistics": CRON_NEVER + "site_statistics": CRON_NEVER, + "approaching_flag_deadline": CRON_NEVER } ########################################### diff --git a/doajtest/testbook/flagged_journals/flagged_journals.yml b/doajtest/testbook/flagged_journals/flagged_journals.yml index ce135de75f..5bbb99a57c 100644 --- a/doajtest/testbook/flagged_journals/flagged_journals.yml +++ b/doajtest/testbook/flagged_journals/flagged_journals.yml @@ -8,24 +8,32 @@ tests: role: Admin steps: - step: Navigate to /testdrive/flags - - step: Login as an admin - - step: Open Feline Aerodynamics Review application (available on your dashboard) + - step: In a separate tab login as LordWiggleworth (admin) + - step: Open Feline Aerodynamics Review journal (available on your dashboard) results: - Above the notes there is the Add Flag button visible and active - step: Click Add Flag button results: - An empty flag form is displayed - - step: In assigned_to input attempt to add editor's id (you can find it in your testdrive/flags data) + - step: In assigned_to input attempt to add "MadamPonderleaf" (an editor) results: - No matches found is displayed and it is not possible to assign the editor - - step: In assigned_to input add random_user's id (you can find it in your testdrive/flags data) + - step: In assigned_to input add "ProfessorQuibbleton" (admin) results: - Id is found and it can be selected - - step: Select the random_user's id + - step: Select "ProfessorQuibbleton" - step: In deadline input add an improbable date (e.g., 2025-02-31) results: - It's not possible to enter an improbable date - - step: In deadline input add a valid date + - step: In deadline input add a date in a past + results: + - A warning "Provided deadline is in the past. Is it correct?" is displayed under the deadline input + - step: Save the form (do not close it) + results: + - Journal form is saved correctly + - step: In deadline input add a date in exactly 1 week + results: + - Warning disappears - step: In text area add flag's note (any text) - step: Save application results: @@ -72,6 +80,53 @@ tests: - In the flag form, in the Assign a User input, there is your id with a red flag icon - Resolve Flag button is active and all fields are editable + - title: Admin - Flags Notifications + context: + role: Admin + setup: + - If you haven't performed previous test (Admin - Add, Edit, Resolve) prepare the Journal by following all the steps; otherwise you may omit steps 2-9 + steps: + - step: Navigate to /testdrive/flags + - step: In a separate tab login as LordWiggleworth (admin) + - step: Open Feline Aerodynamics Review journal (available on your dashboard) + - step: Click Add Flag button + - step: Assign "ProfessorQuibbleton" (admin) + - step: Add deadline in exactly 1 week + - step: In text area add flag's note (any text) + - step: Save application, Unlock & Close the form + - step: Log out + - step: In a separate tab log in as "ProfessorQuibbleton" + results: + - At the top navbar Notifications indicate a new notification + - step: Hover over Notifications button + result: + - At the top of the list new notification "New flag" is listed + - step: Click on the notification + results: + - Feline Aerodynamics Review journal form is opened in a new tab + - On the right the flag is displayed and assigned to you ("ProfessorQuibbleton") + - step: Unlock & Close the form, close the tab + - step: Click the Notifications button at the top navigation bar + results: + - /dashboard/notifications opens + - At the top of the list "New flag" notification is displayed. + - The long description says "A new flag in 'Feline Aerodynamics Review' (id) has been assigned to you" + - step: Open the testdrive/flags page do not close the page where you are logged in + - step: Click "Run background task" button, wait for the task to execute + result: + - Message "Script executed successfully!" is displayed + - step: Go back to the tab where you are logged as ProfessorQuibbleton + - step: refresh the page + - step: navigate to /dashboard/notifications if not already there + results: + - At the top of the notifications list a new notification "Deadline reminder" is listed + - Long description says "The deadline for the flag in 'Feline Aerodynamics Review' (id) is in 7 days." + - At the top navbar Notifications button has a new notification marked + - step: Click "See action" link at the right of the new "Deadline reminder" notification + results: + - Feline Aerodynamics Review journal form is opened in a new tab + - step: Unlock & Close the form, close the tab + - title: Admin - Dashboard [NOT CURRENTLY USED] context: role: Admin @@ -129,7 +184,7 @@ tests: role: Editor steps: - step: Navigate to /testdrive/flags - - step: Login as an editor + - step: Login as MadamPonderleaf (editor) - step: Navigate to /applications - step: Open to Open Journal of Intergalactic Diplomacy - make sure you haven't resolved the flag while logged as an admin in one of the previous tests results: diff --git a/doajtest/testdrive/flags.py b/doajtest/testdrive/flags.py index 37a57fa6ed..bd11f8ac0f 100644 --- a/doajtest/testdrive/flags.py +++ b/doajtest/testdrive/flags.py @@ -1,4 +1,4 @@ -from doajtest.fixtures import ApplicationFixtureFactory +from doajtest.fixtures import JournalFixtureFactory from doajtest.testdrive.factory import TestDrive from portality.lib import dates from portality import models, constants @@ -8,9 +8,10 @@ class Flags(TestDrive): def __init__(self): self.another_eg = None - self.apps = [] + self.journals = [] self.admin_password = None self.admin = None + self.anotheradmin = None self.editor = None self.editor_password = None self.random_user = None @@ -18,14 +19,19 @@ def __init__(self): self.eg = None def setup(self) -> dict: - self.create_accounts() - self.build_applications() + random_str = self.create_random_str() + self.create_accounts(random_str) + self.build_journals(random_str) return { "accounts": { "admin": { "username": self.admin.id, "password": self.admin_password }, + "another admin": { + "username": self.anotheradmin.id, + "password": self.anotheradmin_password + }, "editor": { "username": self.editor.id, "password": self.editor_password @@ -35,30 +41,42 @@ def setup(self) -> dict: "password": self.random_user_password } }, - "applications": self.apps, + "journals": self.journals, "non_renderable": { "editor_groups": [self.eg.name, self.another_eg.name] + }, + "script": { + "script_name": "approaching_flag_deadline", + "title": "Run background task" } } - def create_accounts(self): - admin_name = self.create_random_str() + def create_accounts(self, random_str): + + admin_name = "LordWiggleworth_" + random_str self.admin_password = self.create_random_str() self.admin = models.Account.make_account(admin_name + "@example.com", admin_name, "FlagsManed " + admin_name, ["admin", "editor"]) self.admin.set_password(self.admin_password) self.admin.save() - random_name = self.create_random_str() + anotheradmin_name = "ProfessorQuibbleton_" + random_str + self.anotheradmin_password = self.create_random_str() + self.anotheradmin = models.Account.make_account(anotheradmin_name + "@example.com", anotheradmin_name, "Admin " + anotheradmin_name, + ["admin", "editor"]) + self.anotheradmin.set_password(self.anotheradmin_password) + self.anotheradmin.save() + + random_name = "BaronFeatherfall_" + random_str self.random_user_password = self.create_random_str() self.random_user = models.Account.make_account(random_name + "@example.com", random_name, - "FlagsManed " + random_name, + "Admin " + random_name, ["admin"]) self.random_user.set_password(self.random_user_password) self.random_user.save() - editor_name = self.create_random_str() - self.editor = models.Account.make_account(editor_name + "@example.com", editor_name, "editor " + editor_name, + editor_name = "MadamPonderleaf_" + random_str + self.editor = models.Account.make_account(editor_name + "@example.com", editor_name, "Editor " + editor_name, ["editor"]) self.editor_password = self.create_random_str() self.editor.set_password(self.editor_password) @@ -80,11 +98,11 @@ def create_accounts(self): self.another_eg.set_editor(self.editor.id) self.another_eg.save() - def build_applications(self): - applications = [ + def build_journals(self, random_str): + journals = [ { "type": models.Journal, - "title": "Journal of Quantum Homeopathy", + "title": "Journal of Quantum Homeopathy " + random_str, "assigned_to": self.admin.id, "flagged_to": self.admin.id, "group": self.eg.name, @@ -92,10 +110,8 @@ def build_applications(self): "note": "Peer review process unclear. The journal claims to use “ancient wisdom and telepathic consensus” to select papers. Should we request further clarification, or just accept that the universe decides?" }, { - "type": models.Application, - "title": "The Mars Agricultural Review", - "application_type": constants.APPLICATION_TYPE_NEW_APPLICATION, - "status": "in progress", + "type": models.Journal, + "title": "The Mars Agricultural Review " + random_str, "assigned_to": self.editor.id, "flagged_to": self.admin.id, "group": self.eg.name, @@ -103,19 +119,16 @@ def build_applications(self): "note": "Ethical concerns? Their conflict of interest statement is just 'Trust us.' Also, every editorial board member shares the same last name. Suspicious? Or just an enthusiastic family business?" }, { - "type": models.Application, - "title": "Cryptid Behavioral Studies Quarterly", - "application_type": constants.APPLICATION_TYPE_UPDATE_REQUEST, + "type": models.Journal, + "title": "Cryptid Behavioral Studies Quarterly " + random_str, "assigned_to": self.editor.id, "flagged_to": self.admin.id, "group": self.eg.name, "note": "Formatting issues. Their abstracts are in Comic Sans, their references are in Wingdings, and their figures appear to be hand-drawn with crayon. Surprisingly, it almost adds to the charm." }, { - "type": models.Application, - "title": "The Bermuda Triangle Journal of Lost and Found", - "application_type": constants.APPLICATION_TYPE_NEW_APPLICATION, - "status": "on hold", + "type": models.Journal, + "title": "The Bermuda Triangle Journal of Lost and Found " + random_str, "assigned_to": self.editor.id, "flagged_to": self.editor.id, "group": self.eg.name, @@ -123,14 +136,13 @@ def build_applications(self): }, { "type": models.Journal, - "title": "Feline Aerodynamics Review", + "title": "Feline Aerodynamics Review " + random_str, "assigned_to": self.admin.id, "group": self.eg.name }, { - "type": models.Application, - "title": "Journal of Intergalactic Diplomacy", - "application_type": constants.APPLICATION_TYPE_NEW_APPLICATION, + "type": models.Journal, + "title": "Journal of Intergalactic Diplomacy " + random_str, "assigned_to": self.random_user.id, "flagged_to": self.admin.id, "group": self.another_eg.name, @@ -138,9 +150,8 @@ def build_applications(self): "note": "Editorial process... innovative? They claim to have a 100% acceptance rate because “rejecting knowledge is against our values.” Admirable, but I feel like that’s not how this works." }, { - "type": models.Application, - "title": "Applied Alchemy & Unstable Chemistry", - "application_type": constants.APPLICATION_TYPE_UPDATE_REQUEST, + "type": models.Journal, + "title": "Applied Alchemy & Unstable Chemistry " + random_str, "assigned_to": self.random_user.id, "flagged_to": self.editor.id, "note": "Journal scope mismatch. The journal is called The International Review of Advanced Neuroscience but 90\% of its articles are about cat memes. Honestly, I’d subscribe, but should we approve it?", @@ -148,22 +159,16 @@ def build_applications(self): } ] - for record in applications: - source = ApplicationFixtureFactory.make_application_source() - ap = models.Application(**source) - if "application_type" in record: - source["admin"]["application_type"] = record["application_type"] + for record in journals: + source = JournalFixtureFactory.make_journal_source(True) + ap = models.Journal(**source) bj = ap.bibjson() bj.title = record["title"] ap.set_id(ap.makeid()) ap.set_last_manual_update(dates.today()) ap.set_created(dates.before_now(200)) - ap.remove_current_journal() - ap.remove_related_journal() ap.set_editor_group(record["group"]) ap.set_editor(record["assigned_to"]) - if "status" in record: - ap.set_application_status(record["status"]) if "flagged_to" in record: note = {"id": self.create_random_str(), "note": record["note"], @@ -176,14 +181,16 @@ def build_applications(self): }} ap.set_notes(note) ap.save() - self.apps.append(ap.id) + self.journals.append(ap.id) + + return self.journals def teardown(self, params): for acc in params.get("accounts").values(): models.Account.remove_by_id(acc["username"]) - for app in params.get("applications"): - models.Application.remove_by_id(app) + for app in params.get("journals"): + models.Journal.remove_by_id(app) print(params.get("non_renderable")) print(params.get("non_renderable").get("editor_groups")) diff --git a/doajtest/unit/event_consumers/test_flag_assigned.py b/doajtest/unit/event_consumers/test_flag_assigned.py new file mode 100644 index 0000000000..cdff3236b6 --- /dev/null +++ b/doajtest/unit/event_consumers/test_flag_assigned.py @@ -0,0 +1,53 @@ +from portality import models +from portality import constants +from portality.bll import exceptions +from doajtest.helpers import DoajTestCase +from portality.events.consumers.flag_assigned import FlagAssigned +from doajtest.fixtures import JournalFixtureFactory +import time + + +class TestFlagAssigned(DoajTestCase): + def setUp(self): + super(TestFlagAssigned, self).setUp() + + def tearDown(self): + super(TestFlagAssigned, self).tearDown() + + def test_should_consume(self): + event = models.Event(constants.EVENT_FLAG_ASSIGNED, context={"assignee": "rudolph", "journal": JournalFixtureFactory.make_journal_source()}) + assert FlagAssigned.should_consume(event) + + def test_consume_success(self): + with self._make_and_push_test_context_manager("/"): + + jsource = JournalFixtureFactory.make_journal_source() + j = models.Journal(**jsource) + j.save() + + acc = models.Account() + acc.set_id("LadyBranbury") + acc.set_email("ladybranbury@example.com") + acc.save() + + event = models.Event(constants.EVENT_FLAG_ASSIGNED, context={"journal": j.data, "assignee": acc.id}) + FlagAssigned.consume(event) + + time.sleep(1) + ns = models.Notification.all() + assert len(ns) == 1 + + n = ns[0] + assert n.who == "LadyBranbury" + assert n.created_by == FlagAssigned.ID + assert n.classification == constants.NOTIFICATION_CLASSIFICATION_ASSIGN + assert n.long is not None + assert n.short is not None + assert n.action is not None + assert not n.is_seen() + + def test_consume_fail(self): + event = models.Event(constants.EVENT_FLAG_ASSIGNED, context={"application": {'stuff': 'nonsense'}}) + with self.assertRaises(exceptions.NoSuchObjectException): + FlagAssigned.consume(event) + diff --git a/doajtest/unit/test_find_approaching_deadlines.py b/doajtest/unit/test_find_approaching_deadlines.py new file mode 100644 index 0000000000..19469f2c19 --- /dev/null +++ b/doajtest/unit/test_find_approaching_deadlines.py @@ -0,0 +1,52 @@ +import time + +from portality.constants import BgjobOutcomeStatus +from portality.lib import dates +from portality.core import app +from portality.tasks import approaching_flag_deadline +from portality.background import BackgroundApi +from doajtest.fixtures import JournalFixtureFactory +from portality.models import Journal +from doajtest.helpers import DoajTestCase +from portality.ui.messages import Messages + + +class TestFindApproachingDeadlines(DoajTestCase): + + def setUp(self): + self.journal_found_message = Messages.JOURNALS_WITH_APPROACHING_DEADLINES_FOUND[:15] + super(TestFindApproachingDeadlines, self).setUp() + + def tearDown(self): + super(TestFindApproachingDeadlines, self).tearDown() + + def test_1_success(self): + + jsource = JournalFixtureFactory.make_journal_source(in_doaj=True) + imaginary_agriculture = Journal(**jsource) + imaginary_agriculture.set_id("imaginary_agriculture") + imaginary_agriculture.bibjson().title = "International Journal of Imaginary Agriculture" + imaginary_agriculture.add_note("This is not very urgent", date=dates.today(), author_id="ProfessorSnifflepuff", assigned_to="DameQuacksalot", deadline=dates.format(dates.days_after_now(30), dates.FMT_DATE_STD)) + imaginary_agriculture.save(blocking=True) + + jsource2 = JournalFixtureFactory.make_journal_source(in_doaj=True) + unlikely_engineering = Journal(**jsource2) + unlikely_engineering.set_id("annalsofunlikelyengineering") + unlikely_engineering.bibjson().title = "Annals of Unlikely Engineering" + unlikely_engineering.add_note("This deadline is soon", date=dates.today(), author_id="ProfessorSnifflepuff", + assigned_to="DameQuacksalot", deadline=dates.format(dates.days_after_now(app.config.get('FLAG_APPROACHING_DEADLINE_DELTA', 7)), dates.FMT_DATE_STD)) + unlikely_engineering.save(blocking=True) + + + user = app.config.get("SYSTEM_USERNAME") + job = approaching_flag_deadline.ApproachingFlagDeadlineTask.prepare(user) + task = approaching_flag_deadline.ApproachingFlagDeadlineTask(job) + BackgroundApi.execute(task) + + job = task.background_job + assert job.status == "complete" + assert len(job.audit) > 2 + journal_found_log = [p["message"] for p in job.audit if p["message"].startswith(self.journal_found_message)] + assert len(journal_found_log) == 1 + assert journal_found_log[0].split(": ")[-1] == unlikely_engineering.id + assert job.outcome_status == BgjobOutcomeStatus.Success \ No newline at end of file diff --git a/portality/bll/services/events.py b/portality/bll/services/events.py index 27f3fbe7d6..0a214b6610 100644 --- a/portality/bll/services/events.py +++ b/portality/bll/services/events.py @@ -27,6 +27,7 @@ from portality.events.consumers.application_editor_acceptreject_notify import ApplicationEditorAcceptRejectNotify from portality.events.consumers.update_request_maned_editor_group_assigned_notify import UpdateRequestManedEditorGroupAssignedNotify from portality.events.consumers.article_ris_generator import ArticleRISGenerator +from portality.events.consumers.flag_assigned import FlagAssigned @@ -55,6 +56,7 @@ class EventsService(object): ApplicationPublisherCreatedNotify, ApplicationPublisherQuickRejectNotify, ArticleRISGenerator, + FlagAssigned, BGJobFinishedNotify, JournalDiscontinuingSoonNotify, UpdateRequestManedEditorGroupAssignedNotify, diff --git a/portality/constants.py b/portality/constants.py index a0cd15f521..b20a1d2646 100644 --- a/portality/constants.py +++ b/portality/constants.py @@ -85,6 +85,7 @@ EVENT_JOURNAL_EDITOR_GROUP_ASSIGNED = "journal:editor_group:assigned" EVENT_JOURNAL_DISCONTINUING_SOON = "journal:discontinuing_soon" EVENT_ARTICLE_SAVE = "article:save" +EVENT_FLAG_ASSIGNED = "flag:assigned" NOTIFICATION_CLASSIFICATION_STATUS = "alert" NOTIFICATION_CLASSIFICATION_STATUS_CHANGE = "status_change" diff --git a/portality/crosswalks/journal_form.py b/portality/crosswalks/journal_form.py index 0b3bed7404..d3556b48d8 100644 --- a/portality/crosswalks/journal_form.py +++ b/portality/crosswalks/journal_form.py @@ -32,6 +32,18 @@ def is_new_editor(cls, form, old): new_ed = form.editor.data return old_ed != new_ed and new_ed is not None and new_ed != "" + @classmethod + def is_new_flag_assignee(cls, form, old): + # This method assumes only one flag per journal + old_flags = old.flags + old_flag_assignee = old_flags[0]["flag"]["assigned_to"] if old_flags else None + new_flags = form.flags.data + new_flag_assignee = None + is_resolved = form.flags.data[0]["flag_resolved"] or form.flags.data[0]["flag_resolved"] == "false" + if not is_resolved and new_flags: + new_flag_assignee = form.flags.data[0]["flag_assignee"] + return old_flag_assignee != new_flag_assignee if new_flag_assignee else False + @classmethod def form_diff(cls, a_formdata, b_formdata): diff --git a/portality/events/consumers/flag_assigned.py b/portality/events/consumers/flag_assigned.py new file mode 100644 index 0000000000..55784786f8 --- /dev/null +++ b/portality/events/consumers/flag_assigned.py @@ -0,0 +1,51 @@ +# ~~ FlagAssigned:Consumer ~~ +from portality.ui.messages import Messages +from portality.util import url_for +from portality.events.consumer import EventConsumer +from portality import constants +from portality.bll import exceptions +from portality import models +from portality.bll import DOAJ + +class FlagAssigned(EventConsumer): + ID = "flag:assigned:notify" + + @classmethod + def should_consume(cls, event): + return event.id == constants.EVENT_FLAG_ASSIGNED and event.context.get("assignee") is not None + + @classmethod + def consume(cls, event): + assignee_id = event.context.get("assignee") + j_source = event.context.get("journal") + + try: + journal = models.Journal(**j_source) + except Exception as e: + raise exceptions.NoSuchObjectException( + Messages.EXCEPTION_UNABLE_TO_CONSTRUCT_JOURNAL.format(x=e)) + + acc = models.Account.pull(assignee_id) + + if not acc: + raise exceptions.NoSuchObjectException(Messages.EXCEPTION_NOTIFICATION_NO_ACCOUNT.format(x=assignee_id)) + + if not acc.email: + raise exceptions.NoSuchPropertyException(Messages.EXCEPTION_NOTIFICATION_NO_EMAIL.format(x=acc.id)) + + # ~~-> Notifications:Service ~~ + svc = DOAJ.notificationsService() + + notification = models.Notification() + notification.classification = constants.NOTIFICATION_CLASSIFICATION_ASSIGN + notification.who = acc.id + notification.created_by = cls.ID + notification.long = svc.long_notification(cls.ID).format( + journal_title=journal.bibjson().title, + id=journal.id) + + notification.short = svc.short_notification(cls.ID) + notification.action = url_for("admin.journal_page", journal_id=journal.id) + + svc.notify(notification) + return notification diff --git a/portality/forms/application_processors.py b/portality/forms/application_processors.py index 8e033d80bc..a2b8d7ff9c 100644 --- a/portality/forms/application_processors.py +++ b/portality/forms/application_processors.py @@ -882,6 +882,8 @@ def finalise(self, account=None): changed_by=changed_by) self.target.add_note(n, date=dates.now_str(), author_id=changed_by) + is_new_flag_assignee = JournalFormXWalk.is_new_flag_assignee(self.form, self.source) + # Save the target self.target.set_last_manual_update() self.target.save() @@ -911,6 +913,13 @@ def finalise(self, account=None): # self.add_alert("Problem sending email to associate editor - probably address is invalid") # app.logger.exception('Error sending assignment email to associate.') + if is_new_flag_assignee: + eventsSvc = DOAJ.eventsService() + eventsSvc.trigger(models.Event(constants.EVENT_FLAG_ASSIGNED, current_user.id, { + "assignee": self.target.flags[0]["flag"]["assigned_to"], + "journal": self.target.data + })) + def validate(self): # make use of the ability to disable validation, otherwise, let it run if self.form is not None: diff --git a/portality/scripts/approaching_flag_deadline.py b/portality/scripts/approaching_flag_deadline.py new file mode 100644 index 0000000000..9222b80ed2 --- /dev/null +++ b/portality/scripts/approaching_flag_deadline.py @@ -0,0 +1,16 @@ +from portality.core import app +from portality.tasks import approaching_flag_deadline +from portality.background import BackgroundApi + +def main(): + if app.config.get("SCRIPTS_READ_ONLY_MODE", False): + print("System is in READ-ONLY mode, script cannot run") + exit() + + user = app.config.get("SYSTEM_USERNAME") + job = approaching_flag_deadline.ApproachingFlagDeadlineTask.prepare(user) + task = approaching_flag_deadline.ApproachingFlagDeadlineTask(job) + BackgroundApi.execute(task) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/portality/settings.py b/portality/settings.py index 0b0c4fef8c..077ada9f11 100644 --- a/portality/settings.py +++ b/portality/settings.py @@ -32,6 +32,9 @@ # CAUTION - this can modify the index so should NEVER be used in production! TESTDRIVE_ENABLED = False +# List of script names which can be executed via the testdrive. +TESTDRIVE_SCRIPT_WHITELIST = ["approaching_flag_deadline"] + #################################### # Debug Mode @@ -451,6 +454,7 @@ "auto_assign_editor_group_data": {"month": "*", "day": "*/7", "day_of_week": "*", "hour": "3", "minute": "30"}, "ris_export": {"month": "*", "day": "15", "day_of_week": "*", "hour": "3", "minute": "30"}, "site_statistics": {"month": "*", "day": "*", "day_of_week": "*", "hour": "*", "minute": "40"}, + "approaching_flag_deadline": {"month": "*", "day": "*", "day_of_week": "*", "hour": "0", "minute": "45"}, } @@ -1582,6 +1586,9 @@ 'find_discontinued_soon': { 'last_run_successful_in': _DAY + _HOUR }, + 'approaching_flag_deadline': { + 'last_run_successful_in': _DAY + _HOUR + }, 'harvest': { 'last_run_successful_in': _DAY + _HOUR }, @@ -1628,6 +1635,12 @@ # report journals that discontinue in ... days (eg. 1 = tomorrow) DISCONTINUED_DATE_DELTA = 0 +#################################################### +# Flag management + +# find approaching deadlines in ... days (eg. 1 = tomorrow) +FLAG_APPROACHING_DEADLINE_DELTA = 7 + ################################################## # Feature tours currently active @@ -1758,4 +1771,4 @@ ################################################## # Object validation settings -SEAMLESS_JOURNAL_LIKE_SILENT_PRUNE = False \ No newline at end of file +SEAMLESS_JOURNAL_LIKE_SILENT_PRUNE = False diff --git a/portality/static/js/edges/admin.background_jobs.edge.js b/portality/static/js/edges/admin.background_jobs.edge.js index ed94d43aa6..ceead92ffc 100644 --- a/portality/static/js/edges/admin.background_jobs.edge.js +++ b/portality/static/js/edges/admin.background_jobs.edge.js @@ -44,10 +44,10 @@ $.extend(true, doaj, { // add the date added to doaj if (resultobj.created_date) { - dateRow += "Job Created: " + doaj.dates.humanYearMonth(resultobj.created_date) + "
"; + dateRow += "Job Created: " + resultobj.created_date + "
"; } if (resultobj.last_updated) { - dateRow += "Job Last Updated: " + doaj.dates.humanYearMonth(resultobj.last_updated) + "
"; + dateRow += "Job Last Updated: " + resultobj.last_updated + "
"; } var paramsBlock = ""; diff --git a/portality/static/js/edges/notifications.edge.js b/portality/static/js/edges/notifications.edge.js index 0b70fe52f1..6081b5c5c1 100644 --- a/portality/static/js/edges/notifications.edge.js +++ b/portality/static/js/edges/notifications.edge.js @@ -10,7 +10,7 @@ $.extend(true, doaj, { seen_url: "/dashboard/notifications/{notification_id}/seen", icons: { - alert: ` + alert: ` `, diff --git a/portality/tasks/approaching_flag_deadline.py b/portality/tasks/approaching_flag_deadline.py new file mode 100644 index 0000000000..9e0398b2f1 --- /dev/null +++ b/portality/tasks/approaching_flag_deadline.py @@ -0,0 +1,141 @@ +import time + +from portality.core import app +from portality.bll import DOAJ +from portality.lib import dates +from portality import models +from portality.util import url_for + +from portality.tasks.redis_huey import scheduled_short_queue as queue + +from portality.background import BackgroundTask, BackgroundApi +from portality.tasks.helpers import background_helper +from portality.ui.messages import Messages +from portality import constants + + +class ApproachingDeadlineQuery: + def __init__(self): + self._delta = app.config.get('FLAG_APPROACHING_DEADLINE_DELTA', 7) + self._ddate = dates.days_after_now(days=self._delta) + + def query(self): + return { + "query": { + "bool": { + "filter": { + "bool": { + "must": [ + {"term": {"index.most_urgent_flag_deadline": dates.format(self._ddate, format="%Y-%m-%d")}}, + {"term": {"admin.in_doaj": True}} + ] + } + } + } + } + } + + +# ~~FindFlagsWithApproachingDeadlineTask:Task~~ +class ApproachingFlagDeadlineTask(BackgroundTask): + __action__ = "approaching_flag_deadline" + + def __init__(self, job): + super(ApproachingFlagDeadlineTask, self).__init__(job) + self._delta = app.config.get('FLAG_APPROACHING_DEADLINE_DELTA', 7) + self._ddate = dates.days_after_now(days=self._delta) + + def find_journals_with_approaching_deadlines(self): + jdata = [] + + for journal in models.Journal.iterate(q=ApproachingDeadlineQuery().query(), keepalive='5m', wrap=True): + # ~~->Journal:Model~~ + jdata.append(journal.id) + self.background_job.add_audit_message(Messages.JOURNALS_WITH_APPROACHING_DEADLINES_FOUND.format(delta=self._delta, id=journal.id)) + + return jdata + + def run(self): + journals = self.find_journals_with_approaching_deadlines() + if len(journals): + for j in journals: + journal = models.Journal.pull(j) + assignee = journal.flags[0]["flag"]["assigned_to"] + + svc = DOAJ.notificationsService() + + acc = models.Account.pull(assignee) + if acc is None: + self.background_job.add_audit_message(Messages.EXCEPTION_NOTIFICATION_NO_ACCOUNT.format(x=assignee)) + continue + + notification = models.Notification() + notification.classification = constants.NOTIFICATION_CLASSIFICATION_STATUS + notification.who = acc.id + + source_id = "bg:job:" + self.__action__ + ":notify" + + notification.created_by = source_id + notification.long = svc.long_notification(source_id).format( + journal_title=journal.bibjson().title, + id=journal.id) + + notification.short = svc.short_notification(source_id) + notification.action = url_for("admin.journal_page", journal_id=journal.id) + + svc.notify(notification) + + self.background_job.add_audit_message(Messages.JOURNALS_WITH_APPROACHING_DEADLINES_FOUND_NOTIFICATION_SENT_LOG) + else: + self.background_job.add_audit_message(Messages.NO_JOURNALS_WITH_APPROACHING_DEADLINES_FOUND_LOG) + + def cleanup(self): + """ + Cleanup after a successful OR failed run of the task + :return: + """ + pass + + @classmethod + def prepare(cls, username, **kwargs): + """ + Take an arbitrary set of keyword arguments and return an instance of a BackgroundJob, + or fail with a suitable exception + + :param username: User account for this task to complete as + :param kwargs: arbitrary keyword arguments pertaining to this task type + :return: a BackgroundJob instance representing this task + """ + + # first prepare a job record + job = background_helper.create_job(username, cls.__action__, + queue_id=huey_helper.queue_id, ) + return job + + @classmethod + def submit(cls, background_job): + """ + Submit the specified BackgroundJob to the background queue + + :param background_job: the BackgroundJob instance + :return: + """ + background_job.save() + approaching_flag_deadline.schedule(args=(background_job.id,), delay=app.config.get('HUEY_ASYNC_DELAY', 10)) + + +huey_helper = ApproachingFlagDeadlineTask.create_huey_helper(queue) + + +@huey_helper.register_schedule +def scheduled_approaching_flag_deadline(): + user = app.config.get("SYSTEM_USERNAME") + job = ApproachingFlagDeadlineTask.prepare(user) + ApproachingFlagDeadlineTask.submit(job) + + +@huey_helper.register_execute(is_load_config=False) +def approaching_flag_deadline(job_id): + job = models.BackgroundJob.pull(job_id) + task = ApproachingFlagDeadlineTask(job) + BackgroundApi.execute(task) diff --git a/portality/tasks/consumer_events_queue.py b/portality/tasks/consumer_events_queue.py index 2775834758..8ccc093806 100644 --- a/portality/tasks/consumer_events_queue.py +++ b/portality/tasks/consumer_events_queue.py @@ -21,7 +21,8 @@ from portality.tasks.journal_in_out_doaj import set_in_doaj # noqa from portality.tasks.suggestion_bulk_edit import suggestion_bulk_edit # noqa from portality.tasks.admin_reports import admin_reports # noqa -from portality.tasks.process_event import process_event_execute +from portality.tasks.process_event import process_event_execute # noqa +from portality.tasks.approaching_flag_deadline import approaching_flag_deadline # noqa # Conditionally enable new application autochecking if app.config.get("AUTOCHECK_INCOMING", False): diff --git a/portality/templates-v2/dev/testdrive/testdrive.html b/portality/templates-v2/dev/testdrive/testdrive.html index 49770cb6a2..0d435fc5db 100644 --- a/portality/templates-v2/dev/testdrive/testdrive.html +++ b/portality/templates-v2/dev/testdrive/testdrive.html @@ -10,7 +10,7 @@

{{ name }} - Testdrive setup results

-{% macro render_dict(d) %} +{%- macro render_dict(d) %}
{% for k, v in d.items() %}
{{ k }}
@@ -35,10 +35,10 @@

{{ name }} - Testdrive setup results

{% endfor %}
-{% endmacro %} +{% endmacro -%} -{% for k, v in params.items() %} - {% if k != "teardown" and k != "non_renderable" %} +{%- for k, v in params.items() %} + {% if k != "teardown" and k != "non_renderable" and k!= "script" %}

{{ k }}

{% if v is mapping %} @@ -68,7 +68,83 @@

{{ k }}

{% endif %}
{% endif %} -{% endfor %} +{% endfor -%} + +{% if "script" in params %} + + + + +{% endif %}

Teardown

diff --git a/portality/ui/messages.py b/portality/ui/messages.py index e487f09ed6..13e351d07f 100644 --- a/portality/ui/messages.py +++ b/portality/ui/messages.py @@ -74,10 +74,16 @@ class Messages(object): EXCEPTION_EDITING_DELETED_JOURNAL = "This journal has been deleted, update request cannot be accepted." EXCEPTION_EDITING_NON_EXISTING_APPLICATION = "You cannot edit a not-existent application" - EXCEPTION_NOTIFICATION_NO_ACCOUNT = "Account with id {x} not found" + EXCEPTION_NOTIFICATION_NO_ACCOUNT = "Account with id `{x}` not found" EXCEPTION_NOTIFICATION_NO_EMAIL = "Account with id {x} does not have an email address" EXCEPTION_NOTIFICATION_NO_NOTIFICATION = "Notification with id {n} does not exist" + EXCEPTION_NOTIFICATION_JOURNAL_NOT_FOUND = "Journal with id {x} not found" + + EXCEPTION_UNABLE_TO_CONSTRUCT_JOURNAL = "Unable to construct Journal from supplied source - data structure validation error, {x}" + EXCEPTION_UNABLE_TO_CONSTRUCT_ACCOUNT = "Unable to construct Account from supplied source - data structure validation error, {x}" + + PREVENT_DEEP_PAGING_IN_API = """You cannot access results beyond {max_records} records via this API. If you would like to see more results, you can download all of our data from {data_dump_url}. You can also harvest from our OAI-PMH endpoints; articles: {oai_article_url}, journals: {oai_journal_url}""" @@ -118,6 +124,10 @@ class Messages(object): DISCONTINUED_JOURNALS_FOUND_NOTIFICATION_ERROR_LOG = "Error sending notification with journals discontinuing soon." NO_DISCONTINUED_JOURNALS_FOUND_LOG = "No journals discontinuing soon found" + JOURNALS_WITH_APPROACHING_DEADLINES_FOUND = "Journal with deadline in {delta} days found: {id}" + JOURNALS_WITH_APPROACHING_DEADLINES_FOUND_NOTIFICATION_SENT_LOG = "Notification with approaching deadlines sent." + NO_JOURNALS_WITH_APPROACHING_DEADLINES_FOUND_LOG = "No approaching deadlines found." + FORMS__APPLICATION_STATUS__PENDING = "Pending" FORMS__APPLICATION_STATUS__IN_PROGRESS = 'In Progress' FORMS__APPLICATION_STATUS__COMPLETED = 'Completed' diff --git a/portality/ui/templates.py b/portality/ui/templates.py index d539cd6cf7..4f530df63c 100644 --- a/portality/ui/templates.py +++ b/portality/ui/templates.py @@ -113,4 +113,4 @@ EMAIL_WF_ADMIN_READY = "email/workflow_reminder_fragments/admin_ready_frag.jinja2" EMAIL_WF_ASSED_AGE = "email/workflow_reminder_fragments/assoc_ed_age_frag.jinja2" EMAIL_WF_EDITOR_AGE = "email/workflow_reminder_fragments/editor_age_frag.jinja2" -EMAIL_WF_EDITOR_GROUPCOUNT = "email/workflow_reminder_fragments/editor_groupcount_frag.jinja2" \ No newline at end of file +EMAIL_WF_EDITOR_GROUPCOUNT = "email/workflow_reminder_fragments/editor_groupcount_frag.jinja2" diff --git a/portality/view/testdrive.py b/portality/view/testdrive.py index 70d4a32427..273e42e14e 100644 --- a/portality/view/testdrive.py +++ b/portality/view/testdrive.py @@ -1,4 +1,6 @@ -from flask import Blueprint, make_response, abort, url_for, request, render_template +import importlib + +from flask import Blueprint, make_response, abort, url_for, request, render_template, jsonify from flask_login import current_user, login_required from doajtest.testdrive.factory import TestFactory from portality import util @@ -40,4 +42,16 @@ def teardown(test_id): result = test.teardown(json.loads(request.values.get("d"))) resp = make_response(json.dumps(result)) resp.mimetype = "application/json" - return resp \ No newline at end of file + return resp + +@blueprint.route('/run', methods=['GET', 'POST']) +@util.jsonp +@login_required +def run_script(): + data = request.get_json() + script_name = data.get('script_name') + if script_name not in app.config.get("TESTDRIVE_SCRIPT_WHITELIST"): + return jsonify({'error': 'Invalid script'}), 400 + module = importlib.import_module(f'portality.scripts.{script_name}') + module.main() + return jsonify({'status': 'success', 'received': script_name}) \ No newline at end of file diff --git a/test.cfg b/test.cfg index 63b495583d..74f03797ba 100644 --- a/test.cfg +++ b/test.cfg @@ -59,7 +59,8 @@ HUEY_SCHEDULE = { "datalog_journal_added_update": {"month": "*", "day": "*", "day_of_week": "*", "hour": "*", "minute": "*/30"}, "auto_assign_editor_group_data": {"month": "*", "day": "*/7", "day_of_week": "*", "hour": "3", "minute": "30"}, "ris_export": CRON_NEVER, - "site_statistics": {"month": "*", "day": "*", "day_of_week": "*", "hour": "*", "minute": "40"} + "site_statistics": {"month": "*", "day": "*", "day_of_week": "*", "hour": "*", "minute": "40"}, + "approaching_flag_deadline": {"month": "*", "day": "*", "day_of_week": "*", "hour": "0", "minute": "45"}, } # =======================