diff --git a/cms/data/motivational_banners.yml b/cms/data/motivational_banners.yml new file mode 100644 index 0000000000..e13d52bfe7 --- /dev/null +++ b/cms/data/motivational_banners.yml @@ -0,0 +1,14 @@ +banners: + zero_count: + - "You haven't completed any applications this year yet. Start by choosing one from + the list below." + positive_count: + - "Whoa! {{ COUNT }} applications already? You’re crushing it!" + - "{{ COUNT }} applications down already? You’re on fire! Keep it up, superstar!" + - "{{ COUNT }} applications down, many more to go! Keep rocking!" + - "{{ COUNT }} applications completed? You’re making it look easy!" + - "{{ COUNT }} applications down and still going strong!" + - "Wait, you’ve completed {{ COUNT }} applications this year already? You’re on fire!" + - "Impressive! You’ve already completed {{ COUNT }} applications this year!" + - "{{ COUNT }} applications in the bag this year! You’re smashing it!" + - "Congratulations, you have completed {{ COUNT }} applications this year so far! Keep it up!" \ No newline at end of file diff --git a/cms/sass/components/_alert.scss b/cms/sass/components/_alert.scss index 47f841bdb5..84992cad36 100644 --- a/cms/sass/components/_alert.scss +++ b/cms/sass/components/_alert.scss @@ -26,6 +26,14 @@ border: 2px solid $grapefruit; } + &--success { + border: 2px solid $dark-green; + } + + &--info { + border: 2px solid $dark-grey; + } + code { background-color: rgba($dark-grey, 0.1); } diff --git a/cms/sass/components/_motivational-banner.scss b/cms/sass/components/_motivational-banner.scss new file mode 100644 index 0000000000..583a10021e --- /dev/null +++ b/cms/sass/components/_motivational-banner.scss @@ -0,0 +1,13 @@ +.motivational-banner { + width: 100%; + + h3 { + text-align: center; + margin-bottom: 0; + + .tag { + margin: 0 $spacing-02; + } + } + +} \ No newline at end of file diff --git a/cms/sass/components/_progress-bar.scss b/cms/sass/components/_progress-bar.scss index fb1e4823a8..c6aa9195bc 100644 --- a/cms/sass/components/_progress-bar.scss +++ b/cms/sass/components/_progress-bar.scss @@ -10,39 +10,10 @@ align-items: center; padding: 0 $spacing-03; height: 50px; - text-decoration: none; - color: $warm-black; - // truncate text if it’s too long — user will need to hover to see full info white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + color: inherit; + text-decoration: inherit; } -} - -.progress-bar__bar--pending { - background-color: $yellow; -} - -.progress-bar__bar--in-progress { - background-color: $light-green; -} - -.progress-bar__bar--completed { - background-color: $mid-green; - - .progress-bar__link { - color: $white; - } -} - -.progress-bar__bar--ready { - background-color: $dark-green; - - .progress-bar__link { - color: $white; - } -} - -.progress-bar__bar--on-hold { - background-color: $light-grey; } \ No newline at end of file diff --git a/cms/sass/main.scss b/cms/sass/main.scss index 0f72c8eac8..cfadfb6d72 100644 --- a/cms/sass/main.scss +++ b/cms/sass/main.scss @@ -43,6 +43,7 @@ "components/loading", "components/logo", "components/modal", + "components/motivational-banner", "components/notifications", "components/numbered-table", "components/pager-buttons", @@ -72,5 +73,6 @@ "themes/dashboard", "themes/editorial-form", - "themes/timeline" + "themes/timeline", + "themes/activity-section" ; diff --git a/cms/sass/themes/_activity-section.scss b/cms/sass/themes/_activity-section.scss new file mode 100644 index 0000000000..9cd3a26159 --- /dev/null +++ b/cms/sass/themes/_activity-section.scss @@ -0,0 +1,54 @@ + +.activity-section { + + .status { + + text-decoration: none; + + &--link { + &:hover, + &:focus, + &:active { + color: $grapefruit; + text-decoration: none; + } + } + + &--pending { + background-color: $yellow; + color: $warm-black; + } + + &--in-progress { + background-color: $light-green; + color: $warm-black; + } + + &--completed { + background-color: $mid-green; + color: $warm-black; + } + + &--ready { + background-color: $dark-green; + color: $white; + } + + &--on-hold { + background-color: $light-grey; + color: $warm-black; + } + } + + .color-legend { + font-size: 18px; + + li:not(:first-child) { + margin-left: $spacing-01; + } + + a { + padding: $spacing-02; + } + } +} \ No newline at end of file diff --git a/cms/tours/application_by_status.yml b/cms/tours/application_by_status.yml new file mode 100644 index 0000000000..88671b7504 --- /dev/null +++ b/cms/tours/application_by_status.yml @@ -0,0 +1,13 @@ +steps: + - selector: '#color-legend' + title: Colour legend links + content: +

+ Each label in the colour legend now works as a link!
+ Clicking a label will take you to the search page, filtered by the currently viewed group + and the specific application status. +

+ + - selector: '#feature_tour_nav' + title: Want to see this tip again? + content: Take the tour again by selecting it from the Feature Tours menu. \ No newline at end of file diff --git a/doajtest/testbook/dashboard/editorial_group_status.yml b/doajtest/testbook/dashboard/editorial_group_status.yml index 4cf4b49a7b..d1853a44d0 100644 --- a/doajtest/testbook/dashboard/editorial_group_status.yml +++ b/doajtest/testbook/dashboard/editorial_group_status.yml @@ -28,6 +28,13 @@ tests: to this group. - The number of search results is the same as shown on the dashboard - step: Go back to the dashboard page + - step: Click on one of the statuses in the colour legend under "Open Applications" header (you may want to + right click to keep the dashboard tab open during this test) + results: + - You are taken to the application search which shows the open applications in + that status + - The number of search results is the same as shown on the dashboard + - step: Go back to the dashboard page - step: Click on an editor's name results: - A mail window opens in your mail client diff --git a/doajtest/testbook/dashboard/historical_stats.yml b/doajtest/testbook/dashboard/historical_stats.yml new file mode 100644 index 0000000000..603aab4ee2 --- /dev/null +++ b/doajtest/testbook/dashboard/historical_stats.yml @@ -0,0 +1,94 @@ +suite: Dashboard +testset: Historical Statistics +tests: + - title: Historical Statistics Display - Managing Editor with 3 groups + context: + role: admin + testdrive: statistics + steps: + - step: Navigate to "/testdrive/statistics" and wait for the page to load slowly. This should be done once at the beginning of the test session and not refreshed between individual tests. + - step: Log in with provided managing_editor credentials. + results: + - User dashboard is displayed. + - step: Verify historical statistics in the Activity section at the bottom. + results: + - Three groups are displayed. + - Only associate editors are shown in the Activity section for each group. + - Each group has the correct statistics for the current year. + - The number of READY/COMPLETED applications matches the "additional information" table. + - step: Log out. + - step: (Optional) Click the link at the bottom of the testdrive page to free resources after finishing this test session. + - title: Historical Statistics Display - Editor with 2 groups + context: + role: editor + testdrive: statistics + steps: + - step: Navigate to "/testdrive/statistics" and wait for the page to load slowly. This should be done once at the beginning of the test session and not refreshed between individual tests. + - step: Log in with provided editor_1 credentials. + results: + - User dashboard is displayed. + - step: Verify the motivational banner at the top of the dashboard (green) and check number of finished applications. + results: + - The motivational banner is GREEN and shows the correct sum of applications finished in both groups. + - step: Verify historical statistics in the Activity section at the bottom. + results: + - Two groups are displayed. + - Only associate editors are shown in the Activity section for each group. + - Each group has the correct statistics for the current year. + - The number of READY/COMPLETED applications matches the "additional information" table. + - step: Log out. + - step: (Optional) Click the link at the bottom of the testdrive page to free resources after finishing this test session. + - title: Historical Statistics Display - Editor with 1 group + context: + role: editor + testdrive: statistics + steps: + - step: Navigate to "/testdrive/statistics" and wait for the page to load slowly. This should be done once at the beginning of the test session and not refreshed between individual tests. + - step: Log in with provided editor_2 credentials. + results: + - User dashboard is displayed. + - step: Verify the motivational banner at the top of the dashboard (green) and check number of finished applications. + results: + - The motivational banner is GREEN and shows the correct sum of applications finished in the group. + - step: Verify historical statistics in the Activity section at the bottom. + results: + - One group is displayed. + - Only associate editors are shown in the Activity section for the group. + - The group has the correct statistics for the current year. + - The number of READY/COMPLETED applications matches the "additional information" table. + - step: Log out. + - step: (Optional) Click the link at the bottom of the testdrive page to free resources after finishing this test session. + - title: Historical Statistics Display - Associate Editors with 0 finished applications + context: + role: associate editor + testdrive: statistics + steps: + - step: Navigate to "/testdrive/statistics" and wait for the page to load slowly. This should be done once at the beginning of the test session and not refreshed between individual tests. + - step: Log in with provided associate_editor_1 credentials. + results: + - User dashboard is displayed. + - step: Verify the motivational banner at the top of the dashboard (grey). + results: + - The motivational banner is GREY with appropriate information about no finished applications. + - step: Verify no statistics are displayed at the bottom of the page. + results: + - No statistics are displayed in the Activity section. + - step: Log out. + - step: + - title: Historical Statistics Display - Associate Editor 2 + context: + role: associate editor + testdrive: statistics + steps: + - step: Navigate to "/testdrive/statistics" and wait for the page to load slowly. This should be done once at the beginning of the test session and not refreshed between individual tests. + - step: Log in with provided associate_editor_2 credentials. + results: + - User dashboard is displayed. + - step: Verify the motivational banner at the top of the dashboard (green). + results: + - The motivational banner is GREEN. + - step: Verify no statistics are displayed at the bottom of the page. + results: + - No statistics are displayed in the Activity section. + - step: Log out. + - step: (Optional) Click the link at the bottom of the testdrive page to free resources after finishing this test session. diff --git a/doajtest/testdrive/statistics.py b/doajtest/testdrive/statistics.py new file mode 100644 index 0000000000..bc78f440cf --- /dev/null +++ b/doajtest/testdrive/statistics.py @@ -0,0 +1,201 @@ +from doajtest.testdrive.factory import TestDrive +from portality import constants +from portality import models +from doajtest.fixtures import EditorGroupFixtureFactory +import string +import random +import json + +class Statistics(TestDrive): + """ + Creates a group structure for statistics testdrive: + 3 groups, each with the same Man Ed, 2 groups with the same editor, each with different number of AssEds + """ + + NUMBER_OF_GROUPS = 3 + NUMBER_OF_EDITORS = 2 + NUMBER_OF_ASSEDITORS = 8 + + FINISHED_APPLICATIONS = [ + {'role': 'editor', 'group': 0, 'index': 0, 'count': 2}, + {'role': 'editor', 'group': 1, 'index': 0, 'count': 3}, + {'role': 'editor', 'group': 2, 'index': 1, 'count': 3}, + {'role': 'assed', 'group': 0, 'index': 0, 'count': 0}, + {'role': 'assed', 'group': 0, 'index': 1, 'count': 4}, + {'role': 'assed', 'group': 1, 'index': 2, 'count': 0}, + {'role': 'assed', 'group': 1, 'index': 3, 'count': 5}, + {'role': 'assed', 'group': 1, 'index': 4, 'count': 3}, + {'role': 'assed', 'group': 2, 'index': 5, 'count': 7}, + {'role': 'assed', 'group': 2, 'index': 6, 'count': 4}, + {'role': 'assed', 'group': 2, 'index': 7, 'count': 2}, + ] + + # Define editor groups and associate editors' associations + EDITOR_GROUPS = { + 'eg0': {'editor_index': 0, 'start_assed_index': 0, 'end_assed_index': 2}, + 'eg1': {'editor_index': 0, 'start_assed_index': 2, 'end_assed_index': 5}, + 'eg2': {'editor_index': 1, 'start_assed_index': 5, 'end_assed_index': 8}, + } + + def setup(self) -> dict: + self.createAccounts() + self.createGroups() + self.createProvenanceData() + return { + "admin": { + "username": self.admin, + "password": self.admin_pass + }, + "editor_1": { + "username": self.editors[0]["id"], + "password": self.editors[0]["pass"] + }, + "editor_2": { + "username": self.editors[1]["id"], + "password": self.editors[1]["pass"] + }, + "associate_editor_1": { + "username": self.asseds[0]["id"], + "password": self.asseds[0]["pass"] + }, + "associate_editor_2": { + "username": self.asseds[1]["id"], + "password": self.asseds[1]["pass"] + }, + "finished_applications": self.finished_by_user, + "non_renderable": { + "asseds": [assed['id'] for assed in + self.asseds[2:]], + "groups": self.groups, + "provenance": self.provenance_data + } + } + + def create_random_str(self, n_char=10): + s = string.ascii_letters + string.digits + return ''.join(random.choices(s, k=n_char)) + + def createAccounts(self): + un = self.create_random_str() + pw1 = self.create_random_str() + admin = models.Account.make_account(un + "@example.com", un, "Admin " + un, [constants.ROLE_ADMIN]) + admin.set_password(pw1) + admin.save() + self.admin = admin.id + self.admin_pass = pw1 + + # Create editors accounts + self.editors = [] + for i in range(self.NUMBER_OF_EDITORS): + us = self.create_random_str() + pw = self.create_random_str() + editor = models.Account.make_account(us + "@example.com", us, "Editor" + str(i+1) + " " + us, + [constants.ROLE_EDITOR]) + editor.set_password(pw) + editor.save() + self.editors.append({"id": editor.id, "pass": pw}) + + # Create associate editors accounts + self.asseds = [] + for i in range(self.NUMBER_OF_ASSEDITORS): + us = self.create_random_str() + pw = self.create_random_str() + assed = models.Account.make_account(us + "@example.com", us, "AssEd" + str(i+1) + " " + us, + [constants.ROLE_ASSOCIATE_EDITOR]) + assed.set_password(pw) + assed.save() + self.asseds.append({"id": assed.id, "pass": pw}) + + def createGroups(self): + + self.groups = [] + for group_key, group_info in self.EDITOR_GROUPS.items(): + eg_source = EditorGroupFixtureFactory.make_editor_group_source(group_name=group_key, maned=self.admin, + editor= + self.editors[group_info['editor_index']][ + "id"]) + del eg_source["associates"] # these will be added in a moment + editor_group = models.EditorGroup(**eg_source) + editor_group.set_id(group_key) + ids_to_set = [assed['id'] for assed in + self.asseds[group_info['start_assed_index']:group_info['end_assed_index']]] + editor_group.set_associates(ids_to_set) + editor_group.save() + self.groups.append(editor_group.id) + + def createProvenanceData(self): + + self.finished_by_user = {} + self.provenance_data = [] + role_mapping = { + "editor": { + "array": self.editors, + "status": constants.APPLICATION_STATUS_READY + }, + "assed": { + "array": self.asseds, + "status": constants.APPLICATION_STATUS_COMPLETED + } + } + + for entry in self.FINISHED_APPLICATIONS: + role = entry["role"] + idx = entry['index'] + count = entry['count'] + group_key = "eg" + str(entry['group']) + + users = role_mapping[role]["array"] + app_status = role_mapping[role]["status"] + + status = "status:" + app_status + user_id = users[idx]["id"] + user_name_with_eg = user_id + " (" + role + " in " + group_key + ")" + + self.finished_by_user[user_name_with_eg] = str(count) + + for i in range(count): + p = self.add_provenance_record(status, role, user_id, group_key) + self.provenance_data.append(p.id) + + def add_provenance_record(self, status, role, user, editor_group): + data = { + "user": user, + "roles": [role], + "type": "suggestion", + "action": status, + "editor_group": [editor_group] + } + p = models.Provenance(**data) + p.save() + print(p.id) + return p + + def teardown(self, params): + for key, obj in params.items(): + print(key) + if key != "non_renderable" and key != "finished_applications": + models.Account.remove_by_id(obj["username"]) + + non_renderable = params.get("non_renderable", {}) + + for assed in non_renderable.get("asseds", []): + models.Account.remove_by_id(assed) + + for provenance_id in non_renderable.get("provenance", []): + models.Provenance.remove_by_id(provenance_id) + + for group in non_renderable.get("groups", []): + models.Provenance.remove_by_id(group) + + return {"success": True} + + +if __name__ == "__main__": + structure = Statistics() + params = structure.setup() + print(structure.admin) + print(structure.editors) + print(structure.asseds) + print(structure.groups) + print(structure.provenance_data) + structure.teardown(params) diff --git a/doajtest/unit/test_bll_todo_top_todo_maned.py b/doajtest/unit/test_bll_todo_top_todo_maned.py index 1c5ae4457d..2c0dd07fc5 100644 --- a/doajtest/unit/test_bll_todo_top_todo_maned.py +++ b/doajtest/unit/test_bll_todo_top_todo_maned.py @@ -220,4 +220,60 @@ def build_application(self, id, lmu_diff, cd_diff, status, app_registry, additio additional_fn(ap) ap.save() - app_registry.append(ap) \ No newline at end of file + app_registry.append(ap) + + def test_historical_count(self): + EDITOR_GROUP_SOURCE = EditorGroupFixtureFactory.make_editor_group_source() + eg = models.EditorGroup(**EDITOR_GROUP_SOURCE) + maned = models.Account(**AccountFixtureFactory.make_managing_editor_source()) + + EDITOR_SOURCE = AccountFixtureFactory.make_editor_source() + ASSED1_SOURCE = AccountFixtureFactory.make_assed1_source() + ASSED2_SOURCE = AccountFixtureFactory.make_assed2_source() + ASSED3_SOURCE = AccountFixtureFactory.make_assed3_source() + editor = models.Account(**EDITOR_SOURCE) + assed1 = models.Account(**ASSED1_SOURCE) + assed2 = models.Account(**ASSED2_SOURCE) + assed3 = models.Account(**ASSED3_SOURCE) + editor.save(blocking=True) + assed1.save(blocking=True) + assed2.save(blocking=True) + assed3.save(blocking=True) + eg.set_maned(maned.id) + eg.set_editor(editor.id) + eg.set_associates([assed1.id, assed2.id, assed3.id]) + eg.save(blocking=True) + + self.add_provenance_record("status:" + constants.APPLICATION_STATUS_READY, "editor", editor.id, eg) + self.add_provenance_record("status:" + constants.APPLICATION_STATUS_COMPLETED, "associate_editor", assed1.id, eg) + self.add_provenance_record("status:" + constants.APPLICATION_STATUS_COMPLETED, "associate_editor", assed2.id, eg) + self.add_provenance_record("status:" + constants.APPLICATION_STATUS_COMPLETED, "associate_editor", assed3.id, eg) + + stats = self.svc.group_finished_historical_counts(eg) + + self.assertEqual(stats["year"], dates.now_str(dates.FMT_YEAR)) + self.assertEqual(stats["editor"]["id"], editor.id) + self.assertEqual(stats["editor"]["count"], 1) + + associate_editors = [assed1.id, assed2.id, assed3.id] + + for assed in stats["associate_editors"]: + self.assertTrue(assed["id"] in associate_editors) + self.assertEqual(assed["count"], 1) + + editor_count = self.svc.user_finished_historical_counts(editor) + self.assertEqual(editor_count, 1) + assed_count = self.svc.user_finished_historical_counts(assed1) + self.assertEqual(assed_count, 1) + + + def add_provenance_record(self, status, role, user, editor_group): + data = { + "user": user, + "roles": [role], + "type": "suggestion", + "action": status, + "editor_group": [editor_group.id] + } + p1 = models.Provenance(**data) + p1.save(blocking=True) \ No newline at end of file diff --git a/portality/bll/services/todo.py b/portality/bll/services/todo.py index 25a62a2c71..bf11ce6835 100644 --- a/portality/bll/services/todo.py +++ b/portality/bll/services/todo.py @@ -15,7 +15,7 @@ class TodoService(object): def group_stats(self, group_id): # ~~-> EditorGroup:Model~~ eg = models.EditorGroup.pull(group_id) - stats = {"editor_group" : eg.data} + stats = {"editor_group": eg.data} #~~-> Account:Model ~~ stats["editors"] = {} @@ -23,8 +23,8 @@ def group_stats(self, group_id): for editor in editors: acc = models.Account.pull(editor) stats["editors"][editor] = { - "email" : None if acc is None else acc.email - } + "email": None if acc is None else acc.email + } q = GroupStatsQuery(eg.name) resp = models.Application.query(q=q.query()) @@ -62,8 +62,67 @@ def group_stats(self, group_id): elif b["key"] == constants.APPLICATION_TYPE_UPDATE_REQUEST: stats["by_status"][bucket["key"]]["update_requests"] = b["doc_count"] + stats["historical_numbers"] = self.group_finished_historical_counts(eg) + return stats + def group_finished_historical_counts(self, editor_group: models.EditorGroup, year=None): + """ + Get the count of applications in an editor group where + Associate Editors set to Completed when they have done their review + Editors set them to Ready + in a given year (current by default) + :param editor_group + :param year + :return: historical for editor and associate editor in dict + """ + year_for_query = dates.now_str(dates.FMT_YEAR) if year is None else year + editor_status = "status:" + constants.APPLICATION_STATUS_READY + associate_status = "status:" + constants.APPLICATION_STATUS_COMPLETED + + stats = {"year": year_for_query} + + hs = HistoricalNumbersQuery(editor_group.editor, editor_status, editor_group.id) + # ~~-> Provenance:Model ~~ + editor_count = models.Provenance.count(query=hs.query()) + + # ~~-> Account:Model ~~ + acc = models.Account.pull(editor_group.editor) + stats["editor"] = {"id": acc.id, "count": editor_count} + + stats["associate_editors"] = [] + for associate in editor_group.associates: + hs = HistoricalNumbersQuery(associate, associate_status, editor_group.id) + associate_count = models.Provenance.count(query=hs.query()) + acc = models.Account.pull(associate) + stats["associate_editors"].append({"id": acc.id, "name": acc.name, "count": associate_count}) + + return stats + + def user_finished_historical_counts(self, account, year=None): + """ + Get the count of overall applications + Associate Editors set to Completed + Editors set them to Ready + in a given year (current by default) + :param account + :param year + :return: + """ + hs = None + + if account.has_role("editor"): + hs = HistoricalNumbersQuery(account.id, "status:" + constants.APPLICATION_STATUS_READY, year) + elif account.has_role("associate_editor"): + hs = HistoricalNumbersQuery(account.id, "status:" + constants.APPLICATION_STATUS_COMPLETED, year) + + if hs: + count = models.Provenance.count(query=hs.query()) + else: + count = None + + return count + 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 @@ -74,7 +133,7 @@ def top_todo(self, account, size=25, new_applications=True, update_requests=True """ # first validate the incoming arguments to ensure that we've got the right thing argvalidate("top_todo", [ - {"arg" : account, "instance" : models.Account, "allow_none" : False, "arg_name" : "account"} + {"arg": account, "instance": models.Account, "allow_none": False, "arg_name": "account"} ], exceptions.ArgumentException) queries = [] @@ -92,7 +151,7 @@ def top_todo(self, account, size=25, new_applications=True, update_requests=True if on_hold: 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 new_applications: # editor and associate editor roles only deal with new applications if account.has_role("editor"): groups = [g for g in models.EditorGroup.groups_by_editor(account.id)] regular_groups = [g for g in groups if g.maned != account.id] @@ -128,10 +187,10 @@ def top_todo(self, account, size=25, new_applications=True, update_requests=True todos.append({ "date": ap.last_manual_update_timestamp if sort == "last_manual_update" else ap.date_applied_timestamp, "date_type": sort, - "action_id" : [aid], - "title" : ap.bibjson().title, - "object_id" : ap.id, - "object" : ap, + "action_id": [aid], + "title": ap.bibjson().title, + "object_id": ap.id, + "object": ap, "boost": boost }) @@ -399,7 +458,7 @@ def editor_assign_pending(cls, groups, size): return constants.TODO_EDITOR_ASSIGN_PENDING, assign_pending, sort_date, 1 @classmethod - def associate_stalled(cls, acc_id, size): + def associate_stalled(cls, acc_id, size): sort_field = "created_date" stalled = TodoQuery( musts=[ @@ -422,7 +481,7 @@ def associate_stalled(cls, acc_id, size): return constants.TODO_ASSOCIATE_PROGRESS_STALLED, stalled, sort_field, 0 @classmethod - def associate_follow_up_old(cls, acc_id, size): + def associate_follow_up_old(cls, acc_id, size): sort_field = "created_date" follow_up_old = TodoQuery( musts=[ @@ -486,7 +545,7 @@ class TodoQuery(object): ~~->$Todo:Query~~ ~~^->Elasticsearch:Technology~~ """ - lmu_sort = {"last_manual_update" : {"order" : "asc"}} + lmu_sort = {"last_manual_update": {"order": "asc"}} # cd_sort = {"created_date" : {"order" : "asc"}} # NOTE that admin.date_applied and created_date should be the same for applications, but for some reason this is not always the case # therefore, we take a created_date sort to mean a date_applied sort @@ -502,13 +561,16 @@ def __init__(self, musts=None, must_nots=None, ors=None, sort="last_manual_updat def query(self): sort = self.lmu_sort if self._sort == "last_manual_update" else self.cd_sort q = { - "query" : { - "bool" : {} + "query": { + "bool": { + "must": self._musts, + "must_not": self._must_nots + } }, - "sort" : [ + "sort": [ sort ], - "size" : self._size + "size": self._size } if len(self._musts) > 0: @@ -540,8 +602,8 @@ def is_update_request(cls): @classmethod def editor_group(cls, groups): return { - "terms" : { - "admin.editor_group.exact" : [g.name for g in groups] + "terms": { + "admin.editor_group.exact": [g.name for g in groups] } } @@ -568,16 +630,16 @@ def cd_older_than(cls, count, unit="w"): @classmethod def status(cls, statuses): return { - "terms" : { - "admin.application_status.exact" : statuses + "terms": { + "admin.application_status.exact": statuses } } @classmethod def exists(cls, field): return { - "exists" : { - "field" : field + "exists": { + "field": field } } @@ -604,13 +666,14 @@ class GroupStatsQuery(): ~~->$GroupStats:Query~~ ~~^->Elasticsearch:Technology~~ """ + def __init__(self, group_name, editor_count=10): self.group_name = group_name self.editor_count = editor_count def query(self): return { - "track_total_hits" : True, + "track_total_hits": True, "query": { "bool": { "must": [ @@ -620,10 +683,10 @@ def query(self): } } ], - "must_not" : [ + "must_not": [ { - "terms" : { - "admin.application_status.exact" : [ + "terms": { + "admin.application_status.exact": [ constants.APPLICATION_STATUS_ACCEPTED, constants.APPLICATION_STATUS_REJECTED ] @@ -632,26 +695,26 @@ def query(self): ] } }, - "size" : 0, - "aggs" : { - "editor" : { - "terms" : { - "field" : "admin.editor.exact", - "size" : self.editor_count + "size": 0, + "aggs": { + "editor": { + "terms": { + "field": "admin.editor.exact", + "size": self.editor_count }, - "aggs" : { - "application_type" : { - "terms" : { + "aggs": { + "application_type": { + "terms": { "field": "admin.application_type.exact", "size": 2 } } } }, - "status" : { - "terms" : { - "field" : "admin.application_status.exact", - "size" : len(constants.APPLICATION_STATUSES_ALL) + "status": { + "terms": { + "field": "admin.application_status.exact", + "size": len(constants.APPLICATION_STATUSES_ALL) }, "aggs": { "application_type": { @@ -662,13 +725,13 @@ def query(self): } } }, - "unassigned" : { - "missing" : { + "unassigned": { + "missing": { "field": "admin.editor.exact" }, - "aggs" : { - "application_type" : { - "terms" : { + "aggs": { + "application_type": { + "terms": { "field": "admin.application_type.exact", "size": 2 } @@ -676,4 +739,42 @@ def query(self): } } } - } \ No newline at end of file + } + + +class HistoricalNumbersQuery: + """ + ~~->$HistoricalNumbers:Query~~ + ~~^->Elasticsearch:Technology~~ + """ + + def __init__(self, editor, application_status, editor_group=None, year=None): + self.editor_group = editor_group + self.editor = editor + self.application_status = application_status + self.year = year + + def query(self): + if self.year is None: + date_range = {"gte": "now/y", "lte": "now"} + else: + date_range = { + "gte": f"{self.year}-01-01", + "lte": f"{self.year}-12-31" + } + must_terms = [{"range": {"last_updated": date_range}}, + {"term": {"type": "suggestion"}}, + {"term": {"user.exact": self.editor}}, + {"term": {"action": self.application_status}} + ] + + if self.editor_group: + must_terms.append({"term": {"editor_group": self.editor_group}}) + + return { + "query": { + "bool": { + "must": must_terms + } + } + } diff --git a/portality/constants.py b/portality/constants.py index 974ab3f49c..ba6edf42e6 100644 --- a/portality/constants.py +++ b/portality/constants.py @@ -82,7 +82,7 @@ PROCESS__QUICK_REJECT = "quick_reject" -# Role +# Roles ROLE_ADMIN = "admin" ROLE_PUBLISHER = "publisher" ROLE_EDITOR = "editor" diff --git a/portality/dao.py b/portality/dao.py index 80635d714a..4a5e2e2e5f 100644 --- a/portality/dao.py +++ b/portality/dao.py @@ -855,8 +855,8 @@ def all(cls, size=10000, **kwargs): return cls.q2obj(size=size, **kwargs) @classmethod - def count(cls): - res = ES.count(index=cls.index_name(), doc_type=cls.doc_type()) + def count(cls, query=None): + res = ES.count(index=cls.index_name(), doc_type=cls.doc_type(), body=query) return res.get("count") # return requests.get(cls.target() + '_count').json()['count'] diff --git a/portality/settings.py b/portality/settings.py index e6bd2ae57e..2a5123df04 100644 --- a/portality/settings.py +++ b/portality/settings.py @@ -22,8 +22,8 @@ VALID_ENVIRONMENTS = ['dev', 'test', 'staging', 'production', 'harvester'] CMS_BUILD_ASSETS_ON_STARTUP = False # Cookies security -SESSION_COOKIE_SAMESITE='Strict' -SESSION_COOKIE_SECURE=True +SESSION_COOKIE_SAMESITE = 'Strict' +SESSION_COOKIE_SECURE = True REMEMBER_COOKIE_SECURE = True #################################### @@ -51,7 +51,7 @@ # ~~->Elasticsearch:Technology # elasticsearch settings # TODO: changing from single host / esprit to multi host on ES & correct the default -ELASTIC_SEARCH_HOST = os.getenv('ELASTIC_SEARCH_HOST', 'http://localhost:9200') # remember the http:// or https:// +ELASTIC_SEARCH_HOST = os.getenv('ELASTIC_SEARCH_HOST', 'http://localhost:9200') # remember the http:// or https:// ELASTICSEARCH_HOSTS = [{'host': 'localhost', 'port': 9200}, {'host': 'localhost', 'port': 9201}] ELASTIC_SEARCH_VERIFY_CERTS = True # Verify the SSL certificate of the ES host. Set to False in dev.cfg to avoid having to configure your local certificates @@ -62,8 +62,8 @@ # e.g. host:port/type/doc/id ELASTIC_SEARCH_INDEX_PER_TYPE = True -INDEX_PER_TYPE_SUBSTITUTE = '_doc' # Migrated from esprit -ELASTIC_SEARCH_DB_PREFIX = "doaj-" # note: include the separator +INDEX_PER_TYPE_SUBSTITUTE = '_doc' # Migrated from esprit +ELASTIC_SEARCH_DB_PREFIX = "doaj-" # note: include the separator ELASTIC_SEARCH_TEST_DB_PREFIX = "doajtest-" INITIALISE_INDEX = True # whether or not to try creating the index and required index types on startup @@ -82,15 +82,15 @@ ENABLE_APM = False ELASTIC_APM = { - # Set required service name. Allowed characters: - # a-z, A-Z, 0-9, -, _, and space - 'SERVICE_NAME': '', + # Set required service name. Allowed characters: + # a-z, A-Z, 0-9, -, _, and space + 'SERVICE_NAME': '', - # Use if APM Server requires a token - 'SECRET_TOKEN': '', + # Use if APM Server requires a token + 'SECRET_TOKEN': '', - # Set custom APM Server URL (default: http://localhost:8200) - 'SERVER_URL': '', + # Set custom APM Server URL (default: http://localhost:8200) + 'SERVER_URL': '', } ########################################### @@ -111,7 +111,6 @@ # This puts the cron jobs into READ_ONLY mode SCRIPTS_READ_ONLY_MODE = False - ########################################### # Feature Toggles @@ -175,8 +174,8 @@ STORE_IMPL = "portality.store.StoreLocal" STORE_SCOPE_IMPL = { -# Enable this by scope in order to have different scopes store via different storage implementations -# constants.STORE__SCOPE__PUBLIC_DATA_DUMP: "portality.store.StoreS3" + # Enable this by scope in order to have different scopes store via different storage implementations + # constants.STORE__SCOPE__PUBLIC_DATA_DUMP: "portality.store.StoreS3" } STORE_TMP_IMPL = "portality.store.TempStore" @@ -198,27 +197,27 @@ # S3 credentials for relevant scopes # ~~->S3:Technology~~ STORE_S3_SCOPES = { - "anon_data" : { - "aws_access_key_id" : "put this in your dev/test/production.cfg", - "aws_secret_access_key" : "put this in your dev/test/production.cfg" + "anon_data": { + "aws_access_key_id": "put this in your dev/test/production.cfg", + "aws_secret_access_key": "put this in your dev/test/production.cfg" }, - "cache" : { - "aws_access_key_id" : "put this in your dev/test/production.cfg", - "aws_secret_access_key" : "put this in your dev/test/production.cfg" + "cache": { + "aws_access_key_id": "put this in your dev/test/production.cfg", + "aws_secret_access_key": "put this in your dev/test/production.cfg" }, # Used by the api_export script to dump data from the api - constants.STORE__SCOPE__PUBLIC_DATA_DUMP : { - "aws_access_key_id" : "put this in your dev/test/production.cfg", - "aws_secret_access_key" : "put this in your dev/test/production.cfg" + constants.STORE__SCOPE__PUBLIC_DATA_DUMP: { + "aws_access_key_id": "put this in your dev/test/production.cfg", + "aws_secret_access_key": "put this in your dev/test/production.cfg" }, # Used to store harvester run logs to S3 - "harvester" : { - "aws_access_key_id" : "put this in your dev/test/production.cfg", - "aws_secret_access_key" : "put this in your dev/test/production.cfg" + "harvester": { + "aws_access_key_id": "put this in your dev/test/production.cfg", + "aws_secret_access_key": "put this in your dev/test/production.cfg" } } -STORE_S3_MULTIPART_THRESHOLD = 5 * 1024**3 # 5GB +STORE_S3_MULTIPART_THRESHOLD = 5 * 1024 ** 3 # 5GB #################################### # CMS configuration @@ -256,7 +255,6 @@ # ~~->Cookies:Feature~~ SECRET_KEY = "default-key" - # Consent Cookie and other Top-Level dismissable notes # ~~->ConsentCookie:Feature~~ CONSENT_COOKIE_KEY = "doaj-cookie-consent" @@ -265,7 +263,7 @@ # ~~-> SiteNote:Feature~~ SITE_NOTE_ACTIVE = False SITE_NOTE_KEY = "doaj-site-note" -SITE_NOTE_SLEEP = 259200 # every 3 days +SITE_NOTE_SLEEP = 259200 # every 3 days SITE_NOTE_COOKIE_VALUE = "You have seen our most recent site wide announcement" #################################### @@ -299,10 +297,10 @@ ROLE_MAP = { "editor": [ - "associate_editor", # note, these don't cascade, so we still need to list all the low-level roles + "associate_editor", # note, these don't cascade, so we still need to list all the low-level roles "edit_journal", "edit_suggestion", - "edit_application", # todo: switchover from suggestion to application + "edit_application", # todo: switchover from suggestion to application "editor_area", "assign_to_associate", "list_group_journals", @@ -340,16 +338,16 @@ # ~~->Email:ExternalService # Settings for Flask-Mail. Set in app.cfg -MAIL_SERVER = None # default localhost -MAIL_PORT = 25 # default 25 -#MAIL_USE_TLS # default False -#MAIL_USE_SSL # default False -#MAIL_DEBUG # default app.debug -#MAIL_USERNAME # default None -#MAIL_PASSWORD # default None -#MAIL_DEFAULT_SENDER # default None -#MAIL_MAX_EMAILS # default None -#MAIL_SUPPRESS_SEND # default app.testing +MAIL_SERVER = None # default localhost +MAIL_PORT = 25 # default 25 +# MAIL_USE_TLS # default False +# MAIL_USE_SSL # default False +# MAIL_DEBUG # default app.debug +# MAIL_USERNAME # default None +# MAIL_PASSWORD # default None +# MAIL_DEFAULT_SENDER # default None +# MAIL_MAX_EMAILS # default None +# MAIL_SUPPRESS_SEND # default app.testing ENABLE_EMAIL = True ENABLE_PUBLISHER_EMAIL = True @@ -379,8 +377,8 @@ # workflow email notification settings # ~~->WorkflowNotifications:Feature~~ -MAN_ED_IDLE_WEEKS = 4 # weeks before an application is considered reminder-worthy -ED_IDLE_WEEKS = 3 # weeks before the editor is warned about idle applications in their group +MAN_ED_IDLE_WEEKS = 4 # weeks before an application is considered reminder-worthy +ED_IDLE_WEEKS = 3 # weeks before the editor is warned about idle applications in their group ASSOC_ED_IDLE_DAYS = 10 ASSOC_ED_IDLE_WEEKS = 3 @@ -406,7 +404,7 @@ # ~~->StatusEndpoint:Feature~~ # /status endpoint connection to all app machines -APP_MACHINES_INTERNAL_IPS = [HOST + ':' + str(PORT)] # This should be set in production.cfg (or dev.cfg etc) +APP_MACHINES_INTERNAL_IPS = [HOST + ':' + str(PORT)] # This should be set in production.cfg (or dev.cfg etc) ########################################### # Background Jobs settings @@ -478,12 +476,12 @@ # an array of DAO classes from which to retrieve the type-specific ES mappings # to be loaded into the index during initialisation. ELASTIC_SEARCH_MAPPINGS = [ - "portality.models.Journal", # ~~->Journal:Model~~ - "portality.models.Application", # ~~->Application:Model~~ - "portality.models.DraftApplication", # ~~-> DraftApplication:Model~~ - "portality.models.harvester.HarvestState", # ~~->HarvestState:Model~~ - "portality.models.background.BackgroundJob", # ~~-> BackgroundJob:Model~~ - "portality.models.autocheck.Autocheck" # ~~-> Autocheck:Model~~ + "portality.models.Journal", # ~~->Journal:Model~~ + "portality.models.Application", # ~~->Application:Model~~ + "portality.models.DraftApplication", # ~~-> DraftApplication:Model~~ + "portality.models.harvester.HarvestState", # ~~->HarvestState:Model~~ + "portality.models.background.BackgroundJob", # ~~-> BackgroundJob:Model~~ + "portality.models.autocheck.Autocheck" # ~~-> Autocheck:Model~~ ] # Map from dataobj coercion declarations to ES mappings @@ -495,7 +493,7 @@ "fields": { "exact": { "type": "keyword", -# "index": False, + # "index": False, "store": True } } @@ -505,7 +503,7 @@ "fields": { "exact": { "type": "keyword", -# "index": False, + # "index": False, "store": True } } @@ -515,7 +513,7 @@ "fields": { "exact": { "type": "keyword", -# "index": False, + # "index": False, "store": True } } @@ -525,7 +523,7 @@ "fields": { "exact": { "type": "keyword", -# "index": False, + # "index": False, "store": True } } @@ -535,7 +533,7 @@ "fields": { "exact": { "type": "keyword", -# "index": False, + # "index": False, "store": True } } @@ -545,7 +543,7 @@ "fields": { "exact": { "type": "keyword", -# "index": False, + # "index": False, "store": True } } @@ -555,7 +553,7 @@ "fields": { "exact": { "type": "keyword", -# "index": False, + # "index": False, "store": True } } @@ -565,7 +563,7 @@ "fields": { "exact": { "type": "keyword", -# "index": False, + # "index": False, "store": True } } @@ -575,7 +573,7 @@ "fields": { "exact": { "type": "keyword", -# "index": False, + # "index": False, "store": True } } @@ -585,7 +583,7 @@ "fields": { "exact": { "type": "keyword", -# "index": False, + # "index": False, "store": True } } @@ -595,7 +593,7 @@ "fields": { "exact": { "type": "keyword", -# "index": False, + # "index": False, "store": True } } @@ -605,7 +603,7 @@ "fields": { "exact": { "type": "keyword", -# "index": False, + # "index": False, "store": True } } @@ -654,7 +652,6 @@ 'number_of_replicas': 1 } - DEFAULT_DYNAMIC_MAPPING = { 'dynamic_templates': [ { @@ -665,7 +662,7 @@ "fields": { "exact": { "type": "keyword", - #"normalizer": "lowercase" + # "normalizer": "lowercase" } } } @@ -678,7 +675,7 @@ # a dict of the ES mappings. identify by name, and include name as first object name # and identifier for how non-analyzed fields for faceting are differentiated in the mappings MAPPINGS = { - 'account': { #~~->Account:Model~~ + 'account': { # ~~->Account:Model~~ # 'aliases': { # 'account': {} # }, @@ -687,18 +684,18 @@ } } -MAPPINGS['article'] = MAPPINGS["account"] #~~->Article:Model~~ -MAPPINGS['upload'] = MAPPINGS["account"] #~~->Upload:Model~~ -MAPPINGS['bulk_articles'] = MAPPINGS["account"] #~~->BulkArticles:Model~~ -MAPPINGS['cache'] = MAPPINGS["account"] #~~->Cache:Model~~ -MAPPINGS['lcc'] = MAPPINGS["account"] #~~->LCC:Model~~ -MAPPINGS['editor_group'] = MAPPINGS["account"] #~~->EditorGroup:Model~~ -MAPPINGS['news'] = MAPPINGS["account"] #~~->News:Model~~ -MAPPINGS['lock'] = MAPPINGS["account"] #~~->Lock:Model~~ -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~~ +MAPPINGS['article'] = MAPPINGS["account"] # ~~->Article:Model~~ +MAPPINGS['upload'] = MAPPINGS["account"] # ~~->Upload:Model~~ +MAPPINGS['bulk_articles'] = MAPPINGS["account"] # ~~->BulkArticles:Model~~ +MAPPINGS['cache'] = MAPPINGS["account"] # ~~->Cache:Model~~ +MAPPINGS['lcc'] = MAPPINGS["account"] # ~~->LCC:Model~~ +MAPPINGS['editor_group'] = MAPPINGS["account"] # ~~->EditorGroup:Model~~ +MAPPINGS['news'] = MAPPINGS["account"] # ~~->News:Model~~ +MAPPINGS['lock'] = MAPPINGS["account"] # ~~->Lock:Model~~ +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 @@ -949,31 +946,31 @@ ADMIN_NOTES_SEARCH_MAPPING = { "admin.notes.id": { - "type": "text", - "fields": { - "exact": { - "type": "keyword", - "store": True - } + "type": "text", + "fields": { + "exact": { + "type": "keyword", + "store": True } + } }, "admin.notes.note": { - "type": "text", - "fields": { - "exact": { - "type": "keyword", - "store": True - } + "type": "text", + "fields": { + "exact": { + "type": "keyword", + "store": True } + } }, "admin.notes.author_id": { - "type": "text", - "fields": { - "exact": { - "type": "keyword", - "store": True - } + "type": "text", + "fields": { + "exact": { + "type": "keyword", + "store": True } + } } } @@ -993,7 +990,6 @@ # save the public application form as a draft every 60 seconds PUBLIC_FORM_AUTOSAVE = 60000 - ############################################ # Atom Feed # ~~->AtomFeed:Feature~~ @@ -1018,7 +1014,6 @@ # ~~->Favicon:Content~~ FEED_LOGO = "https://doaj.org/static/doaj/images/favicon.ico" - ########################################### # OAI-PMH SETTINGS # ~~->OAIPMH:Feature~~ @@ -1054,7 +1049,6 @@ OAIPMH_RESUMPTION_TOKEN_EXPIRY = 86400 - ########################################## # Article XML configuration @@ -1103,7 +1097,6 @@ ARTICLE_HISTORY_DIR = os.path.join(ROOT_DIR, "history", "article") JOURNAL_HISTORY_DIR = os.path.join(ROOT_DIR, "history", "journal") - ################################################# # Sitemap settings # ~~->Sitemap:Feature~~ @@ -1111,7 +1104,6 @@ # approximate rate of change of the Table of Contents for journals TOC_CHANGEFREQ = "monthly" - ################################################## # News feed settings # ~~->News:Feature~~ @@ -1124,7 +1116,6 @@ NEWS_PAGE_NEWS_ITEMS = 20 - ################################################## # Edit Lock settings # ~~->Lock:Feature~~ @@ -1135,17 +1126,16 @@ # amount of time a background task can lock a resource for, in seconds BACKGROUND_TASK_LOCK_TIMEOUT = 3600 - ############################################### # Bit.ly configuration # ~~->Bitly:ExternalService~~ # bit,ly api shortening service -#BITLY_SHORTENING_API_URL = "https://api-ssl.bitly.com/v4/shorten" +# BITLY_SHORTENING_API_URL = "https://api-ssl.bitly.com/v4/shorten" # bitly oauth token # ENTER YOUR OWN TOKEN IN APPROPRIATE .cfg FILE -#BITLY_OAUTH_TOKEN = "" +# BITLY_OAUTH_TOKEN = "" ################################################# @@ -1203,7 +1193,6 @@ DISCOVERY_BULK_PAGE_SIZE = 1000 DISCOVERY_RECORDS_PER_FILE = 100000 - ###################################################### # Hotjar configuration # ~~->Hotjar:ExternalService~~ @@ -1211,7 +1200,6 @@ # hotjar id - only activate this in production HOTJAR_ID = "" - ###################################################### # Analytics configuration # specify in environment .cfg file - avoids sending live analytics events from test and dev environments @@ -1228,7 +1216,7 @@ # Analytics custom dimensions. These are configured in the interface. #fixme: are these still configured since the move from GA? ANALYTICS_DIMENSIONS = { - 'oai_res_id': 'dimension1', # In analytics as OAI:Record + 'oai_res_id': 'dimension1', # In analytics as OAI:Record } # Plausible for OAI-PMH @@ -1277,7 +1265,6 @@ 'bulk_article_delete': 'Bulk article delete' } - # Plausible for fixed query widget # ~~->FixedQueryWidget:Feature~~ ANALYTICS_CATEGORY_FQW = 'FQW' @@ -1315,7 +1302,6 @@ MINIMAL_OA_START_DATE = 1900 - ############################################# # Harvester Configuration # ~~->Harvester:Feature~~ @@ -1325,7 +1311,7 @@ # EPMC Client configuration # ~~-> EPMC:ExternalService~~ EPMC_REST_API = "https://www.ebi.ac.uk/europepmc/webservices/rest/" -EPMC_TARGET_VERSION = "6.9" # doc here: https://europepmc.org/docs/Europe_PMC_RESTful_Release_Notes.pdf +EPMC_TARGET_VERSION = "6.9" # doc here: https://europepmc.org/docs/Europe_PMC_RESTful_Release_Notes.pdf EPMC_HARVESTER_THROTTLE = 0.2 # General harvester configuration @@ -1347,7 +1333,7 @@ # ReCAPTCHA configuration # ~~->ReCAPTCHA:ExternalService -#Recaptcha test keys, should be overridden in dev.cfg by the keys obtained from Google ReCaptcha v2 +# Recaptcha test keys, should be overridden in dev.cfg by the keys obtained from Google ReCaptcha v2 RECAPTCHA_ENABLE = True RECAPTCHA_SITE_KEY = '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI' RECAPTCHA_SECRET_KEY = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" @@ -1360,7 +1346,6 @@ PRESERVATION_PASSWD = "password" PRESERVATION_COLLECTION = {} - ######################################################### # Background tasks --- anon export TASKS_ANON_EXPORT_CLEAN = False @@ -1371,21 +1356,19 @@ ######################################################### # Background tasks --- old_data_cleanup TASK_DATA_RETENTION_DAYS = { - "notification": 180, # ~~-> Notifications:Feature ~~ - "background_job": 180, # ~~-> BackgroundJobs:Feature ~~ + "notification": 180, # ~~-> Notifications:Feature ~~ + "background_job": 180, # ~~-> BackgroundJobs:Feature ~~ } ######################################## # Editorial Dashboard - set to-do list size TODO_LIST_SIZE = 48 - ######################################################### # Background tasks --- monitor_bgjobs -TASKS_MONITOR_BGJOBS_TO = ["helpdesk@doaj.org",] +TASKS_MONITOR_BGJOBS_TO = ["helpdesk@doaj.org", ] TASKS_MONITOR_BGJOBS_FROM = "helpdesk@doaj.org" - ################################## # Background monitor # ~~->BackgroundMonitor:Feature~~ @@ -1393,7 +1376,7 @@ # Configures the age of the last completed job on the queue before the queue is marked as unstable # (in seconds) BG_MONITOR_LAST_COMPLETED = { - 'main_queue': 7200, # 2 hours + 'main_queue': 7200, # 2 hours 'long_running': 93600, # 26 hours } @@ -1433,7 +1416,7 @@ # Main queue 'journal_csv': { 'total': 2, - 'oldest': 1200, # 20 mins + 'oldest': 1200, # 20 mins }, 'ingest_articles': { 'total': 250, @@ -1472,20 +1455,6 @@ TOUR_COOKIE_MAX_AGE = 31536000 TOURS = { - "/editor/": [ - { - "roles": ["editor", "associate_editor"], - "content_id": "dashboard_ed_assed", - "name": "Welcome to your dashboard!", - "description": "The new dashboard gives you a way to see all your priority work, take a look at what's new.", - }, - { - "roles": ["editor"], - "content_id": "dashboard_ed", - "name": "Your group activity", - "description": "Your dashboard shows you who is working on what, and the status of your group's applications" - } - ], "/admin/journal/*": [ { "roles": ["admin"], @@ -1494,10 +1463,25 @@ "name": "Autochecks", "description": "Autochecks are available on some journals, and can help you to identify potential problems with the journal's metadata." } + ], + "/editor/": [ + { + "roles": ["editor"], + "content_id": "application_by_status", + "name": "New Links in the Colour Legend", + "description": "Discover how the colour legend labels now serve as links to quickly filter and view applications by group and status." + } + ], + "/dashboard/": [ + { + "roles": ["admin"], + "content_id": "application_by_status", + "name": "New Links in the Colour Legend", + "description": "Discover how the colour legend labels now serve as links to quickly filter and view applications by group and status." + } ] } - ####################################################### # Selenium test environment @@ -1516,7 +1500,6 @@ UR_CONCURRENCY_TIMEOUT = 10 - ############################################# # Google Sheet # ~~->GoogleSheet:ExternalService~~ @@ -1525,7 +1508,6 @@ # value should be key file path of json, empty string means disabled GOOGLE_KEY_PATH = '' - ############################################# # Datalog # ~~->Datalog:Feature~~ @@ -1545,8 +1527,7 @@ AUTOCHECK_INCOMING = False AUTOCHECK_RESOURCE_ISSN_ORG_TIMEOUT = 10 -AUTOCHECK_RESOURCE_ISSN_ORG_THROTTLE = 1 # seconds between requests - +AUTOCHECK_RESOURCE_ISSN_ORG_THROTTLE = 1 # seconds between requests ################################################## # Background jobs Management settings diff --git a/portality/static/js/dashboard.js b/portality/static/js/dashboard.js index 4acc2ae0bd..2ee2e7ce77 100644 --- a/portality/static/js/dashboard.js +++ b/portality/static/js/dashboard.js @@ -1,6 +1,6 @@ // ~~Dashboard:Feature~~ doaj.dashboard = { - statusOrder : [ + statusOrder: [ "pending", "in progress", "completed", @@ -10,11 +10,20 @@ doaj.dashboard = { "ready", "rejected", "accepted" + ], + visibleStatusFilters: [ + "pending", + "in progress", + "completed", + "on hold", + "ready", ] }; -doaj.dashboard.init = function(context) { +doaj.dashboard.init = function (context) { doaj.dashboard.context = context; + doaj.dashboard.motivationalBanners = motivational_banners; + doaj.dashboard.context.historical_count = historical_count; $(".js-group-tab").on("click", doaj.dashboard.groupTabClick); // trigger a click on the first one, so there is something for the user to look at @@ -22,15 +31,39 @@ doaj.dashboard.init = function(context) { if (first) { first.trigger("click"); } + this.generateMotivationalBanner(); + } -doaj.dashboard.groupTabClick = function(event) { +doaj.dashboard.generateMotivationalBanner = function () { + + _addNumberToBanner = function (text) { + var number = `` + doaj.dashboard.context.historical_count + ``; + var bannerTextWithNumber = text.replace(/{{ COUNT }}/g, number); + return bannerTextWithNumber; + } + _addTextToBanner = function (text) { + $("#banner_text_placeholder").html(text); + } + + if (doaj.dashboard.context.historical_count == 0) { + bannerText = doaj.dashboard.motivationalBanners["banners"]["zero_count"][0]; + } else { + var available_texts = doaj.dashboard.motivationalBanners["banners"]["positive_count"] + var randomIndex = Math.floor(Math.random() * available_texts.length); + var randomBannerText = available_texts[randomIndex]; + bannerText = _addNumberToBanner(randomBannerText); + } + _addTextToBanner(bannerText); +} + +doaj.dashboard.groupTabClick = function (event) { let groupId = $(event.target).attr("data-group-id"); doaj.dashboard.loadGroupTab(groupId); } // ~~->GroupStats:Endpoint~~ -doaj.dashboard.loadGroupTab = function(groupId) { +doaj.dashboard.loadGroupTab = function (groupId) { $.ajax({ type: "GET", contentType: "application/json", @@ -41,157 +74,224 @@ doaj.dashboard.loadGroupTab = function(groupId) { }); } -doaj.dashboard.groupLoaded = function(data) { +doaj.dashboard.groupLoaded = function (data) { let container = $("#group-tab"); container.html(doaj.dashboard.renderGroupInfo(data)); } -doaj.dashboard.groupLoadError = function(data) { +doaj.dashboard.groupLoadError = function (data) { alert("Unable to determine group status at this time"); } -doaj.dashboard.renderGroupInfo = function(data) { - // ~~-> EditorGroup:Model~~ - - // first remove the editor from the associates list if they are there - if (data.editor_group.associates && data.editor_group.associates.length > 0) { - let edInAssEd = data.editor_group.associates.indexOf(data.editor_group.editor) - if (edInAssEd > -1) { - data.editor_group.associates.splice(edInAssEd, 1); +doaj.dashboard.renderGroupInfo = function (data) { + // Remove the editor from the associates list if they are there + _removeEditorFromAssociates = function (data) { + if (data.editor_group.associates && data.editor_group.associates.length > 0) { + let edInAssEd = data.editor_group.associates.indexOf(data.editor_group.editor); + if (edInAssEd > -1) { + data.editor_group.associates.splice(edInAssEd, 1); + } + } else { + data.editor_group.associates = []; // to avoid having to keep checking it below } - } else { - data.editor_group.associates = []; // just to avoid having to keep checking it below } - let allEditors = [data.editor_group.editor].concat(data.editor_group.associates); - - let editorListFrag = ""; - for (let i = 0; i < allEditors.length; i++) { - let ed = allEditors[i]; - // ~~-> ApplicationSearch:Page~~ - let appQuerySource = doaj.searchQuerySource({ - "term" : [ - {"admin.editor.exact" : ed}, - {"admin.editor_group.exact" : data.editor_group.name}, - {"index.application_type.exact" : "new application"} // this is required so we only see open applications, not finished ones - ], - "sort": [{"admin.date_applied": {"order": "asc"}}] - }) - // // ~~-> UpdateRequestsSearch:Page ~~ - // let urQuerySource = doaj.searchQuerySource({"term" : [ - // {"admin.editor.exact" : ed}, - // {"admin.editor_group.exact" : data.editor_group.name}, - // {"index.application_type.exact" : "update request"} // this is required so we only see open update requests, not finished ones - // ]}) - let appCount = 0; - let urCount = 0; - if (data.by_editor[ed]) { - appCount = data.by_editor[ed].applications || 0; - urCount = data.by_editor[ed].update_requests || 0; - } + // Generate the search query source + _generateSearchQuerySource = function (term, sort) { + return doaj.searchQuerySource({ + "term": term, + "sort": sort + }); + } - if (data.editors[ed]) { - let isEd = ""; - if (i === 0) { // first one in the list is always the editor - isEd = " (Editor)" - } + _generateStatusLinks = function (data) { + const statusLinks = {}; - editorListFrag += `
  • ` - if (data.editors[ed].email) { - editorListFrag += `${ed}${isEd}` - } - else { - editorListFrag += `${ed}${isEd} (no email)` - } + // Generate URL for each status + doaj.dashboard.statusOrder.forEach(status => { + const queryParams = [ + {"admin.editor_group.exact": data.editor_group.name}, + {"admin.application_status.exact": status}, + {"index.application_type.exact": "new application"} // only see open applications, not finished ones + ]; + const sortOptions = [{"admin.date_applied": {"order": "asc"}}]; + const querySource = _generateSearchQuerySource(queryParams, sortOptions); + const url = `${doaj.dashboard.context.applicationsSearchBase}?source=${querySource}`; + + statusLinks[status] = url; + }); + + return statusLinks; + } + + const statusLinks = _generateStatusLinks(data) + + _generateColorLegend = function(data) { + return `
    + +
    `; + } + + + // Generate the editor list fragment + _generateEditorListFragment = function (data, allEditors) { + let editorListFrag = ""; + let unassignedFragment = _generateUnassignedApplicationsFragment(data); + for (let i = 0; i < allEditors.length; i++) { + let ed = allEditors[i]; + let appQuerySource = _generateSearchQuerySource([ + {"admin.editor.exact": ed}, + {"admin.editor_group.exact": data.editor_group.name}, + {"index.application_type.exact": "new application"} // only see open applications, not finished ones + ], [{"admin.date_applied": {"order": "asc"}}]); - editorListFrag += `${appCount} applications -
  • `; + let appCount = data.by_editor[ed]?.applications || 0; + let urCount = data.by_editor[ed]?.update_requests || 0; + + if (data.editors[ed]) { + let isEd = i === 0 ? " (Editor)" : ""; + editorListFrag += `
  • `; + if (data.editors[ed].email) { + editorListFrag += `${ed}${isEd}`; + } else { + editorListFrag += `${ed}${isEd} (no email)`; + } + editorListFrag += `${appCount} applications
  • `; + } } + editorListFrag += `${unassignedFragment}` + return editorListFrag; } - // ~~-> ApplicationSearch:Page~~ - let appUnassignedSource = doaj.searchQuerySource({ - "term" : [ - {"admin.editor_group.exact" : data.editor_group.name}, + // Generate the unassigned applications fragment + _generateUnassignedApplicationsFragment = function (data) { + let appUnassignedSource = _generateSearchQuerySource([ + {"admin.editor_group.exact": data.editor_group.name}, {"index.has_editor.exact": "No"}, - {"index.application_type.exact" : "new application"} // this is required so we only see open applications, not finished ones - ], - "sort": [{"admin.date_applied": {"order": "asc"}}] - }); - // ~~-> UpdateRequestsSearch:Page ~~ - // let urUnassignedSource = doaj.searchQuerySource({"term" : [ - // {"admin.editor_group.exact" : data.editor_group.name}, - // {"index.has_editor.exact": "No"}, - // {"index.application_type.exact" : "update request"} // this is required so we only see open update requests, not finished ones - // ]}) - editorListFrag += `
  • - Unassigned - ${data.unassigned.applications} applications -
  • `; - - let appStatusProgressBar = ""; - - for (let j = 0; j < doaj.dashboard.statusOrder.length; j++) { - let status = doaj.dashboard.statusOrder[j]; - if (data.by_status.hasOwnProperty(status)) { - if (data.by_status[status].applications > 0) { - // ~~-> ApplicationSearch:Page~~ - let appStatusSource = doaj.searchQuerySource({ - "term": [ - {"admin.editor_group.exact": data.editor_group.name}, - {"admin.application_status.exact": status}, - {"index.application_type.exact": "new application"} // this is required so we only see open applications, not finished ones - ], - "sort": [{"admin.date_applied": {"order": "asc"}}] - }) - appStatusProgressBar += `
  • - - ${data.by_status[status].applications} -
  • `; + {"index.application_type.exact": "new application"} // only see open applications, not finished ones + ], [{"admin.date_applied": {"order": "asc"}}]); + + return `
  • + Unassigned + ${data.unassigned.applications} applications +
  • `; + } + + // Generate the status progress bar + _generateStatusProgressBar = function (data) { + + + let appStatusProgressBar = ""; + + for (let status of doaj.dashboard.statusOrder) { + if (data.by_status[status]?.applications > 0) { + let url = statusLinks[status]; // Get the URL from the precomputed status links + + appStatusProgressBar += ``; } } + + return appStatusProgressBar; } - // ~~-> ApplicationSearch:Page~~ - let appGroupSource = doaj.searchQuerySource({ - "term" : [ - {"admin.editor_group.exact" : data.editor_group.name}, - {"index.application_type.exact" : "new application"} // this is required so we only see open applications, not finished ones - ], - "sort": [{"admin.date_applied": {"order": "asc"}}] - }); - // ~~-> UpdateRequestsSearch:Page ~~ - // let urGroupSource = doaj.searchQuerySource({ "term" : [ - // {"admin.editor_group.exact" : data.editor_group.name}, - // {"index.application_type.exact" : "update request"} // this is required so we only see open applications, not finished ones - // ]}) - let frag = `
    + + _generateStatisticsFragment = function (data) { + let statisticsFrag = ""; + let historicalNumbers = data.historical_numbers; + + if (historicalNumbers) { + statisticsFrag += `
    `; + + if (current_user.role.includes("admin") || historicalNumbers.associate_editors.length > 0) { + statisticsFrag += `

    Statistics for the current year (${historicalNumbers.year})

    `; + } + + if (current_user.role.includes("admin")) { + // Ready applications by editor + statisticsFrag += `

    Editor's Ready Applications: `; + statisticsFrag += `${historicalNumbers.editor.id} ${historicalNumbers.editor.count}

    `; + } + + // Completed applications by associated editor + if (historicalNumbers.associate_editors.length) { + statisticsFrag += `

    Applications Completed by associated editors

    `; + statisticsFrag += `` + } + statisticsFrag += `
    `; + } + + return statisticsFrag; + }; + + + // Generate the main fragment + _renderMainFragment = function (data) { + _removeEditorFromAssociates(data); + + let colorLegend = _generateColorLegend(data); + let allEditors = [data.editor_group.editor].concat(data.editor_group.associates); + let editorListFrag = _generateEditorListFragment(data, allEditors); + let appStatusProgressBar = _generateStatusProgressBar(data); + let statisticsFragment = _generateStatisticsFragment(data); + + let appGroupSource = _generateSearchQuerySource([ + {"admin.editor_group.exact": data.editor_group.name}, + {"index.application_type.exact": "new application"} // only see open applications, not finished ones + ], [{"admin.date_applied": {"order": "asc"}}]); + + + // Combine all fragments + let frag = `

    - ${data.editor_group.name}’s open applications - ${data.total.applications} applications + ${data.editor_group.name}’s open applications + + ${data.total.applications} + applications +

    - + +
    +

    Status progress bar colour legend

    + ${colorLegend} +
    +

    By editor

      ${editorListFrag}
    -
    + ` + if (data["total"]["applications"]) { + frag += `
    +

    Applications by status

    +
      + ${appStatusProgressBar} +
    +
    ` + } + frag += `${statisticsFragment}
    `; -
    -

    Applications by status

    -

    Status progress bar colour legend

    - - -
    -
    `; - return frag; + return frag; + } + + return _renderMainFragment(data); } + diff --git a/portality/templates-v2/dev/testdrive/testdrive.html b/portality/templates-v2/dev/testdrive/testdrive.html index 2ae6df01ad..52e0941f02 100644 --- a/portality/templates-v2/dev/testdrive/testdrive.html +++ b/portality/templates-v2/dev/testdrive/testdrive.html @@ -11,7 +11,7 @@

    {{ name }} - Testdrive setup results

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

    {{ k }}

    {% if v is mapping %} diff --git a/portality/templates-v2/includes/_js_includes.html b/portality/templates-v2/includes/_js_includes.html index d18034e523..d6de913520 100644 --- a/portality/templates-v2/includes/_js_includes.html +++ b/portality/templates-v2/includes/_js_includes.html @@ -29,7 +29,6 @@ - diff --git a/portality/templates-v2/management/admin/dashboard.html b/portality/templates-v2/management/admin/dashboard.html index b70e51e874..414b95daf4 100644 --- a/portality/templates-v2/management/admin/dashboard.html +++ b/portality/templates-v2/management/admin/dashboard.html @@ -36,43 +36,9 @@ {% endif %} {% include "management/includes/_todo.html" %} -
    - {# ~~->$GroupStatus:Feature~~ #} -

    Activity

    -
    - - - {# TODO: there’s a bit of a11y work to be done here; we need to indicate which tabs are hidden and which - aren’t using ARIA attributes. #} - {# TODO: the first tab content needs to be shown by default, without a "click to see" message. #} -
    -
    -
    -
    -
    + {% set groups = managed_groups %} + {% set person_of_assignments = maned_assignments %} + {% include "management/includes/_activity.html" %} {% endblock %} {% block admin_js %} diff --git a/portality/templates-v2/management/base.html b/portality/templates-v2/management/base.html index bde4236224..f352e0a39e 100644 --- a/portality/templates-v2/management/base.html +++ b/portality/templates-v2/management/base.html @@ -184,6 +184,16 @@

    {% include "_tourist/includes/_tourist.html" %} + + + {% block management_js %}{% endblock %} {% endblock %} diff --git a/portality/templates-v2/management/editor/dashboard.html b/portality/templates-v2/management/editor/dashboard.html index 6c32571a21..0c5cc63373 100644 --- a/portality/templates-v2/management/editor/dashboard.html +++ b/portality/templates-v2/management/editor/dashboard.html @@ -9,41 +9,11 @@ {% block editor_content %}
    + {% include "management/includes/_motivational_banner.html" %} {% include "management/includes/_todo.html" %} -
    - {# ~~->$GroupStatus:Feature~~ #} - {% if editor_of_groups | length != 0 %} -

    Activity

    -
    - - {% endif %} - - {# TODO: there’s a bit of a11y work to be done here; we need to indicate which tabs are hidden and which - aren’t using ARIA attributes. #} - {# TODO: the first tab content needs to be shown by default, without a "click to see" message. #} -
    -
    -
    -
    -
    + {% set groups = editor_of_groups %} + {% set person_of_assignments = editor_of_assignments %} + {% include "management/includes/_activity.html" %}
    {% endblock %} diff --git a/portality/templates-v2/management/includes/_activity.html b/portality/templates-v2/management/includes/_activity.html new file mode 100644 index 0000000000..331e7b1565 --- /dev/null +++ b/portality/templates-v2/management/includes/_activity.html @@ -0,0 +1,38 @@ +
    + {# ~~->$GroupStatus:Feature~~ #} + {% if groups | length != 0 %} +

    Activity

    +
    + + {% endif %} + + {# TODO: there’s a bit of a11y work to be done here; we need to indicate which tabs are hidden and which + aren’t using ARIA attributes. #} + {# TODO: the first tab content needs to be shown by default, without a "click to see" message. #} +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/portality/templates-v2/management/includes/_motivational_banner.html b/portality/templates-v2/management/includes/_motivational_banner.html new file mode 100644 index 0000000000..624afae0ca --- /dev/null +++ b/portality/templates-v2/management/includes/_motivational_banner.html @@ -0,0 +1,14 @@ +{% if historical_count %} +
    +
    +
    +

    + {% if historical_count > 0 %} + {% include "management/includes/svg/award.svg" %} + {% endif %} + +

    +
    +
    +
    +{% endif %} \ No newline at end of file diff --git a/portality/templates-v2/management/includes/svg/award.svg b/portality/templates-v2/management/includes/svg/award.svg new file mode 100644 index 0000000000..73caf4bf81 --- /dev/null +++ b/portality/templates-v2/management/includes/svg/award.svg @@ -0,0 +1,4 @@ + + + + diff --git a/portality/view/dashboard.py b/portality/view/dashboard.py index 437351e51c..da1a01d508 100644 --- a/portality/view/dashboard.py +++ b/portality/view/dashboard.py @@ -39,8 +39,10 @@ def top_todo(): update_requests=update_requests, on_hold=on_hold) + count = svc.user_finished_historical_counts(current_user._get_current_object()) + # ~~-> Dashboard:Page~~ - return render_template(templates.DASHBOARD, todos=todos) + return render_template(templates.DASHBOARD, todos=todos, historical_count=count) @blueprint.route("/top_notifications") diff --git a/portality/view/editor.py b/portality/view/editor.py index 05cb2c53b3..f92c26858a 100644 --- a/portality/view/editor.py +++ b/portality/view/editor.py @@ -31,8 +31,9 @@ def index(): # ~~-> Todo:Service~~ svc = DOAJ.todoService() todos = svc.top_todo(current_user._get_current_object(), size=app.config.get("TODO_LIST_SIZE"), update_requests=False) + count = svc.user_finished_historical_counts(current_user._get_current_object()) # ~~-> Dashboard:Page~~ - return render_template(templates.EDITOR_DASHBOARD, todos=todos) + return render_template(templates.EDITOR_DASHBOARD, todos=todos, historical_count=count) @blueprint.route('/group_applications')