From e74b34496b4a27463c491ab67e6e346461b544b7 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Mon, 15 Dec 2025 10:52:24 +0000 Subject: [PATCH 01/13] some preliminary thoughts about state machine implementation --- portality/workflow.py | 160 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 portality/workflow.py diff --git a/portality/workflow.py b/portality/workflow.py new file mode 100644 index 0000000000..96bd7ea481 --- /dev/null +++ b/portality/workflow.py @@ -0,0 +1,160 @@ +from portality import models + + +class State: + + def __init__(self, application): + self._application = application + + @classmethod + def query(cls): + return StateQuery("Draft") + + @property + def application(self): + return self._application + + def enter(self): + pass + + def exit(self, event, *args, **kwargs): + pass + + +class StateQuery: + def __init__(self, domain:str, subdomain:str=None, badge:list=None, editor_group:bool=False, editor:bool=False, revisions:bool=False): + self.domain = domain + self.subdomain = subdomain + self.badge = badge or [] + self.editor_group = editor_group + self.editor = editor + self.revisions = revisions + + def query(self): + q = { + "query": { + "bool": { + "must": [ + {"term": {"workflow.domain.exact": self.domain}} + ] + } + } + } + if self.subdomain: + q["query"]["bool"]["must"].append({"term": {"admin.workflow.subdomain.exact": self.subdomain}}) + if self.badge: + q["query"]["bool"]["must"].append({"terms": {"admin.workflow.badges.exact": self.badge}}) + if self.editor_group: + q["query"]["bool"]["must"].append({"exists": {"field": "admin.editor_group"}}) + if self.editor: + q["query"]["bool"]["must"].append({"exists": {"field": "admin.editor"}}) + if self.revisions: + q["query"]["bool"]["must"].append({"exists": {"field": "admin.workflow.revisions"}}) + + +class Draft(State): + SUBMIT = "submit" + + def exit(self, event, *args, **kwargs): + if event == self.SUBMIT: + self._submit_handler() + else: + raise ValueError(f"Invalid event '{event}' for Draft state") + + def _submit_handler(self): + pass + +class Submitted(State): + AUTOCHECKS_COMPLETE = "autochecks_complete" + + def exit(self, event, *args, **kwargs): + if event == self.AUTOCHECKS_COMPLETE: + self._autochecks_complete_handler() + else: + raise ValueError(f"Invalid event '{event}' for Submitted state") + + def _autochecks_complete_handler(self): + pass + +class QualityTestNoEditor(State): + CLAIM = "claim" + + def exit(self, event, account=None, *args, **kwargs): + if event == self.CLAIM: + self._claim_handler(account) + else: + raise ValueError(f"Invalid event '{event}' for QualityTestNoEditor state") + + def _claim_handler(self, account): + self.application.set_editor(account.id) + +class QualityTestWithEditor(State): + UNCLAIM = "unclaim", + PASS = "quality_test_pass" + FAIL = "quality_test_fail" + + def exit(self, event, *args, **kwargs): + if event == self.UNCLAIM: + self._unclaim() + elif event == self.PASS: + self._quality_test_pass() + elif event == self.FAIL: + self._quality_test_fail() + else: + raise ValueError(f"Invalid event '{event}' for QualityTestWithEditor state") + + def _remove_badge(self): + pass + + def _unclaim(self): + self.application.remove_editor() + self._remove_badge() + + def _quality_test_pass(self): + pass + + def _quality_test_fail(self): + pass + + +class WorkflowService: + TRANSITIONS = [ + (Draft, Draft.SUBMIT, Submitted), + (Submitted, Submitted.AUTOCHECKS_COMPLETE, QualityTestNoEditor), + (QualityTestNoEditor, QualityTestNoEditor.CLAIM, QualityTestWithEditor), + ] + + @classmethod + def list_for_state(cls, state:State.__class__): + query = state.query() + for app in models.Application.iterate_unstable(q=query): + yield state(app) + + @classmethod + def event(cls, state_instance:State, event:str): + for (source, evt, target) in cls.TRANSITIONS: + if isinstance(state_instance, source) and evt == event: + return cls.transition(state_instance, event, target, validate=False) + raise ValueError(f"No transition for event '{event}' from state '{type(state_instance).__name__}'") + + @classmethod + def transition(cls, state_instance:State, event, target_state:State.__class__, validate=True): + if validate: + found = False + for (source, evt, target) in cls.TRANSITIONS: + if isinstance(state_instance, source) and evt == event and target == target_state: + found = True + break + if not found: + raise ValueError(f"Invalid transition for event '{event}' from state '{type(state_instance).__name__}' to state '{target_state.__name__}'") + + state_instance.exit(event) + new_state = target_state(state_instance.application) + new_state.enter() + return new_state + + +if __name__ == "__main__": + drafts = WorkflowService.list_for_state(Draft) + draft = drafts.__next__() + new_state = WorkflowService.event(draft, draft.SUBMIT) From b7399dc57b288d68cfd5e44aa490b8f5a69448f8 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Fri, 3 Apr 2026 18:19:41 +0100 Subject: [PATCH 02/13] full working prototype of the state machine --- doajtest/testdrive/workflow.py | 54 +++ doajtest/unit/bll_workflow/__init__.py | 0 doajtest/unit/bll_workflow/test_basic.py | 22 + portality/app.py | 2 + portality/bll/doaj.py | 7 +- portality/bll/services/workflow.py | 406 ++++++++++++++++++ portality/models/__init__.py | 1 + portality/models/workflow.py | 200 +++++++++ portality/settings.py | 1 + .../admin/_workflow/includes/assign.html | 6 + .../admin/_workflow/includes/claim.html | 7 + .../admin/_workflow/includes/edit.html | 1 + .../admin/_workflow/includes/fail.html | 7 + .../admin/_workflow/includes/unclaim.html | 7 + .../_workflow/includes/workflow_entry.html | 25 ++ .../management/admin/workflow.html | 30 ++ portality/ui/templates.py | 9 + portality/ui/workflow.py | 84 ++++ portality/view/workflow.py | 101 +++++ portality/workflow.py | 160 ------- 20 files changed, 969 insertions(+), 161 deletions(-) create mode 100644 doajtest/testdrive/workflow.py create mode 100644 doajtest/unit/bll_workflow/__init__.py create mode 100644 doajtest/unit/bll_workflow/test_basic.py create mode 100644 portality/bll/services/workflow.py create mode 100644 portality/models/workflow.py create mode 100644 portality/templates-v2/management/admin/_workflow/includes/assign.html create mode 100644 portality/templates-v2/management/admin/_workflow/includes/claim.html create mode 100644 portality/templates-v2/management/admin/_workflow/includes/edit.html create mode 100644 portality/templates-v2/management/admin/_workflow/includes/fail.html create mode 100644 portality/templates-v2/management/admin/_workflow/includes/unclaim.html create mode 100644 portality/templates-v2/management/admin/_workflow/includes/workflow_entry.html create mode 100644 portality/templates-v2/management/admin/workflow.html create mode 100644 portality/ui/workflow.py create mode 100644 portality/view/workflow.py delete mode 100644 portality/workflow.py diff --git a/doajtest/testdrive/workflow.py b/doajtest/testdrive/workflow.py new file mode 100644 index 0000000000..7c343fbc89 --- /dev/null +++ b/doajtest/testdrive/workflow.py @@ -0,0 +1,54 @@ +from doajtest.fixtures import ApplicationFixtureFactory +from doajtest.testdrive.factory import TestDrive +from portality import models, constants +from portality.bll import DOAJ + + +class Workflow(TestDrive): + + def setup(self) -> dict: + un = self.create_random_str() + pw = self.create_random_str() + acc = models.Account.make_account(un + "@example.com", un, "Admin " + un,[constants.ROLE_ADMIN]) + acc.set_password(pw) + acc.generate_api_key() + acc.save() + + svc = DOAJ.workflowService() + states = [] + for i in range(5): + source = ApplicationFixtureFactory.make_application_source() + app = models.Application(**source) + state = svc.initialise_workflow(app) + state.saveall() + states.append(state) + + report = { + + } + for state in states: + t = str(type(state)) + if t not in report: + report[t] = [] + report[t].append({ + "application" : state.application.id, + "workflow_control": state.workflow_control.id + }) + + return { + "account": { + "username": acc.id, + "password": pw, + "api_key": acc.api_key + }, + "states": report + } + + def teardown(self, params) -> dict: + models.Account.remove_by_id(params["account"]["username"]) + for state, components in params["states"].items(): + for component in components: + models.Application.remove_by_id(component["application"]) + models.WorkflowControl.remove_by_id(component["workflow_control"]) + + return {"status": "success"} \ No newline at end of file diff --git a/doajtest/unit/bll_workflow/__init__.py b/doajtest/unit/bll_workflow/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/doajtest/unit/bll_workflow/test_basic.py b/doajtest/unit/bll_workflow/test_basic.py new file mode 100644 index 0000000000..14f113135f --- /dev/null +++ b/doajtest/unit/bll_workflow/test_basic.py @@ -0,0 +1,22 @@ +from doajtest.fixtures import ApplicationFixtureFactory +from doajtest.helpers import DoajTestCase +from portality import models +from portality.bll import DOAJ +from portality.bll.services.workflow import AwaitingTriage + + +class TestAnon(DoajTestCase): + def setUp(self): + pass + + def tearDown(self): + pass + + def test_01_initialise_workflow(self): + source = ApplicationFixtureFactory.make_application_source() + application = models.Application(**source) + + wfSvc = DOAJ.workflowService() + state = wfSvc.initialise_workflow(application) + + assert isinstance(state, AwaitingTriage) diff --git a/portality/app.py b/portality/app.py index 626805863f..a6c945b3bf 100644 --- a/portality/app.py +++ b/portality/app.py @@ -49,6 +49,7 @@ from portality.lib.normalise import normalise_doi from portality.view.dashboard import blueprint as dashboard from portality.view.tours import blueprint as tours +from portality.view.workflow import blueprint as workflow if app.config.get("DEBUG", False) and app.config.get("TESTDRIVE_ENABLED", False): from portality.view.testdrive import blueprint as testdrive @@ -87,6 +88,7 @@ app.register_blueprint(jct, url_prefix="/jct") # ~~-> JCT:Blueprint~~ app.register_blueprint(dashboard, url_prefix="/dashboard") #~~-> Dashboard:Blueprint~~ app.register_blueprint(tours, url_prefix="/tours") # ~~-> Tours:Blueprint~~ +app.register_blueprint(workflow, url_prefix="/workflow") # ~~-> Workflow:Blueprint~~ app.register_blueprint(oaipmh) # ~~-> OAIPMH:Blueprint~~ app.register_blueprint(openurl) # ~~-> OpenURL:Blueprint~~ diff --git a/portality/bll/doaj.py b/portality/bll/doaj.py index 587f01a638..e648620280 100644 --- a/portality/bll/doaj.py +++ b/portality/bll/doaj.py @@ -173,4 +173,9 @@ def adminAlertsService(cls): :return: AdminAlertsService """ from portality.bll.services import admin_alerts - return admin_alerts.AdminAlertsService() \ No newline at end of file + return admin_alerts.AdminAlertsService() + + @classmethod + def workflowService(cls): + from portality.bll.services import workflow + return workflow.WorkflowService() \ No newline at end of file diff --git a/portality/bll/services/workflow.py b/portality/bll/services/workflow.py new file mode 100644 index 0000000000..a0e8cd92ed --- /dev/null +++ b/portality/bll/services/workflow.py @@ -0,0 +1,406 @@ +from typing import Type, Union + +from portality import models, constants + +# Generic state definition values +ANY = "*" +UNASSIGNED = "-" +ASSIGNED = "+" + +# Triage module state definition values +MODULE_TRIAGE = "triage" +MODULE_TRIAGE_STAGE_IN_PROGRESS = "in_progress" +MODULE_TRIAGE_STAGE_MINIMAL_REVIEW = "minimal_review" +MODULE_TRIAGE_STAGES = [ + MODULE_TRIAGE_STAGE_IN_PROGRESS, + MODULE_TRIAGE_STAGE_MINIMAL_REVIEW +] +MODULE_TRIAGE_EG = "Triage" + +class WorkflowControlStateQuery: + def __init__(self, + module:str=None, + stage:str=None, + editor_group:str=None, + reviewer:str=None, + size:int=100): + self._module = module + self._stage = stage + self._editor_group = editor_group + self._reviewer = reviewer + self._size = size + + @property + def size(self): + return self._size + + @size.setter + def size(self, size:int): + self._size = size + + def query(self): + must = [] + must_not = [] + + if self._module is not None: + if self._module != ANY: + if self._module == UNASSIGNED: + must_not.append({"exists": {"field": "state.module"}}) + elif self._module == ASSIGNED: + must.append({"exists": {"field": "state.module"}}) + else: + must.append({"term": {"state.module.exact": self._module}}) + + if self._stage is not None: + if self._stage != ANY: + if self._stage == UNASSIGNED: + must_not.append({"exists": {"field": "state.stage"}}) + elif self._stage == ASSIGNED: + must.append({"exists": {"field": "state.stage"}}) + else: + must.append({"term": {"state.stage.exact": self._stage}}) + + if self._editor_group is not None: + if self._editor_group != ANY: + if self._editor_group == UNASSIGNED: + must_not.append({"exists": {"field": "state.editor_group"}}) + elif self._editor_group == ASSIGNED: + must.append({"exists": {"field": "state.editor_group"}}) + else: + must.append({"term": {"state.editor_group.exact": self._editor_group}}) + + if self._reviewer is not None: + if self._reviewer != ANY: + if self._reviewer == UNASSIGNED: + must_not.append({"exists": {"field": "state.reviewer"}}) + elif self._reviewer == ASSIGNED: + must.append({"exists": {"field": "state.reviewer"}}) + else: + must.append({"term": {"state.reviewer.exact": self._reviewer}}) + + bool = {} + if len(must) > 0: + bool["must"] = must + if len(must_not) > 0: + bool["must_not"] = must_not + q = {"query": {"bool": bool}} + + q["sort"] = {"created_date": {"order": "asc"}} + + return q + +class WorkflowEvent: + pass + +class WorkflowAction: + pass + +class State: + module = None + stage = None + editor_group = None + reviewer = None + + events = [] + actions = [] + + def __init__(self, wf_control:models.WorkflowControl, application:models.Application=None): + self._wf_control = wf_control + self._application = application + + @classmethod + def query(cls): + return WorkflowControlStateQuery(cls.module, cls.stage, cls.editor_group, cls.reviewer) + + @classmethod + def enter(cls, wf_control:models.WorkflowControl, application:models.Application=None): + instance = cls(wf_control, application) + instance.apply() + return instance + + @classmethod + def matches(cls, wf_control:models.WorkflowControl): + # Check the module. + # If the wfc has no module, and the state definition requires a specific module, then it doesn't match. + # If the wfc has a module, and the state definition requires a specifc module, and it isn't this one, then it doesn't match + if wf_control.module is None: + if not (cls.module == ANY): + return False + else: + if not (cls.module == ANY or cls.module == wf_control.module): + return False + + # if we get to here, module matches + + # check the stage + # If the wfc has no stage, and the state definition requires a specific stage, then it doesn't match. + # If the wfc has a stage, and the state definition requires a specifc stage, and it isn't this one, then it doesn't match + if wf_control.stage is None: + if not (cls.stage == ANY): + return False + else: + if not (cls.stage == ANY or cls.stage == wf_control.stage): + return False + + # by here, module and stage match + + # check the editor group + # if the wfc has no eg, then if the allowed editor group is not ANY or UNASSIGNED), no match + # if the wfc has an eg, then if the allowed editor group is not ANY or ASSIGNED or the same as the wfc's eg, no match + if wf_control.editor_group is None: + if not (cls.editor_group == ANY or cls.editor_group == UNASSIGNED): + return False + else: + if not (cls.editor_group == ANY or cls.editor_group == ASSIGNED or cls.editor_group == wf_control.editor_group): + return False + + # by here, module, stage and editor group match + + if wf_control.reviewer is None: + if not (cls.reviewer == ANY or cls.reviewer == UNASSIGNED): + return False + else: + if not (cls.reviewer == ANY or cls.reviewer == ASSIGNED or cls.reviewer == wf_control.editor_group): + return False + + return True + + @property + def workflow_control(self): + return self._wf_control + + @property + def application(self): + if self._application is None: + app_id = self.workflow_control.application_id + self._application = models.Application.pull(app_id) + return self._application + + def apply(self): + pass + + def exit(self, event:WorkflowEvent): + return self + + def do(self, action:WorkflowAction): + return self + + def saveall(self, *args, **kwargs): + self.workflow_control.save(*args, **kwargs) + if self._application: + self._application.save(*args, **kwargs) + + +class ReviewerAssignment(WorkflowEvent): + def __init__(self, reviewer:str): + self.reviewer = reviewer + +class Claim(ReviewerAssignment): pass + +class Unclaim(WorkflowEvent): pass + +class Assign(ReviewerAssignment): pass + +class Reassign(ReviewerAssignment): pass + +class Fail(WorkflowEvent): pass + +class MinimalReview(WorkflowEvent): pass + +class ApplicationEdit(WorkflowAction): pass + +class AwaitingTriage(State): + module = MODULE_TRIAGE + stage = ANY + editor_group = MODULE_TRIAGE_EG + reviewer = UNASSIGNED + + events = [Claim, Assign] + + def apply(self): + # Ensure the workflow control object is in the right state + wf_control = self.workflow_control + if wf_control.module != self.module: + wf_control.module = self.module + + if wf_control.stage not in MODULE_TRIAGE_STAGES: + if wf_control.triage.has_minimal_review: + wf_control.stage = MODULE_TRIAGE_STAGE_MINIMAL_REVIEW + else: + wf_control.stage = MODULE_TRIAGE_STAGE_IN_PROGRESS + + if wf_control.editor_group != self.editor_group: + wf_control.editor_group = self.editor_group + wf_control.reviewer = None + + # back-compatibility for the original workflow + application = self.application + + if application.application_status != constants.APPLICATION_STATUS_PENDING: + application.set_application_status(constants.APPLICATION_STATUS_PENDING) + + if application.editor_group != self.editor_group: + application.set_editor_group(self.editor_group) + application.remove_editor() + + def exit(self, event:WorkflowEvent): + if isinstance(event, Claim): + return self.event_claim(event) + elif isinstance(event, Assign): + return self.event_assign(event) + else: + raise ValueError(f"Unknown event '{event}' for state '{type(self).__name__}'") + + def event_claim(self, event:Claim): + wfc = self.workflow_control + wfc.reviewer = event.reviewer + if wfc.triage.has_minimal_review: + return TriageAssessmentMinimalReview.enter(self._wf_control, self._application) + else: + return TriageAssessmentInProgress.enter(self._wf_control, self._application) + + def event_assign(self, event:Assign): + wfc = self.workflow_control + wfc.reviewer = event.reviewer + if wfc.triage.has_minimal_review: + return TriageAssessmentMinimalReview.enter(self._wf_control, self._application) + else: + return TriageAssessmentInProgress.enter(self._wf_control, self._application) + +class TriageAssessmentInProgress(State): + module = MODULE_TRIAGE + stage = MODULE_TRIAGE_STAGE_IN_PROGRESS + editor_group = MODULE_TRIAGE_EG + reviewer = ASSIGNED + + events = [Unclaim, Reassign, Fail, MinimalReview] + actions = [ApplicationEdit] + + def apply(self): + # Ensure the workflow control object is in the right state + wf_control = self.workflow_control + if wf_control.module != self.module: + wf_control.module = self.module + + if wf_control.stage != self.stage: + wf_control.stage = self.stage + + if wf_control.editor_group != self.editor_group: + wf_control.editor_group = self.editor_group + + if wf_control.reviewer is None: + raise ValueError("Reviewer must be set for TriageAssessmentInProgress state") + + # back-compatibility for the original workflow + application = self.application + + if application.application_status != constants.APPLICATION_STATUS_IN_PROGRESS: + application.set_application_status(constants.APPLICATION_STATUS_IN_PROGRESS) + + if application.editor_group != self.editor_group: + application.set_editor_group(self.editor_group) + + if application.editor != wf_control.reviewer: + application.set_editor(wf_control.reviewer) + + def exit(self, event:WorkflowEvent): + if isinstance(event, Unclaim): + return self.event_unclaim(event) + elif isinstance(event, Reassign): + return self.event_reassign(event) + elif isinstance(event, Fail): + return self.event_fail(event) + elif isinstance(event, MinimalReview): + return self.event_minimal_review(event) + else: + raise ValueError(f"Unknown event '{event}' for state '{type(self).__name__}'") + + def do(self, action:WorkflowAction): + if isinstance(action, ApplicationEdit): + self.do_edit(action) + else: + raise ValueError(f"Unknown action '{action}' for state '{type(self).__name__}'") + + def event_unclaim(self, event:Unclaim): + wfc = self.workflow_control + del wfc.reviewer + return AwaitingTriage.enter(self._wf_control, self._application) + + def event_reassign(self, event:Reassign): + wfc = self.workflow_control + wfc.reviewer = event.reviewer + return self.enter(self._wf_control, self._application) + + def event_fail(self, event:Fail): + # TODO: we don't know where this goes yet + raise NotImplementedError() + + def event_minimal_review(self, event:MinimalReview): + wfc = self.workflow_control + wfc.triage.has_minimal_review = True + return TriageAssessmentMinimalReview.enter(self._wf_control, self._application) + + def do_edit(self, *args, **kwargs): + # FIXME: where does has_minimal_review get calculated? + if self.workflow_control.triage.has_minimal_review: + return TriageAssessmentMinimalReview.enter(self._wf_control, self._application) + else: + return self + +class TriageAssessmentMinimalReview(State): + pass + + + +class WorkflowService: + STATES = [ + AwaitingTriage, + TriageAssessmentInProgress, + TriageAssessmentMinimalReview + ] + + def iterate_state(self, state:Type[State]): + query = state.query() + for wfc in models.WorkflowControl.iterate_unstable(q=query.query()): + yield state(wfc) + + def first_n_in_state(self, state:Type[State], n:int) -> list[State]: + query = state.query() + query.size = n + return [state(x) for x in models.WorkflowControl.object_query(q=query.query())] + + def apply_event(self, wfc_id:str, event:WorkflowEvent, save=True): + state_instance = self.state_for_workflow_control(wfc_id) + if state_instance is None: + raise ValueError(f"No state found for workflow control with id '{wfc_id}'") + + new_state = self.event(state_instance, event) + if save: + new_state.saveall() + return new_state + + def state_for_workflow_control(self, wfc_id:str) -> Union[State, None]: + wfc = models.WorkflowControl.pull(wfc_id) + if wfc is None: + return None + + for state in self.STATES: + if state.matches(wfc): + return state(wfc) + + return None + + def event(self, state_instance:State, event:WorkflowEvent) -> State: + if event.__class__ not in state_instance.events: + raise ValueError(f"Invalid event '{event}' for state '{type(state_instance).__name__}'") + + new_state = state_instance.exit(event) + return new_state + + def initialise_workflow(self, application): + wfc = models.WorkflowControl() + wfc.application_id = application.id + wfc.original_application = application + wfc.application_title = application.bibjson().title + initial_state = AwaitingTriage.enter(wfc, application) + return initial_state \ No newline at end of file diff --git a/portality/models/__init__.py b/portality/models/__init__.py index bdd14687f5..e77996ff78 100644 --- a/portality/models/__init__.py +++ b/portality/models/__init__.py @@ -33,6 +33,7 @@ from portality.models.admin_alert import AdminAlert from portality.models.shortened_url import ShortenedUrl, CountWithinDaysQuery from portality.models.ris_export import RISExport +from portality.models.workflow import WorkflowControl import sys diff --git a/portality/models/workflow.py b/portality/models/workflow.py new file mode 100644 index 0000000000..d73744ee5e --- /dev/null +++ b/portality/models/workflow.py @@ -0,0 +1,200 @@ +from portality.dao import DomainObject +from portality.lib import es_data_mapping +from portality.lib.coerce import COERCE_MAP +from portality.lib.seamless import SeamlessMixin +from portality.core import app +from portality.models import Application + +import json + +TRIAGE_STRUCT = { + "fields": { + "has_minimal_review": {"coerce": "bool", "default": False}, + } +} + +STRUCT = { + "fields": { + "id": {"coerce": "unicode"}, + "created_date": {"coerce": "utcdatetime"}, + "last_updated": {"coerce": "utcdatetime"}, + "es_type": {"coerce": "unicode"}, + }, + "objects": ["application", "state", "modules", "audit"], + "structs": { + "application": { + "fields": { + "id": {"coerce": "unicode"}, + "title": {"coerce": "unicode"}, + "original": {"coerce": "unicode"}, + } + }, + "audit": { + "fields": { + "user": {"coerce": "unicode"}, + "date": {"coerce": "utcdatetime"}, + "from_state": {"coerce": "unicode"}, + "to_state": {"coerce": "unicode"} + } + }, + "modules": { + "objects": ["triage", "quick_fail"], + "structs": { + "triage": TRIAGE_STRUCT, + "quick_fail": {} + } + }, + "state": { + "fields": { + "module": {"coerce": "unicode"}, + "stage": {"coerce": "unicode"}, + "editor_group": {"coerce": "unicode"}, + "reviewer": {"coerce": "unicode"} + } + } + } +} + +MAPPING_OPTS = { + "dynamic": None, + "coerces": app.config["DATAOBJ_TO_MAPPING_DEFAULTS"], + "exceptions": { + "application.original": { + "type": "text" + } + } +} + +class WorkflowControl(SeamlessMixin, DomainObject): + __type__ = "workflow_control" + + __SEAMLESS_STRUCT__ = STRUCT + __SEAMLESS_COERCE__ = COERCE_MAP + + def __init__(self, **kwargs): + # FIXME: hack, to deal with ES integration layer being improperly abstracted + if "_source" in kwargs: + kwargs = kwargs["_source"] + super(WorkflowControl, self).__init__(raw=kwargs) + + def mappings(self): + return es_data_mapping.create_mapping(self.__seamless_struct__.raw, MAPPING_OPTS) + + @property + def data(self): + return self.__seamless__.data + + #################################### + ## state properties + + @property + def module(self): + return self.__seamless__.get_single("state.module") + + @module.setter + def module(self, val): + self.__seamless__.set_single("state.module", val) + + @property + def stage(self): + return self.__seamless__.get_single("state.stage") + + @stage.setter + def stage(self, val): + self.__seamless__.set_single("state.stage", val) + + @property + def editor_group(self): + return self.__seamless__.get_single("state.editor_group") + + @editor_group.setter + def editor_group(self, val): + self.__seamless__.set_single("state.editor_group", val) + + @property + def reviewer(self): + return self.__seamless__.get_single("state.reviewer") + + @reviewer.setter + def reviewer(self, val): + self.__seamless__.set_single("state.reviewer", val) + + @reviewer.deleter + def reviewer(self): + self.__seamless__.delete("state.reviewer") + + ################################## + ## Application properties + + @property + def application_id(self): + return self.__seamless__.get_single("application.id") + + @application_id.setter + def application_id(self, val): + self.__seamless__.set_single("application.id", val) + + @property + def application_title(self): + return self.__seamless__.get_single("application.title") + + @application_title.setter + def application_title(self, val): + self.__seamless__.set_single("application.title", val) + + @property + def original_application_raw(self): + return self.__seamless__.get_single("application.original") + + @original_application_raw.setter + def original_application_raw(self, val): + self.__seamless__.set_single("application.original", val) + + @property + def original_application_json(self): + return json.loads(self.original_application_raw) + + @property + def original_application(self): + j = self.original_application_json + return Application(**j) + + @original_application.setter + def original_application(self, val): + raw = json.dumps(val.data) + self.original_application_raw = raw + + ################################## + ## Module specifics + + @property + def triage(self): + t = self.__seamless__.get_single("modules.triage") + if t is None: + self.__seamless__.set_single("modules.triage", {}) + t = self.__seamless__.get_single("modules.triage") + return Triage(t) + +class Triage(SeamlessMixin): + __SEAMLESS_STRUCT__ = TRIAGE_STRUCT + __SEAMLESS_COERCE__ = COERCE_MAP + + # constructor + def __init__(self, raw=None, **kwargs): + super(Triage, self).__init__(raw=raw, **kwargs) + + @property + def data(self): + return self.__seamless__.data + + @property + def has_minimal_review(self): + return self.__seamless__.get_single("has_minimal_review") + + @has_minimal_review.setter + def has_minimal_review(self, val): + self.__seamless__.set_single("has_minimal_review", val) + + +########################################## + diff --git a/portality/settings.py b/portality/settings.py index 991511370c..89208d3f4e 100644 --- a/portality/settings.py +++ b/portality/settings.py @@ -495,6 +495,7 @@ "portality.models.ur_review_route.URReviewRoute", # ~~-> URReviewRoute:Model~~ "portality.models.admin_alert.AdminAlert", # ~~-> AdminAlert:Model~~ "portality.models.ris_export.RISExport", + "portality.models.workflow.WorkflowControl" ] # Map from dataobj coercion declarations to ES mappings diff --git a/portality/templates-v2/management/admin/_workflow/includes/assign.html b/portality/templates-v2/management/admin/_workflow/includes/assign.html new file mode 100644 index 0000000000..3c62b508b8 --- /dev/null +++ b/portality/templates-v2/management/admin/_workflow/includes/assign.html @@ -0,0 +1,6 @@ +
+ + +
\ No newline at end of file diff --git a/portality/templates-v2/management/admin/_workflow/includes/claim.html b/portality/templates-v2/management/admin/_workflow/includes/claim.html new file mode 100644 index 0000000000..6f4baa7813 --- /dev/null +++ b/portality/templates-v2/management/admin/_workflow/includes/claim.html @@ -0,0 +1,7 @@ +
+ + {% if onward %} + + {% endif %} + +
\ No newline at end of file diff --git a/portality/templates-v2/management/admin/_workflow/includes/edit.html b/portality/templates-v2/management/admin/_workflow/includes/edit.html new file mode 100644 index 0000000000..540ca36351 --- /dev/null +++ b/portality/templates-v2/management/admin/_workflow/includes/edit.html @@ -0,0 +1 @@ +Edit \ No newline at end of file diff --git a/portality/templates-v2/management/admin/_workflow/includes/fail.html b/portality/templates-v2/management/admin/_workflow/includes/fail.html new file mode 100644 index 0000000000..305d851ea4 --- /dev/null +++ b/portality/templates-v2/management/admin/_workflow/includes/fail.html @@ -0,0 +1,7 @@ +
+ + {% if onward %} + + {% endif %} + +
\ No newline at end of file diff --git a/portality/templates-v2/management/admin/_workflow/includes/unclaim.html b/portality/templates-v2/management/admin/_workflow/includes/unclaim.html new file mode 100644 index 0000000000..40e4c70de0 --- /dev/null +++ b/portality/templates-v2/management/admin/_workflow/includes/unclaim.html @@ -0,0 +1,7 @@ +
+ + {% if onward %} + + {% endif %} + +
\ No newline at end of file diff --git a/portality/templates-v2/management/admin/_workflow/includes/workflow_entry.html b/portality/templates-v2/management/admin/_workflow/includes/workflow_entry.html new file mode 100644 index 0000000000..e5919ea3f9 --- /dev/null +++ b/portality/templates-v2/management/admin/_workflow/includes/workflow_entry.html @@ -0,0 +1,25 @@ +
+ {% set wfc = state.workflow_control %} +

{{ wfc.application_title }}


+ + Module: {{ wfc.module }} + Stage: {{ wfc.stage }} + {% if wfc.editorial_group %} + Group: {{ wfc.editorial_group }} + {% endif %} + {% if wfc.reviewer %} + Reviewer: {{ wfc.reviewer }} + {% endif %} + + {% for event in state.events %} +
+ {% include event.template %} +
+ {% endfor %} + + {% for action in state.actions %} +
+ {% include action.template %} +
+ {% endfor %} +
\ No newline at end of file diff --git a/portality/templates-v2/management/admin/workflow.html b/portality/templates-v2/management/admin/workflow.html new file mode 100644 index 0000000000..9c5c43dd11 --- /dev/null +++ b/portality/templates-v2/management/admin/workflow.html @@ -0,0 +1,30 @@ +{% extends "management/admin/base.html" %} +{# ~~Workflow:Page~~ #} + +{% block page_title %}Workflow Overview{% endblock %} + +{% block page_header %} + Hi, {% if current_user.name %}{{ current_user.name }}{% else %} + {{ current_user.id }}{% endif %}! +{% endblock %} + +{% block admin_content %} + {% set onward = "workflow.index" %} +

Awaiting Triage

+
+ {% for state in awaiting_triage %} + {% include "management/admin/_workflow/includes/workflow_entry.html" %} + {% endfor %} +
+ +

Triage In Progress

+
+ {% for state in triage_in_progress %} + {% include "management/admin/_workflow/includes/workflow_entry.html" %} + {% endfor %} +
+{% endblock %} + +{% block admin_js %} + +{% endblock %} diff --git a/portality/ui/templates.py b/portality/ui/templates.py index d539cd6cf7..044419f97d 100644 --- a/portality/ui/templates.py +++ b/portality/ui/templates.py @@ -55,6 +55,7 @@ DASHBOARD = "management/admin/dashboard.html" NOTIFICATIONS = "management/admin/notifications.html" ADMIN_UNLOCKED = "management/admin/unlocked.html" +ADMIN_WORKFLOW_OVERVIEW = "management/admin/workflow.html" # Application Form MANED_APPLICATION_FORM = "management/admin/maned_application.html" @@ -70,6 +71,14 @@ EDITOR_READ_ONLY_JOURNAL = "management/editor/readonly_journal.html" MANED_JOURNAL_BULK_EDIT = "management/admin/_application-form/layouts/maned_journal_bulk_edit.html" +# Workflow components +WORKFLOW_CLAIM_WIDGET = "management/admin/_workflow/includes/claim.html" +WORKFLOW_ASSIGN_WIDGET = "management/admin/_workflow/includes/assign.html" +WORKFLOW_UNCLAIM_WIDGET = "management/admin/_workflow/includes/unclaim.html" +WORKFLOW_MINIMAL_REVIEW_WIDGET = "management/admin/_workflow/includes/minimal_review.html" +WORKFLOW_FAIL_WIDGET = "management/admin/_workflow/includes/fail.html" +WORKFLOW_EDIT_WIDGET = "management/admin/_workflow/includes/edit.html" + # Reusable application form components AF_ENTRY_GOUP = "_application-form/includes/_entry_group.html" AF_ENTRY_GROUP_HORIZONTAL = "_application-form/includes/_entry_group_horizontal.html" diff --git a/portality/ui/workflow.py b/portality/ui/workflow.py new file mode 100644 index 0000000000..88f9dfcee7 --- /dev/null +++ b/portality/ui/workflow.py @@ -0,0 +1,84 @@ +from portality.bll.services.workflow import Claim, Assign, Reassign, Unclaim, Fail, MinimalReview, ApplicationEdit +from portality.ui import templates +from portality.util import url_for + +class StateUI: + def __init__(self, state): + self.state = state + self._events = None + self._actions = None + + @property + def workflow_control(self): + return self.state.workflow_control + + @property + def events(self): + if self._events is not None: + return self._events + self._events = [EVENT_MAP.get(e) for e in self.state.events if EVENT_MAP.get(e) is not None] + return self._events + + @property + def actions(self): + if self._actions is not None: + return self._actions + self._actions = [ACTION_MAP.get(a) for a in self.state.actions if ACTION_MAP.get(a) is not None] + return self._actions + +################################# + +class EventUI: + template = None + route_id = None + + @classmethod + def get_route(cls): + return url_for(cls.route_id) + +class ClaimUI(EventUI): + template = templates.WORKFLOW_CLAIM_WIDGET + route_id = "workflow.claim" + +class AssignUI(EventUI): + template = templates.WORKFLOW_ASSIGN_WIDGET + route_id = "workflow.assign" + +class UnclaimUI(EventUI): + template = templates.WORKFLOW_UNCLAIM_WIDGET + route_id = "workflow.unclaim" + +class ReassignUI(EventUI): + template = templates.WORKFLOW_ASSIGN_WIDGET + route_id = "workflow.reassign" + +class FailUI(EventUI): + template = templates.WORKFLOW_FAIL_WIDGET + route_id = "workflow.fail" + +EVENT_MAP = { + Claim: ClaimUI, + Assign: AssignUI, + Reassign: ReassignUI, + Unclaim: UnclaimUI, + Fail: FailUI, + MinimalReview: None +} + +##################################### + +class ActionUI: + template = None + route_id = None + + @classmethod + def get_route(cls, *args, **kwargs): + return url_for(cls.route_id, *args, **kwargs) + +class ApplicationEditUI(ActionUI): + template = templates.WORKFLOW_EDIT_WIDGET + route_id = "workflow.edit" + +ACTION_MAP = { + ApplicationEdit: ApplicationEditUI +} \ No newline at end of file diff --git a/portality/view/workflow.py b/portality/view/workflow.py new file mode 100644 index 0000000000..8d74e3fe12 --- /dev/null +++ b/portality/view/workflow.py @@ -0,0 +1,101 @@ +import json + +from flask import Blueprint, render_template, request, abort, url_for, redirect, make_response +from flask_login import login_required, current_user + +from portality import models +from portality.bll import DOAJ +from portality.bll.services.workflow import AwaitingTriage, TriageAssessmentInProgress, Claim, Unclaim +from portality.decorators import ssl_required +from portality.ui import templates +from portality.ui.workflow import StateUI + +blueprint = Blueprint('workflow', __name__) + +@blueprint.route('/') +@login_required +@ssl_required +def index(): + svc = DOAJ.workflowService() + awaiting_triage = [StateUI(x) for x in svc.first_n_in_state(AwaitingTriage, 10)] + triage_in_progress = [StateUI(x) for x in svc.first_n_in_state(TriageAssessmentInProgress, 10)] + return render_template(templates.ADMIN_WORKFLOW_OVERVIEW, + awaiting_triage=awaiting_triage, + triage_in_progress=triage_in_progress, + admin_page=True) + +@blueprint.route('/claim', methods=['POST']) +@login_required +@ssl_required +def claim(): + wfc_id = request.form.get("workflow_control") + if wfc_id is None: + abort(400) + + svc = DOAJ.workflowService() + try: + svc.apply_event(wfc_id, Claim(current_user.id)) + except ValueError: + abort(404) + + onward = request.form.get("onward") + if onward is not None: + url = url_for(onward) + return redirect(url) + + resp = make_response(json.dumps({"status": "success"})) + resp.mimetype = "application/json" + return resp + +@blueprint.route("/unclaim", methods=["POST"]) +@login_required +@ssl_required +def unclaim(): + wfc_id = request.form.get("workflow_control") + if wfc_id is None: + abort(400) + + svc = DOAJ.workflowService() + try: + svc.apply_event(wfc_id, Unclaim()) + except ValueError: + abort(404) + + onward = request.form.get("onward") + if onward is not None: + url = url_for(onward) + return redirect(url) + + resp = make_response(json.dumps({"status": "success"})) + resp.mimetype = "application/json" + return resp + +@blueprint.route("/assign", methods=["POST"]) +@login_required +@ssl_required +def assign(): + pass + +@blueprint.route("/reassign", methods=["POST"]) +@login_required +@ssl_required +def reassign(): + pass + +@blueprint.route("/fail", methods=["POST"]) +@login_required +@ssl_required +def fail(): + pass + +@blueprint.route("/minimal_review", methods=["POST"]) +@login_required +@ssl_required +def minimal_review(): + pass + +@blueprint.route("/edit/", methods=["GET"]) +@login_required +@ssl_required +def edit(application_id): + pass \ No newline at end of file diff --git a/portality/workflow.py b/portality/workflow.py deleted file mode 100644 index 96bd7ea481..0000000000 --- a/portality/workflow.py +++ /dev/null @@ -1,160 +0,0 @@ -from portality import models - - -class State: - - def __init__(self, application): - self._application = application - - @classmethod - def query(cls): - return StateQuery("Draft") - - @property - def application(self): - return self._application - - def enter(self): - pass - - def exit(self, event, *args, **kwargs): - pass - - -class StateQuery: - def __init__(self, domain:str, subdomain:str=None, badge:list=None, editor_group:bool=False, editor:bool=False, revisions:bool=False): - self.domain = domain - self.subdomain = subdomain - self.badge = badge or [] - self.editor_group = editor_group - self.editor = editor - self.revisions = revisions - - def query(self): - q = { - "query": { - "bool": { - "must": [ - {"term": {"workflow.domain.exact": self.domain}} - ] - } - } - } - if self.subdomain: - q["query"]["bool"]["must"].append({"term": {"admin.workflow.subdomain.exact": self.subdomain}}) - if self.badge: - q["query"]["bool"]["must"].append({"terms": {"admin.workflow.badges.exact": self.badge}}) - if self.editor_group: - q["query"]["bool"]["must"].append({"exists": {"field": "admin.editor_group"}}) - if self.editor: - q["query"]["bool"]["must"].append({"exists": {"field": "admin.editor"}}) - if self.revisions: - q["query"]["bool"]["must"].append({"exists": {"field": "admin.workflow.revisions"}}) - - -class Draft(State): - SUBMIT = "submit" - - def exit(self, event, *args, **kwargs): - if event == self.SUBMIT: - self._submit_handler() - else: - raise ValueError(f"Invalid event '{event}' for Draft state") - - def _submit_handler(self): - pass - -class Submitted(State): - AUTOCHECKS_COMPLETE = "autochecks_complete" - - def exit(self, event, *args, **kwargs): - if event == self.AUTOCHECKS_COMPLETE: - self._autochecks_complete_handler() - else: - raise ValueError(f"Invalid event '{event}' for Submitted state") - - def _autochecks_complete_handler(self): - pass - -class QualityTestNoEditor(State): - CLAIM = "claim" - - def exit(self, event, account=None, *args, **kwargs): - if event == self.CLAIM: - self._claim_handler(account) - else: - raise ValueError(f"Invalid event '{event}' for QualityTestNoEditor state") - - def _claim_handler(self, account): - self.application.set_editor(account.id) - -class QualityTestWithEditor(State): - UNCLAIM = "unclaim", - PASS = "quality_test_pass" - FAIL = "quality_test_fail" - - def exit(self, event, *args, **kwargs): - if event == self.UNCLAIM: - self._unclaim() - elif event == self.PASS: - self._quality_test_pass() - elif event == self.FAIL: - self._quality_test_fail() - else: - raise ValueError(f"Invalid event '{event}' for QualityTestWithEditor state") - - def _remove_badge(self): - pass - - def _unclaim(self): - self.application.remove_editor() - self._remove_badge() - - def _quality_test_pass(self): - pass - - def _quality_test_fail(self): - pass - - -class WorkflowService: - TRANSITIONS = [ - (Draft, Draft.SUBMIT, Submitted), - (Submitted, Submitted.AUTOCHECKS_COMPLETE, QualityTestNoEditor), - (QualityTestNoEditor, QualityTestNoEditor.CLAIM, QualityTestWithEditor), - ] - - @classmethod - def list_for_state(cls, state:State.__class__): - query = state.query() - for app in models.Application.iterate_unstable(q=query): - yield state(app) - - @classmethod - def event(cls, state_instance:State, event:str): - for (source, evt, target) in cls.TRANSITIONS: - if isinstance(state_instance, source) and evt == event: - return cls.transition(state_instance, event, target, validate=False) - raise ValueError(f"No transition for event '{event}' from state '{type(state_instance).__name__}'") - - @classmethod - def transition(cls, state_instance:State, event, target_state:State.__class__, validate=True): - if validate: - found = False - for (source, evt, target) in cls.TRANSITIONS: - if isinstance(state_instance, source) and evt == event and target == target_state: - found = True - break - if not found: - raise ValueError(f"Invalid transition for event '{event}' from state '{type(state_instance).__name__}' to state '{target_state.__name__}'") - - state_instance.exit(event) - new_state = target_state(state_instance.application) - new_state.enter() - return new_state - - -if __name__ == "__main__": - drafts = WorkflowService.list_for_state(Draft) - draft = drafts.__next__() - new_state = WorkflowService.event(draft, draft.SUBMIT) From a4b11ad7682f097139e4a7cd6148f5a258694b47 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Thu, 9 Apr 2026 12:25:35 +0100 Subject: [PATCH 03/13] pretty complete implementation of the triage workflow back-end, and primitive front-end --- doajtest/testdrive/workflow.py | 5 + portality/bll/services/authorisation.py | 2 - portality/bll/services/workflow.py | 245 +++++++++++++++++- portality/constants.py | 1 + portality/models/workflow.py | 32 ++- .../admin/_workflow/includes/triaged.html | 12 + .../management/admin/workflow.html | 7 + portality/ui/templates.py | 1 + portality/ui/workflow.py | 11 +- portality/view/workflow.py | 47 +++- 10 files changed, 343 insertions(+), 20 deletions(-) create mode 100644 portality/templates-v2/management/admin/_workflow/includes/triaged.html diff --git a/doajtest/testdrive/workflow.py b/doajtest/testdrive/workflow.py index 7c343fbc89..43b80a4b5c 100644 --- a/doajtest/testdrive/workflow.py +++ b/doajtest/testdrive/workflow.py @@ -2,6 +2,7 @@ from doajtest.testdrive.factory import TestDrive from portality import models, constants from portality.bll import DOAJ +from portality.lib import dates class Workflow(TestDrive): @@ -19,7 +20,11 @@ def setup(self) -> dict: for i in range(5): source = ApplicationFixtureFactory.make_application_source() app = models.Application(**source) + app.set_id(app.makeid()) + app.bibjson().title = "Workflow Test " + str(i) + app.set_created(dates.before_now(86400*i)) state = svc.initialise_workflow(app) + state.workflow_control.set_created(app.created_date) state.saveall() states.append(state) diff --git a/portality/bll/services/authorisation.py b/portality/bll/services/authorisation.py index 5be4e65c77..fbf6aa6737 100644 --- a/portality/bll/services/authorisation.py +++ b/portality/bll/services/authorisation.py @@ -80,8 +80,6 @@ def can_edit_application(self, account, application): raise exceptions.AuthoriseException(reason=no_auth_reason) - - def can_view_application(self, account, application): """ Is the given account allowed to view the update request application diff --git a/portality/bll/services/workflow.py b/portality/bll/services/workflow.py index a0e8cd92ed..3661ca4813 100644 --- a/portality/bll/services/workflow.py +++ b/portality/bll/services/workflow.py @@ -1,6 +1,9 @@ from typing import Type, Union from portality import models, constants +from portality.bll import DOAJ +from portality.bll.exceptions import AuthoriseException +from portality.models import Account, EditorGroup # Generic state definition values ANY = "*" @@ -90,10 +93,36 @@ def query(self): return q class WorkflowEvent: - pass + def __init__(self, actor:Union[str, Account]): + self._actor = actor + + @property + def actor(self) -> Account: + if isinstance(self._actor, str): + self._actor = Account.pull(self._actor) + return self._actor + + @property + def actor_id(self) -> str: + if isinstance(self._actor, str): + return self._actor + return self._actor.id class WorkflowAction: - pass + def __init__(self, actor: Union[str, Account]): + self._actor = actor + + @property + def actor(self) -> Account: + if isinstance(self._actor, str): + self._actor = Account.pull(self._actor) + return self._actor + + @property + def actor_id(self) -> str: + if isinstance(self._actor, str): + return self._actor + return self._actor.id class State: module = None @@ -192,10 +221,23 @@ def saveall(self, *args, **kwargs): class ReviewerAssignment(WorkflowEvent): - def __init__(self, reviewer:str): - self.reviewer = reviewer + def __init__(self, actor:Union[str, Account], reviewer:Union[str, Account]): + super().__init__(actor) + self._reviewer = reviewer + + @property + def reviewer(self) -> Account: + if isinstance(self._reviewer, str): + self._reviewer = Account.pull(self._reviewer) + return self._reviewer + + @property + def reviewer_id(self) -> str: + if isinstance(self._reviewer, str): + return self._reviewer + return self._reviewer.id -class Claim(ReviewerAssignment): pass +class Claim(WorkflowEvent): pass class Unclaim(WorkflowEvent): pass @@ -207,6 +249,25 @@ class Fail(WorkflowEvent): pass class MinimalReview(WorkflowEvent): pass +class RescindMinimalReview(WorkflowEvent): pass + +class Triaged(WorkflowEvent): + def __init__(self, actor:Union[str, Account], + target_editor_group:Union[str, EditorGroup]=None, + target_maned:Union[str, Account]=None): + super().__init__(actor) + self._editor_group = target_editor_group + self._maned = target_maned + + @property + def editor_group(self): + return self._editor_group + + @property + def maned(self): + return self._maned + + class ApplicationEdit(WorkflowAction): pass class AwaitingTriage(State): @@ -253,7 +314,11 @@ def exit(self, event:WorkflowEvent): def event_claim(self, event:Claim): wfc = self.workflow_control - wfc.reviewer = event.reviewer + # FIXME: needs to be resolved with feature vs role capability + if not event.actor.has_role(constants.ROLE_TRIAGE): + raise AuthoriseException(reason=AuthoriseException.WRONG_ROLE) + + wfc.reviewer = event.actor_id if wfc.triage.has_minimal_review: return TriageAssessmentMinimalReview.enter(self._wf_control, self._application) else: @@ -261,7 +326,13 @@ def event_claim(self, event:Claim): def event_assign(self, event:Assign): wfc = self.workflow_control - wfc.reviewer = event.reviewer + # FIXME: needs to be resolved with feature vs role capability + if not (event.actor.has_role(constants.ROLE_TRIAGE) or event.actor.has_role(constants.ROLE_ADMIN)): + raise AuthoriseException(reason=AuthoriseException.WRONG_ROLE) + if not event.reviewer.has_role(constants.ROLE_TRIAGE): + raise AuthoriseException(reason=AuthoriseException.WRONG_ROLE) + + wfc.reviewer = event.reviewer_id if wfc.triage.has_minimal_review: return TriageAssessmentMinimalReview.enter(self._wf_control, self._application) else: @@ -323,12 +394,18 @@ def do(self, action:WorkflowAction): def event_unclaim(self, event:Unclaim): wfc = self.workflow_control + if wfc.reviewer != event.actor_id: + raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) + del wfc.reviewer return AwaitingTriage.enter(self._wf_control, self._application) def event_reassign(self, event:Reassign): wfc = self.workflow_control - wfc.reviewer = event.reviewer + if not event.actor.has_role(constants.ROLE_ADMIN): + raise AuthoriseException(reason=AuthoriseException.WRONG_ROLE) + + wfc.reviewer = event.reviewer_id return self.enter(self._wf_control, self._application) def event_fail(self, event:Fail): @@ -337,26 +414,172 @@ def event_fail(self, event:Fail): def event_minimal_review(self, event:MinimalReview): wfc = self.workflow_control + if wfc.reviewer != event.actor_id: + raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) + wfc.triage.has_minimal_review = True return TriageAssessmentMinimalReview.enter(self._wf_control, self._application) - def do_edit(self, *args, **kwargs): + def do_edit(self, action:ApplicationEdit): + wfc = self.workflow_control + if wfc.reviewer != action.actor_id: + raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) + # FIXME: where does has_minimal_review get calculated? if self.workflow_control.triage.has_minimal_review: - return TriageAssessmentMinimalReview.enter(self._wf_control, self._application) + return self.event_minimal_review(MinimalReview(action.actor_id)) else: return self class TriageAssessmentMinimalReview(State): + module = MODULE_TRIAGE + stage = MODULE_TRIAGE_STAGE_MINIMAL_REVIEW + editor_group = MODULE_TRIAGE_EG + reviewer = ASSIGNED + + events = [Triaged, Unclaim, Reassign, Fail, RescindMinimalReview] + actions = [ApplicationEdit] + + def apply(self): + # Ensure the workflow control object is in the right state + wf_control = self.workflow_control + if wf_control.module != self.module: + wf_control.module = self.module + + if wf_control.stage != self.stage: + wf_control.stage = self.stage + + if wf_control.editor_group != self.editor_group: + wf_control.editor_group = self.editor_group + + if wf_control.reviewer is None: + raise ValueError("Reviewer must be set for TriageAssessmentMinimalReview state") + + # back-compatibility for the original workflow + application = self.application + + if application.application_status != constants.APPLICATION_STATUS_IN_PROGRESS: + application.set_application_status(constants.APPLICATION_STATUS_IN_PROGRESS) + + if application.editor_group != self.editor_group: + application.set_editor_group(self.editor_group) + + if application.editor != wf_control.reviewer: + application.set_editor(wf_control.reviewer) + + def exit(self, event: WorkflowEvent): + if isinstance(event, Triaged): + return self.event_triaged(event) + elif isinstance(event, Unclaim): + return self.event_unclaim(event) + elif isinstance(event, Reassign): + return self.event_reassign(event) + elif isinstance(event, Fail): + return self.event_fail(event) + elif isinstance(event, RescindMinimalReview): + return self.event_rescind_minimal_review(event) + else: + raise ValueError(f"Unknown event '{event}' for state '{type(self).__name__}'") + + def do(self, action: WorkflowAction): + if isinstance(action, ApplicationEdit): + self.do_edit(action) + else: + raise ValueError(f"Unknown action '{action}' for state '{type(self).__name__}'") + + def event_triaged(self, event:Triaged): + wfc = self.workflow_control + if wfc.reviewer != event.actor_id: + raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) + + if event.maned is not None: + groups = EditorGroup.groups_by_editor(event.maned) + vessel = None + for g in groups: + if len(g.associates) > 0: + continue + if g.name.lower() != event.maned.lower(): + continue + vessel = g + break + + if vessel is None: + raise ValueError(f"Maned '{event.maned}' is not the editor of a managing editor's special vessel group") + + wfc.editor_group = vessel.name + wfc.reviewer = event.maned + + return QuickFailCriteriaCheck.enter(self._wf_control, self._application) + + elif event.editor_group is not None: + eg = EditorGroup.pull(event.editor_group) + if eg is None: + raise ValueError(f"EditorGroup '{event.editor_group}' does not exist") + + wfc.editor_group = eg.name + del wfc.reviewer + + return QuickFailAwaitingAssignment.enter(self._wf_control, self._application) + + raise ValueError("Triaged event must have either a target maned or a target editor group") + + def event_unclaim(self, event: Unclaim): + wfc = self.workflow_control + if wfc.reviewer != event.actor_id: + raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) + + del wfc.reviewer + return AwaitingTriage.enter(self._wf_control, self._application) + + def event_reassign(self, event: Reassign): + wfc = self.workflow_control + if not event.actor.has_role(constants.ROLE_ADMIN): + raise AuthoriseException(reason=AuthoriseException.WRONG_ROLE) + + wfc.reviewer = event.reviewer_id + return self.enter(self._wf_control, self._application) + + def event_fail(self, event: Fail): + # TODO: we don't know where this goes yet + raise NotImplementedError() + + def event_rescind_minimal_review(self, event: RescindMinimalReview): + wfc = self.workflow_control + if wfc.reviewer != event.actor_id: + raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) + + wfc.triage.has_minimal_review = False + return TriageAssessmentInProgress.enter(self._wf_control, self._application) + + def do_edit(self, action: ApplicationEdit): + wfc = self.workflow_control + if wfc.reviewer != action.actor_id: + raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) + + # FIXME: where does has_minimal_review get calculated? + if self.workflow_control.triage.has_minimal_review: + return self + else: + return self.event_rescind_minimal_review(RescindMinimalReview(action.actor_id)) + + +class QuickFailAwaitingAssignment(State): pass +class QuickFailCriteriaCheck(State): + pass class WorkflowService: STATES = [ + # Triage States AwaitingTriage, TriageAssessmentInProgress, - TriageAssessmentMinimalReview + TriageAssessmentMinimalReview, + + # Quick Fail States + QuickFailAwaitingAssignment, + QuickFailCriteriaCheck ] def iterate_state(self, state:Type[State]): diff --git a/portality/constants.py b/portality/constants.py index a0cd15f521..5c8ada43fb 100644 --- a/portality/constants.py +++ b/portality/constants.py @@ -107,6 +107,7 @@ ROLE_API = "api" # TODO add ultra_bulk_delete and refactor view to use constants ROLE_ADMIN_REPORT_WITH_NOTES = "ultra_admin_reports_with_notes" # MUST start with ultra_ so that superusers don't gain +ROLE_TRIAGE = "triage" CRON_NEVER = {"month": "2", "day": "31", "day_of_week": "*", "hour": "*", "minute": "*"} diff --git a/portality/models/workflow.py b/portality/models/workflow.py index d73744ee5e..ec54f85d3d 100644 --- a/portality/models/workflow.py +++ b/portality/models/workflow.py @@ -9,7 +9,7 @@ TRIAGE_STRUCT = { "fields": { - "has_minimal_review": {"coerce": "bool", "default": False}, + "has_minimal_review": {"coerce": "bool"}, } } @@ -77,6 +77,22 @@ def __init__(self, **kwargs): kwargs = kwargs["_source"] super(WorkflowControl, self).__init__(raw=kwargs) + #################################### + ## Class methods for locating WorkflowControl objects + + @classmethod + def find_by_application(cls, app_id): + q = WorkflowControlQuery(application_id=app_id) + objs = cls.object_query(q.query()) + if len(objs) > 1: + raise ValueError("Multiple WorkflowControl objects found for application id: {}".format(app_id)) + elif len(objs) == 0: + return None + else: + return objs[0] + + #################################### + def mappings(self): return es_data_mapping.create_mapping(self.__seamless_struct__.raw, MAPPING_OPTS) @@ -198,3 +214,17 @@ def has_minimal_review(self, val): ########################################## +class WorkflowControlQuery: + def __init__(self, application_id=None): + self._application_id = application_id + + def query(self): + return { + "query": { + "bool": { + "must": [ + {"term": {"application.id.exact": self._application_id}} + ] + } + } + } \ No newline at end of file diff --git a/portality/templates-v2/management/admin/_workflow/includes/triaged.html b/portality/templates-v2/management/admin/_workflow/includes/triaged.html new file mode 100644 index 0000000000..ab6b5f0958 --- /dev/null +++ b/portality/templates-v2/management/admin/_workflow/includes/triaged.html @@ -0,0 +1,12 @@ +
+ {% if onward %} + + {% endif %} + + +
\ No newline at end of file diff --git a/portality/templates-v2/management/admin/workflow.html b/portality/templates-v2/management/admin/workflow.html index 9c5c43dd11..4e4d0c6199 100644 --- a/portality/templates-v2/management/admin/workflow.html +++ b/portality/templates-v2/management/admin/workflow.html @@ -23,6 +23,13 @@

Triage In Progress

{% include "management/admin/_workflow/includes/workflow_entry.html" %} {% endfor %} + +

Triage that has met Minimal Review needs

+
+ {% for state in triage_minimal_review %} + {% include "management/admin/_workflow/includes/workflow_entry.html" %} + {% endfor %} +
{% endblock %} {% block admin_js %} diff --git a/portality/ui/templates.py b/portality/ui/templates.py index 044419f97d..9314fb8d61 100644 --- a/portality/ui/templates.py +++ b/portality/ui/templates.py @@ -78,6 +78,7 @@ WORKFLOW_MINIMAL_REVIEW_WIDGET = "management/admin/_workflow/includes/minimal_review.html" WORKFLOW_FAIL_WIDGET = "management/admin/_workflow/includes/fail.html" WORKFLOW_EDIT_WIDGET = "management/admin/_workflow/includes/edit.html" +WORKFLOW_TRIAGED_WIDGET = "management/admin/_workflow/includes/triaged.html" # Reusable application form components AF_ENTRY_GOUP = "_application-form/includes/_entry_group.html" diff --git a/portality/ui/workflow.py b/portality/ui/workflow.py index 88f9dfcee7..a284ba35a5 100644 --- a/portality/ui/workflow.py +++ b/portality/ui/workflow.py @@ -1,4 +1,5 @@ -from portality.bll.services.workflow import Claim, Assign, Reassign, Unclaim, Fail, MinimalReview, ApplicationEdit +from portality.bll.services.workflow import Claim, Assign, Reassign, Unclaim, Fail, MinimalReview, ApplicationEdit, \ + RescindMinimalReview, Triaged from portality.ui import templates from portality.util import url_for @@ -56,13 +57,19 @@ class FailUI(EventUI): template = templates.WORKFLOW_FAIL_WIDGET route_id = "workflow.fail" +class TriagedUI(EventUI): + template = templates.WORKFLOW_TRIAGED_WIDGET + route_id = "workflow.triaged" + EVENT_MAP = { Claim: ClaimUI, Assign: AssignUI, Reassign: ReassignUI, Unclaim: UnclaimUI, Fail: FailUI, - MinimalReview: None + MinimalReview: None, + RescindMinimalReview: None, + Triaged: TriagedUI } ##################################### diff --git a/portality/view/workflow.py b/portality/view/workflow.py index 8d74e3fe12..6637fa1c6b 100644 --- a/portality/view/workflow.py +++ b/portality/view/workflow.py @@ -5,7 +5,9 @@ from portality import models from portality.bll import DOAJ -from portality.bll.services.workflow import AwaitingTriage, TriageAssessmentInProgress, Claim, Unclaim +from portality.bll.exceptions import AuthoriseException +from portality.bll.services.workflow import AwaitingTriage, TriageAssessmentInProgress, Claim, Unclaim, \ + TriageAssessmentMinimalReview, MinimalReview, RescindMinimalReview from portality.decorators import ssl_required from portality.ui import templates from portality.ui.workflow import StateUI @@ -19,9 +21,11 @@ def index(): svc = DOAJ.workflowService() awaiting_triage = [StateUI(x) for x in svc.first_n_in_state(AwaitingTriage, 10)] triage_in_progress = [StateUI(x) for x in svc.first_n_in_state(TriageAssessmentInProgress, 10)] + triage_minimal_review = [StateUI(x) for x in svc.first_n_in_state(TriageAssessmentMinimalReview, 10)] return render_template(templates.ADMIN_WORKFLOW_OVERVIEW, awaiting_triage=awaiting_triage, triage_in_progress=triage_in_progress, + triage_minimal_review=triage_minimal_review, admin_page=True) @blueprint.route('/claim', methods=['POST']) @@ -34,7 +38,9 @@ def claim(): svc = DOAJ.workflowService() try: - svc.apply_event(wfc_id, Claim(current_user.id)) + new_state = svc.apply_event(wfc_id, Claim(current_user)) + except AuthoriseException: + abort(401) except ValueError: abort(404) @@ -57,7 +63,9 @@ def unclaim(): svc = DOAJ.workflowService() try: - svc.apply_event(wfc_id, Unclaim()) + new_state = svc.apply_event(wfc_id, Unclaim(current_user)) + except AuthoriseException: + abort(401) except ValueError: abort(404) @@ -94,8 +102,39 @@ def fail(): def minimal_review(): pass +@blueprint.route("/triaged", methods=["POST"]) +@login_required +@ssl_required +def triaged(): + pass + @blueprint.route("/edit/", methods=["GET"]) @login_required @ssl_required def edit(application_id): - pass \ No newline at end of file + # FIXME: this is just a demonstrator + try: + wfc = models.WorkflowControl.find_by_application(application_id) + except ValueError: + abort(500) + + if wfc is None: + abort(404) + + event = None + if wfc.triage.has_minimal_review: + event = RescindMinimalReview(current_user) + else: + event = MinimalReview(current_user) + + svc = DOAJ.workflowService() + try: + new_state = svc.apply_event(wfc.id, event) + except AuthoriseException: + abort(401) + except ValueError: + abort(404) + + url = url_for("workflow.index") + return redirect(url) + From 2854a9af5e98ca527a46c3eb7bbde109652f8a0e Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Thu, 9 Apr 2026 18:17:21 +0100 Subject: [PATCH 04/13] add latest changes to workflow definition from planning meeting --- doajtest/testdrive/workflow.py | 2 +- portality/bll/services/workflow.py | 162 +++++++++++++++--- portality/models/workflow.py | 58 ++++++- .../admin/_workflow/includes/fail.html | 2 + .../admin/_workflow/includes/unassign.html | 7 + .../_workflow/includes/workflow_entry.html | 75 +++++--- .../management/admin/workflow.html | 7 + portality/ui/templates.py | 1 + portality/ui/workflow.py | 9 +- portality/view/workflow.py | 53 +++++- 10 files changed, 326 insertions(+), 50 deletions(-) create mode 100644 portality/templates-v2/management/admin/_workflow/includes/unassign.html diff --git a/doajtest/testdrive/workflow.py b/doajtest/testdrive/workflow.py index 43b80a4b5c..cfd2d41581 100644 --- a/doajtest/testdrive/workflow.py +++ b/doajtest/testdrive/workflow.py @@ -23,7 +23,7 @@ def setup(self) -> dict: app.set_id(app.makeid()) app.bibjson().title = "Workflow Test " + str(i) app.set_created(dates.before_now(86400*i)) - state = svc.initialise_workflow(app) + state = svc.initialise_workflow(acc, app) state.workflow_control.set_created(app.created_date) state.saveall() states.append(state) diff --git a/portality/bll/services/workflow.py b/portality/bll/services/workflow.py index 3661ca4813..dd6ddc0a43 100644 --- a/portality/bll/services/workflow.py +++ b/portality/bll/services/workflow.py @@ -142,9 +142,13 @@ def query(cls): return WorkflowControlStateQuery(cls.module, cls.stage, cls.editor_group, cls.reviewer) @classmethod - def enter(cls, wf_control:models.WorkflowControl, application:models.Application=None): + def enter(cls, actor:Union[str, Account], + wf_control:models.WorkflowControl, + application:models.Application=None, + from_state: "State"=None): instance = cls(wf_control, application) instance.apply() + instance.audit(actor, from_state) return instance @classmethod @@ -214,6 +218,21 @@ def exit(self, event:WorkflowEvent): def do(self, action:WorkflowAction): return self + def get_audit_partial(self): + return { + "module": self.module, + "stage": self.stage, + "editor_group": self.workflow_control.editor_group, + "reviewer": self.workflow_control.reviewer + } + + def audit(self, actor, from_state:"State"=None, date=None): + origin_data = None + if from_state is not None: + origin_data = from_state.get_audit_partial() + target_data = self.get_audit_partial() + self.workflow_control.add_audit(actor, target_data, origin_data, date) + def saveall(self, *args, **kwargs): self.workflow_control.save(*args, **kwargs) if self._application: @@ -245,7 +264,21 @@ class Assign(ReviewerAssignment): pass class Reassign(ReviewerAssignment): pass -class Fail(WorkflowEvent): pass +class Unassign(WorkflowEvent): pass + +class Fail(WorkflowEvent): + def __init__(self, actor:Union[str, Account], note:str=None, embargo_end:str=None): + super().__init__(actor) + self._note = note + self._embargo_end = embargo_end + + @property + def note(self): + return self._note + + @property + def embargo_end(self): + return self._embargo_end class MinimalReview(WorkflowEvent): pass @@ -320,9 +353,9 @@ def event_claim(self, event:Claim): wfc.reviewer = event.actor_id if wfc.triage.has_minimal_review: - return TriageAssessmentMinimalReview.enter(self._wf_control, self._application) + return TriageAssessmentMinimalReview.enter(event.actor, self._wf_control, self._application, self) else: - return TriageAssessmentInProgress.enter(self._wf_control, self._application) + return TriageAssessmentInProgress.enter(event.actor, self._wf_control, self._application, self) def event_assign(self, event:Assign): wfc = self.workflow_control @@ -334,9 +367,9 @@ def event_assign(self, event:Assign): wfc.reviewer = event.reviewer_id if wfc.triage.has_minimal_review: - return TriageAssessmentMinimalReview.enter(self._wf_control, self._application) + return TriageAssessmentMinimalReview.enter(event.actor, self._wf_control, self._application, self) else: - return TriageAssessmentInProgress.enter(self._wf_control, self._application) + return TriageAssessmentInProgress.enter(event.actor, self._wf_control, self._application, self) class TriageAssessmentInProgress(State): module = MODULE_TRIAGE @@ -344,7 +377,7 @@ class TriageAssessmentInProgress(State): editor_group = MODULE_TRIAGE_EG reviewer = ASSIGNED - events = [Unclaim, Reassign, Fail, MinimalReview] + events = [Unclaim, Reassign, Unassign, Fail, MinimalReview] actions = [ApplicationEdit] def apply(self): @@ -379,6 +412,8 @@ def exit(self, event:WorkflowEvent): return self.event_unclaim(event) elif isinstance(event, Reassign): return self.event_reassign(event) + elif isinstance(event, Unassign): + return self.event_unassign(event) elif isinstance(event, Fail): return self.event_fail(event) elif isinstance(event, MinimalReview): @@ -398,7 +433,7 @@ def event_unclaim(self, event:Unclaim): raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) del wfc.reviewer - return AwaitingTriage.enter(self._wf_control, self._application) + return AwaitingTriage.enter(event.actor, self._wf_control, self._application, self) def event_reassign(self, event:Reassign): wfc = self.workflow_control @@ -406,11 +441,30 @@ def event_reassign(self, event:Reassign): raise AuthoriseException(reason=AuthoriseException.WRONG_ROLE) wfc.reviewer = event.reviewer_id - return self.enter(self._wf_control, self._application) + return self.enter(event.actor, self._wf_control, self._application, self) + + def event_unassign(self, event:Unassign): + wfc = self.workflow_control + if not event.actor.has_role(constants.ROLE_ADMIN): + raise AuthoriseException(reason=AuthoriseException.WRONG_ROLE) + + del wfc.reviewer + return AwaitingTriage.enter(event.actor, self._wf_control, self._application, self) def event_fail(self, event:Fail): - # TODO: we don't know where this goes yet - raise NotImplementedError() + wfc = self.workflow_control + if wfc.reviewer != event.actor_id: + raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) + + appSvc = DOAJ.applicationService() + appSvc.reject_application(self.application, event.actor, note=event.note) + + del wfc.editor_group + del wfc.reviewer + + # TODO: not clear what to do with application embargoes, perhaps these are a new type? + + return Rejected.enter(event.actor, self._wf_control, self._application, self) def event_minimal_review(self, event:MinimalReview): wfc = self.workflow_control @@ -418,7 +472,7 @@ def event_minimal_review(self, event:MinimalReview): raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) wfc.triage.has_minimal_review = True - return TriageAssessmentMinimalReview.enter(self._wf_control, self._application) + return TriageAssessmentMinimalReview.enter(event.actor, self._wf_control, self._application, self) def do_edit(self, action:ApplicationEdit): wfc = self.workflow_control @@ -437,7 +491,7 @@ class TriageAssessmentMinimalReview(State): editor_group = MODULE_TRIAGE_EG reviewer = ASSIGNED - events = [Triaged, Unclaim, Reassign, Fail, RescindMinimalReview] + events = [Triaged, Unclaim, Reassign, Unassign, Fail, RescindMinimalReview] actions = [ApplicationEdit] def apply(self): @@ -474,6 +528,8 @@ def exit(self, event: WorkflowEvent): return self.event_unclaim(event) elif isinstance(event, Reassign): return self.event_reassign(event) + elif isinstance(event, Unassign): + return self.event_unassign(event) elif isinstance(event, Fail): return self.event_fail(event) elif isinstance(event, RescindMinimalReview): @@ -509,7 +565,7 @@ def event_triaged(self, event:Triaged): wfc.editor_group = vessel.name wfc.reviewer = event.maned - return QuickFailCriteriaCheck.enter(self._wf_control, self._application) + return QuickFailCriteriaCheck.enter(event.actor, self._wf_control, self._application, self) elif event.editor_group is not None: eg = EditorGroup.pull(event.editor_group) @@ -519,7 +575,7 @@ def event_triaged(self, event:Triaged): wfc.editor_group = eg.name del wfc.reviewer - return QuickFailAwaitingAssignment.enter(self._wf_control, self._application) + return QuickFailAwaitingAssignment.enter(event.actor, self._wf_control, self._application, self) raise ValueError("Triaged event must have either a target maned or a target editor group") @@ -529,7 +585,7 @@ def event_unclaim(self, event: Unclaim): raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) del wfc.reviewer - return AwaitingTriage.enter(self._wf_control, self._application) + return AwaitingTriage.enter(event.actor, self._wf_control, self._application, self) def event_reassign(self, event: Reassign): wfc = self.workflow_control @@ -537,11 +593,30 @@ def event_reassign(self, event: Reassign): raise AuthoriseException(reason=AuthoriseException.WRONG_ROLE) wfc.reviewer = event.reviewer_id - return self.enter(self._wf_control, self._application) + return self.enter(event.actor, self._wf_control, self._application, self) + + def event_unassign(self, event:Unassign): + wfc = self.workflow_control + if not event.actor.has_role(constants.ROLE_ADMIN): + raise AuthoriseException(reason=AuthoriseException.WRONG_ROLE) + + del wfc.reviewer + return AwaitingTriage.enter(event.actor, self._wf_control, self._application, self) def event_fail(self, event: Fail): - # TODO: we don't know where this goes yet - raise NotImplementedError() + wfc = self.workflow_control + if wfc.reviewer != event.actor_id: + raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) + + appSvc = DOAJ.applicationService() + appSvc.reject_application(self.application, event.actor, event.note) + + del wfc.editor_group + del wfc.reviewer + + # TODO: not clear what to do with application embargoes, perhaps these are a new type? + + return Rejected.enter(event.actor, self._wf_control, self._application, self) def event_rescind_minimal_review(self, event: RescindMinimalReview): wfc = self.workflow_control @@ -549,7 +624,7 @@ def event_rescind_minimal_review(self, event: RescindMinimalReview): raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) wfc.triage.has_minimal_review = False - return TriageAssessmentInProgress.enter(self._wf_control, self._application) + return TriageAssessmentInProgress.enter(event.actor, self._wf_control, self._application, self) def do_edit(self, action: ApplicationEdit): wfc = self.workflow_control @@ -569,6 +644,49 @@ class QuickFailAwaitingAssignment(State): class QuickFailCriteriaCheck(State): pass +MODULE_REJECTED = "rejected" +MODULE_REJECTED_STAGE_REJECTED = "rejected" + +class Rejected(State): + module = MODULE_REJECTED + stage = MODULE_REJECTED_STAGE_REJECTED + editor_group = ANY + reviewer = ANY + + def apply(self): + # Ensure the workflow control object is in the right state + wf_control = self.workflow_control + if wf_control.module != self.module: + wf_control.module = self.module + + if wf_control.stage != self.stage: + wf_control.stage = self.stage + + # back-compatibility for the original workflow + application = self.application + + if application.application_status != constants.APPLICATION_STATUS_REJECTED: + appSvc = DOAJ.applicationService() + acc = None + if wf_control.reviewer is not None: + acc = models.Account.pull(wf_control.reviewer) + if acc is None: + # FIXME: what's the way to do this? + acc = models.Account.pull("system") + appSvc.reject_application(application, acc) + + if wf_control.editor_group is not None: + if application.editor_group != wf_control.editor_group: + application.set_editor_group(wf_control.editor_group) + else: + application.remove_editor_group() + + if wf_control.reviewer is not None: + if application.editor != wf_control.reviewer: + application.set_editor(wf_control.reviewer) + else: + application.remove_editor() + class WorkflowService: STATES = [ @@ -620,10 +738,10 @@ def event(self, state_instance:State, event:WorkflowEvent) -> State: new_state = state_instance.exit(event) return new_state - def initialise_workflow(self, application): + def initialise_workflow(self, actor:Union[str, Account], application:models.Application) -> State: wfc = models.WorkflowControl() wfc.application_id = application.id wfc.original_application = application wfc.application_title = application.bibjson().title - initial_state = AwaitingTriage.enter(wfc, application) + initial_state = AwaitingTriage.enter(actor, wfc, application) return initial_state \ No newline at end of file diff --git a/portality/models/workflow.py b/portality/models/workflow.py index ec54f85d3d..71050de379 100644 --- a/portality/models/workflow.py +++ b/portality/models/workflow.py @@ -1,9 +1,12 @@ +from datetime import datetime +from typing import Union + from portality.dao import DomainObject -from portality.lib import es_data_mapping +from portality.lib import es_data_mapping, dates from portality.lib.coerce import COERCE_MAP from portality.lib.seamless import SeamlessMixin from portality.core import app -from portality.models import Application +from portality.models import Application, Account import json @@ -20,7 +23,10 @@ "last_updated": {"coerce": "utcdatetime"}, "es_type": {"coerce": "unicode"}, }, - "objects": ["application", "state", "modules", "audit"], + "lists": { + "audit": {"contains": "object"} + }, + "objects": ["application", "state", "modules"], "structs": { "application": { "fields": { @@ -35,6 +41,25 @@ "date": {"coerce": "utcdatetime"}, "from_state": {"coerce": "unicode"}, "to_state": {"coerce": "unicode"} + }, + "objects": ["from_state", "to_state"], + "structs": { + "from_state": { + "fields": { + "module": {"coerce": "unicode"}, + "stage": {"coerce": "unicode"}, + "editor_group": {"coerce": "unicode"}, + "reviewer": {"coerce": "unicode"} + } + }, + "to_state": { + "fields": { + "module": {"coerce": "unicode"}, + "stage": {"coerce": "unicode"}, + "editor_group": {"coerce": "unicode"}, + "reviewer": {"coerce": "unicode"} + } + } } }, "modules": { @@ -127,6 +152,10 @@ def editor_group(self): def editor_group(self, val): self.__seamless__.set_single("state.editor_group", val) + @editor_group.deleter + def editor_group(self): + self.__seamless__.delete("state.editor_group") + @property def reviewer(self): return self.__seamless__.get_single("state.reviewer") @@ -191,6 +220,29 @@ def triage(self): t = self.__seamless__.get_single("modules.triage") return Triage(t) + ################################## + ## Audit + + def add_audit(self, actor:Union[str, Account], to_state:dict, from_state:dict=None, date:Union[str, datetime]=None): + if date is None: + date = dates.now() + if isinstance(actor, Account): + actor = actor.id + + audit_entry = { + "user": actor, + "date": date, + "to_state": to_state + } + if from_state is not None: + audit_entry["from_state"] = from_state + + self.__seamless__.add_to_list_with_struct("audit", audit_entry) + + @property + def audit(self): + return self.__seamless__.get_list("audit") + class Triage(SeamlessMixin): __SEAMLESS_STRUCT__ = TRIAGE_STRUCT __SEAMLESS_COERCE__ = COERCE_MAP diff --git a/portality/templates-v2/management/admin/_workflow/includes/fail.html b/portality/templates-v2/management/admin/_workflow/includes/fail.html index 305d851ea4..d6ca41eaad 100644 --- a/portality/templates-v2/management/admin/_workflow/includes/fail.html +++ b/portality/templates-v2/management/admin/_workflow/includes/fail.html @@ -3,5 +3,7 @@ {% if onward %} {% endif %} + + \ No newline at end of file diff --git a/portality/templates-v2/management/admin/_workflow/includes/unassign.html b/portality/templates-v2/management/admin/_workflow/includes/unassign.html new file mode 100644 index 0000000000..f14750b205 --- /dev/null +++ b/portality/templates-v2/management/admin/_workflow/includes/unassign.html @@ -0,0 +1,7 @@ +
+ + {% if onward %} + + {% endif %} + +
\ No newline at end of file diff --git a/portality/templates-v2/management/admin/_workflow/includes/workflow_entry.html b/portality/templates-v2/management/admin/_workflow/includes/workflow_entry.html index e5919ea3f9..2cb02e32f3 100644 --- a/portality/templates-v2/management/admin/_workflow/includes/workflow_entry.html +++ b/portality/templates-v2/management/admin/_workflow/includes/workflow_entry.html @@ -1,25 +1,60 @@ -
+
{% set wfc = state.workflow_control %} -

{{ wfc.application_title }}


+

+ {{ wfc.application_title }} +

- Module: {{ wfc.module }} - Stage: {{ wfc.stage }} - {% if wfc.editorial_group %} - Group: {{ wfc.editorial_group }} - {% endif %} - {% if wfc.reviewer %} - Reviewer: {{ wfc.reviewer }} - {% endif %} +
+ Module: {{ wfc.module }} + Stage: {{ wfc.stage }} + {% if wfc.editorial_group %} + Group: {{ wfc.editorial_group }} + {% endif %} + {% if wfc.reviewer %} + Reviewer: {{ wfc.reviewer }} + {% endif %} - {% for event in state.events %} -
- {% include event.template %} -
- {% endfor %} + {% for event in state.events %} +
+ {% include event.template %} +
+ {% endfor %} - {% for action in state.actions %} -
- {% include action.template %} -
- {% endfor %} + {% for action in state.actions %} +
+ {% include action.template %} +
+ {% endfor %} +
+ +
+ + + + + + + + + + + {% for entry in wfc.audit %} + + + + + + + {% endfor %} + +
Event DateActorFrom StateTo State
{{ entry.date }}{{ entry.user }} + {% if entry.from_state %} + {{ entry.from_state.module }}: {{ entry.from_state.stage }} + {% endif %} + + {% if entry.to_state %} + {{ entry.to_state.module }}: {{ entry.to_state.stage }} + {% endif %} +
+
\ No newline at end of file diff --git a/portality/templates-v2/management/admin/workflow.html b/portality/templates-v2/management/admin/workflow.html index 4e4d0c6199..363f46b1fc 100644 --- a/portality/templates-v2/management/admin/workflow.html +++ b/portality/templates-v2/management/admin/workflow.html @@ -30,6 +30,13 @@

Triage that has met Minimal Review needs

{% include "management/admin/_workflow/includes/workflow_entry.html" %} {% endfor %} + +

Rejected Applications

+
+ {% for state in rejected %} + {% include "management/admin/_workflow/includes/workflow_entry.html" %} + {% endfor %} +
{% endblock %} {% block admin_js %} diff --git a/portality/ui/templates.py b/portality/ui/templates.py index c82b04a61c..778d7ae85e 100644 --- a/portality/ui/templates.py +++ b/portality/ui/templates.py @@ -76,6 +76,7 @@ # Workflow components WORKFLOW_CLAIM_WIDGET = "management/admin/_workflow/includes/claim.html" WORKFLOW_ASSIGN_WIDGET = "management/admin/_workflow/includes/assign.html" +WORKFLOW_UNASSIGN_WIDGET = "management/admin/_workflow/includes/unassign.html" WORKFLOW_UNCLAIM_WIDGET = "management/admin/_workflow/includes/unclaim.html" WORKFLOW_MINIMAL_REVIEW_WIDGET = "management/admin/_workflow/includes/minimal_review.html" WORKFLOW_FAIL_WIDGET = "management/admin/_workflow/includes/fail.html" diff --git a/portality/ui/workflow.py b/portality/ui/workflow.py index a284ba35a5..ed31df7d02 100644 --- a/portality/ui/workflow.py +++ b/portality/ui/workflow.py @@ -1,5 +1,5 @@ from portality.bll.services.workflow import Claim, Assign, Reassign, Unclaim, Fail, MinimalReview, ApplicationEdit, \ - RescindMinimalReview, Triaged + RescindMinimalReview, Triaged, Unassign from portality.ui import templates from portality.util import url_for @@ -45,6 +45,10 @@ class AssignUI(EventUI): template = templates.WORKFLOW_ASSIGN_WIDGET route_id = "workflow.assign" +class UnassignUI(EventUI): + template = templates.WORKFLOW_UNASSIGN_WIDGET + route_id = "workflow.unassign" + class UnclaimUI(EventUI): template = templates.WORKFLOW_UNCLAIM_WIDGET route_id = "workflow.unclaim" @@ -69,7 +73,8 @@ class TriagedUI(EventUI): Fail: FailUI, MinimalReview: None, RescindMinimalReview: None, - Triaged: TriagedUI + Triaged: TriagedUI, + Unassign: UnassignUI } ##################################### diff --git a/portality/view/workflow.py b/portality/view/workflow.py index 6637fa1c6b..c8232e9c41 100644 --- a/portality/view/workflow.py +++ b/portality/view/workflow.py @@ -7,7 +7,7 @@ from portality.bll import DOAJ from portality.bll.exceptions import AuthoriseException from portality.bll.services.workflow import AwaitingTriage, TriageAssessmentInProgress, Claim, Unclaim, \ - TriageAssessmentMinimalReview, MinimalReview, RescindMinimalReview + TriageAssessmentMinimalReview, MinimalReview, RescindMinimalReview, Unassign, Rejected, Fail from portality.decorators import ssl_required from portality.ui import templates from portality.ui.workflow import StateUI @@ -22,10 +22,12 @@ def index(): awaiting_triage = [StateUI(x) for x in svc.first_n_in_state(AwaitingTriage, 10)] triage_in_progress = [StateUI(x) for x in svc.first_n_in_state(TriageAssessmentInProgress, 10)] triage_minimal_review = [StateUI(x) for x in svc.first_n_in_state(TriageAssessmentMinimalReview, 10)] + rejected = [StateUI(x) for x in svc.first_n_in_state(Rejected, 10)] return render_template(templates.ADMIN_WORKFLOW_OVERVIEW, awaiting_triage=awaiting_triage, triage_in_progress=triage_in_progress, triage_minimal_review=triage_minimal_review, + rejected=rejected, admin_page=True) @blueprint.route('/claim', methods=['POST']) @@ -90,11 +92,58 @@ def assign(): def reassign(): pass +@blueprint.route("/unassign", methods=["POST"]) +@login_required +@ssl_required +def unassign(): + wfc_id = request.form.get("workflow_control") + if wfc_id is None: + abort(400) + + svc = DOAJ.workflowService() + try: + new_state = svc.apply_event(wfc_id, Unassign(current_user)) + except AuthoriseException: + abort(401) + except ValueError: + abort(404) + + onward = request.form.get("onward") + if onward is not None: + url = url_for(onward) + return redirect(url) + + resp = make_response(json.dumps({"status": "success"})) + resp.mimetype = "application/json" + return resp + @blueprint.route("/fail", methods=["POST"]) @login_required @ssl_required def fail(): - pass + wfc_id = request.form.get("workflow_control") + if wfc_id is None: + abort(400) + + note = request.form.get("note") + embargo = request.form.get("embargo_end") + + svc = DOAJ.workflowService() + try: + new_state = svc.apply_event(wfc_id, Fail(current_user, note, embargo)) + except AuthoriseException: + abort(401) + except ValueError: + abort(404) + + onward = request.form.get("onward") + if onward is not None: + url = url_for(onward) + return redirect(url) + + resp = make_response(json.dumps({"status": "success"})) + resp.mimetype = "application/json" + return resp @blueprint.route("/minimal_review", methods=["POST"]) @login_required From a477016f47ed9bda358c85f61f6a39c43e0eece7 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Thu, 9 Apr 2026 20:14:11 +0100 Subject: [PATCH 05/13] refactor workflow service for improved structure and scaleability of code --- portality/bll/doaj.py | 5 +- portality/bll/services/workflow/__init__.py | 0 portality/bll/services/workflow/core.py | 201 +++++++++ portality/bll/services/workflow/quick_fail.py | 9 + portality/bll/services/workflow/rejected.py | 53 +++ portality/bll/services/workflow/service.py | 70 +++ .../{workflow.py => workflow/triage.py} | 409 +----------------- portality/models/__init__.py | 2 +- portality/models/workflow.py | 79 +++- portality/ui/workflow.py | 8 +- portality/view/workflow.py | 6 +- 11 files changed, 448 insertions(+), 394 deletions(-) create mode 100644 portality/bll/services/workflow/__init__.py create mode 100644 portality/bll/services/workflow/core.py create mode 100644 portality/bll/services/workflow/quick_fail.py create mode 100644 portality/bll/services/workflow/rejected.py create mode 100644 portality/bll/services/workflow/service.py rename portality/bll/services/{workflow.py => workflow/triage.py} (52%) diff --git a/portality/bll/doaj.py b/portality/bll/doaj.py index e2ec235af0..f68b78fbdc 100644 --- a/portality/bll/doaj.py +++ b/portality/bll/doaj.py @@ -1,4 +1,5 @@ # ~~DOAJ:Service~~ + class DOAJ(object): """ Primary entry point to the services which back up the DOAJ Business Logic Layer. @@ -182,5 +183,5 @@ def adminAlertsService(cls): @classmethod def workflowService(cls): - from portality.bll.services import workflow - return workflow.WorkflowService() \ No newline at end of file + from portality.bll.services.workflow import service + return service.WorkflowService() \ No newline at end of file diff --git a/portality/bll/services/workflow/__init__.py b/portality/bll/services/workflow/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/portality/bll/services/workflow/core.py b/portality/bll/services/workflow/core.py new file mode 100644 index 0000000000..5a974429b6 --- /dev/null +++ b/portality/bll/services/workflow/core.py @@ -0,0 +1,201 @@ +from typing import Union + +from portality.models import Account, WorkflowControl, Application, WorkflowControlStateQuery + +##################################### +## Base Workflow Event and all shared events + +class WorkflowEvent: + def __init__(self, actor:Union[str, Account]): + self._actor = actor + + @property + def actor(self) -> Account: + if isinstance(self._actor, str): + self._actor = Account.pull(self._actor) + return self._actor + + @property + def actor_id(self) -> str: + if isinstance(self._actor, str): + return self._actor + return self._actor.id + +class ReviewerAssignment(WorkflowEvent): + def __init__(self, actor:Union[str, Account], reviewer:Union[str, Account]): + super().__init__(actor) + self._reviewer = reviewer + + @property + def reviewer(self) -> Account: + if isinstance(self._reviewer, str): + self._reviewer = Account.pull(self._reviewer) + return self._reviewer + + @property + def reviewer_id(self) -> str: + if isinstance(self._reviewer, str): + return self._reviewer + return self._reviewer.id + +class Assign(ReviewerAssignment): pass + +class Reassign(ReviewerAssignment): pass + +class Claim(WorkflowEvent): pass + +class Unclaim(WorkflowEvent): pass + +class Unassign(WorkflowEvent): pass + +class Fail(WorkflowEvent): + def __init__(self, actor:Union[str, Account], note:str=None, embargo_end:str=None): + super().__init__(actor) + self._note = note + self._embargo_end = embargo_end + + @property + def note(self): + return self._note + + @property + def embargo_end(self): + return self._embargo_end + +##################################### +## Base Workflow Action and all shared actions + +class WorkflowAction: + def __init__(self, actor: Union[str, Account]): + self._actor = actor + + @property + def actor(self) -> Account: + if isinstance(self._actor, str): + self._actor = Account.pull(self._actor) + return self._actor + + @property + def actor_id(self) -> str: + if isinstance(self._actor, str): + return self._actor + return self._actor.id + +class ApplicationEdit(WorkflowAction): pass + +###################################### +## Base State object + +class State: + module = None + stage = None + editor_group = None + reviewer = None + + events = [] + actions = [] + + def __init__(self, wf_control:WorkflowControl, application:Application=None): + self._wf_control = wf_control + self._application = application + + @classmethod + def query(cls): + return WorkflowControlStateQuery(cls.module, cls.stage, cls.editor_group, cls.reviewer) + + @classmethod + def enter(cls, actor:Union[str, Account], + wf_control:WorkflowControl, + application:Application=None, + from_state: "State"=None): + instance = cls(wf_control, application) + instance.apply() + instance.audit(actor, from_state) + return instance + + @classmethod + def matches(cls, wf_control:WorkflowControl): + # Check the module. + # If the wfc has no module, and the state definition requires a specific module, then it doesn't match. + # If the wfc has a module, and the state definition requires a specifc module, and it isn't this one, then it doesn't match + if wf_control.module is None: + if not (cls.module == WorkflowControl.ANY): + return False + else: + if not (cls.module == WorkflowControl.ANY or cls.module == wf_control.module): + return False + + # if we get to here, module matches + + # check the stage + # If the wfc has no stage, and the state definition requires a specific stage, then it doesn't match. + # If the wfc has a stage, and the state definition requires a specifc stage, and it isn't this one, then it doesn't match + if wf_control.stage is None: + if not (cls.stage == WorkflowControl.ANY): + return False + else: + if not (cls.stage == WorkflowControl.ANY or cls.stage == wf_control.stage): + return False + + # by here, module and stage match + + # check the editor group + # if the wfc has no eg, then if the allowed editor group is not WorkflowControl.ANY or WorkflowControl.UNASSIGNED), no match + # if the wfc has an eg, then if the allowed editor group is not WorkflowControl.ANY or WorkflowControl.ASSIGNED or the same as the wfc's eg, no match + if wf_control.editor_group is None: + if not (cls.editor_group == WorkflowControl.ANY or cls.editor_group == WorkflowControl.UNASSIGNED): + return False + else: + if not (cls.editor_group == WorkflowControl.ANY or cls.editor_group == WorkflowControl.ASSIGNED or cls.editor_group == wf_control.editor_group): + return False + + # by here, module, stage and editor group match + + if wf_control.reviewer is None: + if not (cls.reviewer == WorkflowControl.ANY or cls.reviewer == WorkflowControl.UNASSIGNED): + return False + else: + if not (cls.reviewer == WorkflowControl.ANY or cls.reviewer == WorkflowControl.ASSIGNED or cls.reviewer == wf_control.editor_group): + return False + + return True + + @property + def workflow_control(self): + return self._wf_control + + @property + def application(self): + if self._application is None: + app_id = self.workflow_control.application_id + self._application = Application.pull(app_id) + return self._application + + def apply(self): + pass + + def exit(self, event:WorkflowEvent): + return self + + def do(self, action:WorkflowAction): + return self + + def get_audit_partial(self): + return { + "module": self.module, + "stage": self.stage, + "editor_group": self.workflow_control.editor_group, + "reviewer": self.workflow_control.reviewer + } + + def audit(self, actor, from_state:"State"=None, date=None): + origin_data = None + if from_state is not None: + origin_data = from_state.get_audit_partial() + target_data = self.get_audit_partial() + self.workflow_control.add_audit(actor, target_data, origin_data, date) + + def saveall(self, *args, **kwargs): + self.workflow_control.save(*args, **kwargs) + if self._application: + self._application.save(*args, **kwargs) \ No newline at end of file diff --git a/portality/bll/services/workflow/quick_fail.py b/portality/bll/services/workflow/quick_fail.py new file mode 100644 index 0000000000..e3d705ba85 --- /dev/null +++ b/portality/bll/services/workflow/quick_fail.py @@ -0,0 +1,9 @@ +from portality.bll.services.workflow.core import State + + +class QuickFailAwaitingAssignment(State): + pass + + +class QuickFailCriteriaCheck(State): + pass diff --git a/portality/bll/services/workflow/rejected.py b/portality/bll/services/workflow/rejected.py new file mode 100644 index 0000000000..cb28854784 --- /dev/null +++ b/portality/bll/services/workflow/rejected.py @@ -0,0 +1,53 @@ +from portality import constants +from portality.bll import DOAJ +from portality.bll.services.workflow.core import State +from portality.models import Account, WorkflowControl + +################################## +## Rejected state definition values + +MODULE_REJECTED = "rejected" +MODULE_REJECTED_STAGE_REJECTED = "rejected" + +################################## +## Rejected state definition(s) + +class Rejected(State): + module = MODULE_REJECTED + stage = MODULE_REJECTED_STAGE_REJECTED + editor_group = WorkflowControl.ANY + reviewer = WorkflowControl.ANY + + def apply(self): + # Ensure the workflow control object is in the right state + wf_control = self.workflow_control + if wf_control.module != self.module: + wf_control.module = self.module + + if wf_control.stage != self.stage: + wf_control.stage = self.stage + + # back-compatibility for the original workflow + application = self.application + + if application.application_status != constants.APPLICATION_STATUS_REJECTED: + appSvc = DOAJ.applicationService() + acc = None + if wf_control.reviewer is not None: + acc = Account.pull(wf_control.reviewer) + if acc is None: + # FIXME: what's the way to do this? + acc = Account.pull("system") + appSvc.reject_application(application, acc) + + if wf_control.editor_group is not None: + if application.editor_group != wf_control.editor_group: + application.set_editor_group(wf_control.editor_group) + else: + application.remove_editor_group() + + if wf_control.reviewer is not None: + if application.editor != wf_control.reviewer: + application.set_editor(wf_control.reviewer) + else: + application.remove_editor() \ No newline at end of file diff --git a/portality/bll/services/workflow/service.py b/portality/bll/services/workflow/service.py new file mode 100644 index 0000000000..13af1bebb2 --- /dev/null +++ b/portality/bll/services/workflow/service.py @@ -0,0 +1,70 @@ +from typing import Type, Union + +from portality.bll.services.workflow.quick_fail import QuickFailAwaitingAssignment, QuickFailCriteriaCheck +from portality.bll.services.workflow.rejected import Rejected +from portality.bll.services.workflow.triage import AwaitingTriage, TriageAssessmentInProgress, \ + TriageAssessmentMinimalReview +from portality.bll.services.workflow.core import State, WorkflowEvent +from portality.models import WorkflowControl, Account, Application + + +class WorkflowService: + STATES = [ + # Triage States + AwaitingTriage, + TriageAssessmentInProgress, + TriageAssessmentMinimalReview, + + # Quick Fail States + QuickFailAwaitingAssignment, + QuickFailCriteriaCheck, + + # Rejected State + Rejected + ] + + def iterate_state(self, state:Type[State]): + query = state.query() + for wfc in WorkflowControl.iterate_unstable(q=query.query()): + yield state(wfc) + + def first_n_in_state(self, state:Type[State], n:int) -> list[State]: + query = state.query() + query.size = n + return [state(x) for x in WorkflowControl.object_query(q=query.query())] + + def apply_event(self, wfc_id:str, event:WorkflowEvent, save=True): + state_instance = self.state_for_workflow_control(wfc_id) + if state_instance is None: + raise ValueError(f"No state found for workflow control with id '{wfc_id}'") + + new_state = self.event(state_instance, event) + if save: + new_state.saveall() + return new_state + + def state_for_workflow_control(self, wfc_id:str) -> Union[State, None]: + wfc = WorkflowControl.pull(wfc_id) + if wfc is None: + return None + + for state in self.STATES: + if state.matches(wfc): + return state(wfc) + + return None + + def event(self, state_instance:State, event:WorkflowEvent) -> State: + if event.__class__ not in state_instance.events: + raise ValueError(f"Invalid event '{event}' for state '{type(state_instance).__name__}'") + + new_state = state_instance.exit(event) + return new_state + + def initialise_workflow(self, actor:Union[str, Account], application:Application) -> State: + wfc = WorkflowControl() + wfc.application_id = application.id + wfc.original_application = application + wfc.application_title = application.bibjson().title + initial_state = AwaitingTriage.enter(actor, wfc, application) + return initial_state diff --git a/portality/bll/services/workflow.py b/portality/bll/services/workflow/triage.py similarity index 52% rename from portality/bll/services/workflow.py rename to portality/bll/services/workflow/triage.py index dd6ddc0a43..aeb8d647b2 100644 --- a/portality/bll/services/workflow.py +++ b/portality/bll/services/workflow/triage.py @@ -1,16 +1,16 @@ -from typing import Type, Union +from typing import Union -from portality import models, constants +from portality import constants from portality.bll import DOAJ from portality.bll.exceptions import AuthoriseException -from portality.models import Account, EditorGroup -# Generic state definition values -ANY = "*" -UNASSIGNED = "-" -ASSIGNED = "+" +from portality.bll.services.workflow.core import WorkflowEvent, State, Claim, Assign, Unclaim, Reassign, Unassign, \ + Fail, ApplicationEdit, WorkflowAction +from portality.models import Account, EditorGroup, WorkflowControl +######################################## # Triage module state definition values + MODULE_TRIAGE = "triage" MODULE_TRIAGE_STAGE_IN_PROGRESS = "in_progress" MODULE_TRIAGE_STAGE_MINIMAL_REVIEW = "minimal_review" @@ -20,265 +20,8 @@ ] MODULE_TRIAGE_EG = "Triage" -class WorkflowControlStateQuery: - def __init__(self, - module:str=None, - stage:str=None, - editor_group:str=None, - reviewer:str=None, - size:int=100): - self._module = module - self._stage = stage - self._editor_group = editor_group - self._reviewer = reviewer - self._size = size - - @property - def size(self): - return self._size - - @size.setter - def size(self, size:int): - self._size = size - - def query(self): - must = [] - must_not = [] - - if self._module is not None: - if self._module != ANY: - if self._module == UNASSIGNED: - must_not.append({"exists": {"field": "state.module"}}) - elif self._module == ASSIGNED: - must.append({"exists": {"field": "state.module"}}) - else: - must.append({"term": {"state.module.exact": self._module}}) - - if self._stage is not None: - if self._stage != ANY: - if self._stage == UNASSIGNED: - must_not.append({"exists": {"field": "state.stage"}}) - elif self._stage == ASSIGNED: - must.append({"exists": {"field": "state.stage"}}) - else: - must.append({"term": {"state.stage.exact": self._stage}}) - - if self._editor_group is not None: - if self._editor_group != ANY: - if self._editor_group == UNASSIGNED: - must_not.append({"exists": {"field": "state.editor_group"}}) - elif self._editor_group == ASSIGNED: - must.append({"exists": {"field": "state.editor_group"}}) - else: - must.append({"term": {"state.editor_group.exact": self._editor_group}}) - - if self._reviewer is not None: - if self._reviewer != ANY: - if self._reviewer == UNASSIGNED: - must_not.append({"exists": {"field": "state.reviewer"}}) - elif self._reviewer == ASSIGNED: - must.append({"exists": {"field": "state.reviewer"}}) - else: - must.append({"term": {"state.reviewer.exact": self._reviewer}}) - - bool = {} - if len(must) > 0: - bool["must"] = must - if len(must_not) > 0: - bool["must_not"] = must_not - q = {"query": {"bool": bool}} - - q["sort"] = {"created_date": {"order": "asc"}} - - return q - -class WorkflowEvent: - def __init__(self, actor:Union[str, Account]): - self._actor = actor - - @property - def actor(self) -> Account: - if isinstance(self._actor, str): - self._actor = Account.pull(self._actor) - return self._actor - - @property - def actor_id(self) -> str: - if isinstance(self._actor, str): - return self._actor - return self._actor.id - -class WorkflowAction: - def __init__(self, actor: Union[str, Account]): - self._actor = actor - - @property - def actor(self) -> Account: - if isinstance(self._actor, str): - self._actor = Account.pull(self._actor) - return self._actor - - @property - def actor_id(self) -> str: - if isinstance(self._actor, str): - return self._actor - return self._actor.id - -class State: - module = None - stage = None - editor_group = None - reviewer = None - - events = [] - actions = [] - - def __init__(self, wf_control:models.WorkflowControl, application:models.Application=None): - self._wf_control = wf_control - self._application = application - - @classmethod - def query(cls): - return WorkflowControlStateQuery(cls.module, cls.stage, cls.editor_group, cls.reviewer) - - @classmethod - def enter(cls, actor:Union[str, Account], - wf_control:models.WorkflowControl, - application:models.Application=None, - from_state: "State"=None): - instance = cls(wf_control, application) - instance.apply() - instance.audit(actor, from_state) - return instance - - @classmethod - def matches(cls, wf_control:models.WorkflowControl): - # Check the module. - # If the wfc has no module, and the state definition requires a specific module, then it doesn't match. - # If the wfc has a module, and the state definition requires a specifc module, and it isn't this one, then it doesn't match - if wf_control.module is None: - if not (cls.module == ANY): - return False - else: - if not (cls.module == ANY or cls.module == wf_control.module): - return False - - # if we get to here, module matches - - # check the stage - # If the wfc has no stage, and the state definition requires a specific stage, then it doesn't match. - # If the wfc has a stage, and the state definition requires a specifc stage, and it isn't this one, then it doesn't match - if wf_control.stage is None: - if not (cls.stage == ANY): - return False - else: - if not (cls.stage == ANY or cls.stage == wf_control.stage): - return False - - # by here, module and stage match - - # check the editor group - # if the wfc has no eg, then if the allowed editor group is not ANY or UNASSIGNED), no match - # if the wfc has an eg, then if the allowed editor group is not ANY or ASSIGNED or the same as the wfc's eg, no match - if wf_control.editor_group is None: - if not (cls.editor_group == ANY or cls.editor_group == UNASSIGNED): - return False - else: - if not (cls.editor_group == ANY or cls.editor_group == ASSIGNED or cls.editor_group == wf_control.editor_group): - return False - - # by here, module, stage and editor group match - - if wf_control.reviewer is None: - if not (cls.reviewer == ANY or cls.reviewer == UNASSIGNED): - return False - else: - if not (cls.reviewer == ANY or cls.reviewer == ASSIGNED or cls.reviewer == wf_control.editor_group): - return False - - return True - - @property - def workflow_control(self): - return self._wf_control - - @property - def application(self): - if self._application is None: - app_id = self.workflow_control.application_id - self._application = models.Application.pull(app_id) - return self._application - - def apply(self): - pass - - def exit(self, event:WorkflowEvent): - return self - - def do(self, action:WorkflowAction): - return self - - def get_audit_partial(self): - return { - "module": self.module, - "stage": self.stage, - "editor_group": self.workflow_control.editor_group, - "reviewer": self.workflow_control.reviewer - } - - def audit(self, actor, from_state:"State"=None, date=None): - origin_data = None - if from_state is not None: - origin_data = from_state.get_audit_partial() - target_data = self.get_audit_partial() - self.workflow_control.add_audit(actor, target_data, origin_data, date) - - def saveall(self, *args, **kwargs): - self.workflow_control.save(*args, **kwargs) - if self._application: - self._application.save(*args, **kwargs) - - -class ReviewerAssignment(WorkflowEvent): - def __init__(self, actor:Union[str, Account], reviewer:Union[str, Account]): - super().__init__(actor) - self._reviewer = reviewer - - @property - def reviewer(self) -> Account: - if isinstance(self._reviewer, str): - self._reviewer = Account.pull(self._reviewer) - return self._reviewer - - @property - def reviewer_id(self) -> str: - if isinstance(self._reviewer, str): - return self._reviewer - return self._reviewer.id - -class Claim(WorkflowEvent): pass - -class Unclaim(WorkflowEvent): pass - -class Assign(ReviewerAssignment): pass - -class Reassign(ReviewerAssignment): pass - -class Unassign(WorkflowEvent): pass - -class Fail(WorkflowEvent): - def __init__(self, actor:Union[str, Account], note:str=None, embargo_end:str=None): - super().__init__(actor) - self._note = note - self._embargo_end = embargo_end - - @property - def note(self): - return self._note - - @property - def embargo_end(self): - return self._embargo_end +###################################### +## Workflow events specific to Triage class MinimalReview(WorkflowEvent): pass @@ -300,14 +43,14 @@ def editor_group(self): def maned(self): return self._maned - -class ApplicationEdit(WorkflowAction): pass +###################################### +## Triage Workflow States class AwaitingTriage(State): module = MODULE_TRIAGE - stage = ANY + stage = WorkflowControl.ANY editor_group = MODULE_TRIAGE_EG - reviewer = UNASSIGNED + reviewer = WorkflowControl.UNASSIGNED events = [Claim, Assign] @@ -371,11 +114,12 @@ def event_assign(self, event:Assign): else: return TriageAssessmentInProgress.enter(event.actor, self._wf_control, self._application, self) + class TriageAssessmentInProgress(State): module = MODULE_TRIAGE stage = MODULE_TRIAGE_STAGE_IN_PROGRESS editor_group = MODULE_TRIAGE_EG - reviewer = ASSIGNED + reviewer = WorkflowControl.ASSIGNED events = [Unclaim, Reassign, Unassign, Fail, MinimalReview] actions = [ApplicationEdit] @@ -463,10 +207,10 @@ def event_fail(self, event:Fail): del wfc.reviewer # TODO: not clear what to do with application embargoes, perhaps these are a new type? - + from portality.bll.services.workflow.rejected import Rejected return Rejected.enter(event.actor, self._wf_control, self._application, self) - def event_minimal_review(self, event:MinimalReview): + def event_minimal_review(self, event: MinimalReview): wfc = self.workflow_control if wfc.reviewer != event.actor_id: raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) @@ -485,11 +229,12 @@ def do_edit(self, action:ApplicationEdit): else: return self + class TriageAssessmentMinimalReview(State): module = MODULE_TRIAGE stage = MODULE_TRIAGE_STAGE_MINIMAL_REVIEW editor_group = MODULE_TRIAGE_EG - reviewer = ASSIGNED + reviewer = WorkflowControl.ASSIGNED events = [Triaged, Unclaim, Reassign, Unassign, Fail, RescindMinimalReview] actions = [ApplicationEdit] @@ -543,7 +288,7 @@ def do(self, action: WorkflowAction): else: raise ValueError(f"Unknown action '{action}' for state '{type(self).__name__}'") - def event_triaged(self, event:Triaged): + def event_triaged(self, event: Triaged): wfc = self.workflow_control if wfc.reviewer != event.actor_id: raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) @@ -565,6 +310,7 @@ def event_triaged(self, event:Triaged): wfc.editor_group = vessel.name wfc.reviewer = event.maned + from portality.bll.services.workflow.quick_fail import QuickFailCriteriaCheck return QuickFailCriteriaCheck.enter(event.actor, self._wf_control, self._application, self) elif event.editor_group is not None: @@ -575,6 +321,7 @@ def event_triaged(self, event:Triaged): wfc.editor_group = eg.name del wfc.reviewer + from portality.bll.services.workflow.quick_fail import QuickFailAwaitingAssignment return QuickFailAwaitingAssignment.enter(event.actor, self._wf_control, self._application, self) raise ValueError("Triaged event must have either a target maned or a target editor group") @@ -609,13 +356,14 @@ def event_fail(self, event: Fail): raise AuthoriseException(reason=AuthoriseException.NOT_AUTHORISED) appSvc = DOAJ.applicationService() - appSvc.reject_application(self.application, event.actor, event.note) + appSvc.reject_application(self.application, event.actor, note=event.note) del wfc.editor_group del wfc.reviewer # TODO: not clear what to do with application embargoes, perhaps these are a new type? + from portality.bll.services.workflow.rejected import Rejected return Rejected.enter(event.actor, self._wf_control, self._application, self) def event_rescind_minimal_review(self, event: RescindMinimalReview): @@ -636,112 +384,3 @@ def do_edit(self, action: ApplicationEdit): return self else: return self.event_rescind_minimal_review(RescindMinimalReview(action.actor_id)) - - -class QuickFailAwaitingAssignment(State): - pass - -class QuickFailCriteriaCheck(State): - pass - -MODULE_REJECTED = "rejected" -MODULE_REJECTED_STAGE_REJECTED = "rejected" - -class Rejected(State): - module = MODULE_REJECTED - stage = MODULE_REJECTED_STAGE_REJECTED - editor_group = ANY - reviewer = ANY - - def apply(self): - # Ensure the workflow control object is in the right state - wf_control = self.workflow_control - if wf_control.module != self.module: - wf_control.module = self.module - - if wf_control.stage != self.stage: - wf_control.stage = self.stage - - # back-compatibility for the original workflow - application = self.application - - if application.application_status != constants.APPLICATION_STATUS_REJECTED: - appSvc = DOAJ.applicationService() - acc = None - if wf_control.reviewer is not None: - acc = models.Account.pull(wf_control.reviewer) - if acc is None: - # FIXME: what's the way to do this? - acc = models.Account.pull("system") - appSvc.reject_application(application, acc) - - if wf_control.editor_group is not None: - if application.editor_group != wf_control.editor_group: - application.set_editor_group(wf_control.editor_group) - else: - application.remove_editor_group() - - if wf_control.reviewer is not None: - if application.editor != wf_control.reviewer: - application.set_editor(wf_control.reviewer) - else: - application.remove_editor() - - -class WorkflowService: - STATES = [ - # Triage States - AwaitingTriage, - TriageAssessmentInProgress, - TriageAssessmentMinimalReview, - - # Quick Fail States - QuickFailAwaitingAssignment, - QuickFailCriteriaCheck - ] - - def iterate_state(self, state:Type[State]): - query = state.query() - for wfc in models.WorkflowControl.iterate_unstable(q=query.query()): - yield state(wfc) - - def first_n_in_state(self, state:Type[State], n:int) -> list[State]: - query = state.query() - query.size = n - return [state(x) for x in models.WorkflowControl.object_query(q=query.query())] - - def apply_event(self, wfc_id:str, event:WorkflowEvent, save=True): - state_instance = self.state_for_workflow_control(wfc_id) - if state_instance is None: - raise ValueError(f"No state found for workflow control with id '{wfc_id}'") - - new_state = self.event(state_instance, event) - if save: - new_state.saveall() - return new_state - - def state_for_workflow_control(self, wfc_id:str) -> Union[State, None]: - wfc = models.WorkflowControl.pull(wfc_id) - if wfc is None: - return None - - for state in self.STATES: - if state.matches(wfc): - return state(wfc) - - return None - - def event(self, state_instance:State, event:WorkflowEvent) -> State: - if event.__class__ not in state_instance.events: - raise ValueError(f"Invalid event '{event}' for state '{type(state_instance).__name__}'") - - new_state = state_instance.exit(event) - return new_state - - def initialise_workflow(self, actor:Union[str, Account], application:models.Application) -> State: - wfc = models.WorkflowControl() - wfc.application_id = application.id - wfc.original_application = application - wfc.application_title = application.bibjson().title - initial_state = AwaitingTriage.enter(actor, wfc, application) - return initial_state \ No newline at end of file diff --git a/portality/models/__init__.py b/portality/models/__init__.py index 65502a5d33..00a0abc676 100644 --- a/portality/models/__init__.py +++ b/portality/models/__init__.py @@ -35,7 +35,7 @@ from portality.models.data_dump import DataDump from portality.models.journal_csv import JournalCSV from portality.models.ris_export import RISExport -from portality.models.workflow import WorkflowControl +from portality.models.workflow import WorkflowControl, WorkflowControlStateQuery import sys diff --git a/portality/models/workflow.py b/portality/models/workflow.py index 71050de379..81a8e00ed9 100644 --- a/portality/models/workflow.py +++ b/portality/models/workflow.py @@ -96,6 +96,11 @@ class WorkflowControl(SeamlessMixin, DomainObject): __SEAMLESS_STRUCT__ = STRUCT __SEAMLESS_COERCE__ = COERCE_MAP + # Generic state definition values + ANY = "*" + UNASSIGNED = "-" + ASSIGNED = "+" + def __init__(self, **kwargs): # FIXME: hack, to deal with ES integration layer being improperly abstracted if "_source" in kwargs: @@ -279,4 +284,76 @@ def query(self): ] } } - } \ No newline at end of file + } + +class WorkflowControlStateQuery: + def __init__(self, + module:str=None, + stage:str=None, + editor_group:str=None, + reviewer:str=None, + size:int=100): + self._module = module + self._stage = stage + self._editor_group = editor_group + self._reviewer = reviewer + self._size = size + + @property + def size(self): + return self._size + + @size.setter + def size(self, size:int): + self._size = size + + def query(self): + must = [] + must_not = [] + + if self._module is not None: + if self._module != WorkflowControl.ANY: + if self._module == WorkflowControl.UNASSIGNED: + must_not.append({"exists": {"field": "state.module"}}) + elif self._module == WorkflowControl.ASSIGNED: + must.append({"exists": {"field": "state.module"}}) + else: + must.append({"term": {"state.module.exact": self._module}}) + + if self._stage is not None: + if self._stage != WorkflowControl.ANY: + if self._stage == WorkflowControl.UNASSIGNED: + must_not.append({"exists": {"field": "state.stage"}}) + elif self._stage == WorkflowControl.ASSIGNED: + must.append({"exists": {"field": "state.stage"}}) + else: + must.append({"term": {"state.stage.exact": self._stage}}) + + if self._editor_group is not None: + if self._editor_group != WorkflowControl.ANY: + if self._editor_group == WorkflowControl.UNASSIGNED: + must_not.append({"exists": {"field": "state.editor_group"}}) + elif self._editor_group == WorkflowControl.ASSIGNED: + must.append({"exists": {"field": "state.editor_group"}}) + else: + must.append({"term": {"state.editor_group.exact": self._editor_group}}) + + if self._reviewer is not None: + if self._reviewer != WorkflowControl.ANY: + if self._reviewer == WorkflowControl.UNASSIGNED: + must_not.append({"exists": {"field": "state.reviewer"}}) + elif self._reviewer == WorkflowControl.ASSIGNED: + must.append({"exists": {"field": "state.reviewer"}}) + else: + must.append({"term": {"state.reviewer.exact": self._reviewer}}) + + bool = {} + if len(must) > 0: + bool["must"] = must + if len(must_not) > 0: + bool["must_not"] = must_not + q = {"query": {"bool": bool}} + + q["sort"] = {"created_date": {"order": "asc"}} + + return q \ No newline at end of file diff --git a/portality/ui/workflow.py b/portality/ui/workflow.py index ed31df7d02..306562703d 100644 --- a/portality/ui/workflow.py +++ b/portality/ui/workflow.py @@ -1,5 +1,5 @@ -from portality.bll.services.workflow import Claim, Assign, Reassign, Unclaim, Fail, MinimalReview, ApplicationEdit, \ - RescindMinimalReview, Triaged, Unassign +from portality.bll.services.workflow.core import Claim, Assign, Reassign, Unclaim, Fail, ApplicationEdit, Unassign +from portality.bll.services.workflow.triage import MinimalReview, RescindMinimalReview, Triaged from portality.ui import templates from portality.util import url_for @@ -71,10 +71,12 @@ class TriagedUI(EventUI): Reassign: ReassignUI, Unclaim: UnclaimUI, Fail: FailUI, + Unassign: UnassignUI, + MinimalReview: None, RescindMinimalReview: None, Triaged: TriagedUI, - Unassign: UnassignUI + } ##################################### diff --git a/portality/view/workflow.py b/portality/view/workflow.py index c8232e9c41..ea36aab744 100644 --- a/portality/view/workflow.py +++ b/portality/view/workflow.py @@ -6,8 +6,10 @@ from portality import models from portality.bll import DOAJ from portality.bll.exceptions import AuthoriseException -from portality.bll.services.workflow import AwaitingTriage, TriageAssessmentInProgress, Claim, Unclaim, \ - TriageAssessmentMinimalReview, MinimalReview, RescindMinimalReview, Unassign, Rejected, Fail +from portality.bll.services.workflow.core import Claim, Unclaim, Unassign, Fail +from portality.bll.services.workflow.rejected import Rejected +from portality.bll.services.workflow.triage import AwaitingTriage, TriageAssessmentInProgress, \ + TriageAssessmentMinimalReview, RescindMinimalReview, MinimalReview from portality.decorators import ssl_required from portality.ui import templates from portality.ui.workflow import StateUI From e094d8cbb7bb789382264ba2278e021576710119 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Thu, 30 Apr 2026 17:23:40 +0100 Subject: [PATCH 06/13] add support for user attributes on user accounts --- portality/constants.py | 21 ++ portality/forms/validate.py | 20 +- .../migrate/4317_user_attributes/__init__.py | 0 .../apply_user_attributes.py | 185 ++++++++++++++++++ portality/models/account.py | 79 ++++++++ .../_account/includes/_edit_form_js.html | 67 ++++++- .../_account/includes/_edit_user_form.html | 57 +++++- portality/templates-v2/management/base.html | 12 ++ portality/view/account.py | 50 ++++- 9 files changed, 473 insertions(+), 18 deletions(-) create mode 100644 portality/migrate/4317_user_attributes/__init__.py create mode 100644 portality/migrate/4317_user_attributes/apply_user_attributes.py diff --git a/portality/constants.py b/portality/constants.py index 4ed8d7c3a5..a8569d71f3 100644 --- a/portality/constants.py +++ b/portality/constants.py @@ -113,6 +113,27 @@ # TODO add ultra_bulk_delete and refactor view to use constants ROLE_ADMIN_REPORT_WITH_NOTES = "ultra_admin_reports_with_notes" # MUST start with ultra_ so that superusers don't gain +USER_ATTR__WORKFLOW = "workflow" +USER_ATTR__LANGUAGE = "language" +USER_ATTR__COUNTRY = "country" +USER_ATTR__TAG = "tag" + +USER_ATTR__ALL = [ + USER_ATTR__WORKFLOW, + USER_ATTR__LANGUAGE, + USER_ATTR__COUNTRY, + USER_ATTR__TAG +] + +EWF__TRIAGE = "Triage" +EWF__QUICK_FAIL = "Quick Fail" +EWF__QUALITY_REVIEW = "Quality Review" + +EWF__ALL_STAGES = [ + EWF__TRIAGE, + EWF__QUICK_FAIL, + EWF__QUALITY_REVIEW +] CRON_NEVER = {"month": "2", "day": "31", "day_of_week": "*", "hour": "*", "minute": "*"} diff --git a/portality/forms/validate.py b/portality/forms/validate.py index 228ca2b803..e3afc275c3 100644 --- a/portality/forms/validate.py +++ b/portality/forms/validate.py @@ -670,9 +670,13 @@ def __init__(self, message=None): def __call__(self, form, field): if field.data is not None and field.data != '': - check = get_currency_code(field.data, fail_if_not_found=True) - if check is None: - raise validators.ValidationError(self.message) + value = field.data + if not isinstance(value, list): + value = [value] + for v in value: + check = get_currency_code(v, fail_if_not_found=True) + if check is None: + raise validators.ValidationError(self.message) class CurrentISOLanguage(object): @@ -683,6 +687,10 @@ def __init__(self, message=None): def __call__(self, form, field): if field.data is not None and field.data != '': - check = isolang.find(field.data) - if check is None: - raise validators.ValidationError(self.message) + value = field.data + if not isinstance(value, list): + value = [value] + for v in value: + check = isolang.find(v) + if check is None: + raise validators.ValidationError(self.message) diff --git a/portality/migrate/4317_user_attributes/__init__.py b/portality/migrate/4317_user_attributes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/portality/migrate/4317_user_attributes/apply_user_attributes.py b/portality/migrate/4317_user_attributes/apply_user_attributes.py new file mode 100644 index 0000000000..dde8fd1a38 --- /dev/null +++ b/portality/migrate/4317_user_attributes/apply_user_attributes.py @@ -0,0 +1,185 @@ +"""Migration script to add user attributes (country and language) based on Editor Group CSVs. + +Usage: + python -m portality.migrate.4317_user_attributes /path/to/countries.csv /path/to/languages.csv + +The first CSV should have two columns with headers: the first is the country name, the second the Editor Group name. +The second CSV should have two columns with headers: the first is the language name, the second the Editor Group name. + +For each row this script finds the EditorGroup by name, collects the accounts attached to the group +(maned, editor, associates) and schedules the appropriate ISO code to be added to each user's attributes +('country' or 'language'). All validations are done against the portality datasets (pycountry/isolang). + +The script collates all labels for each user and performs a single save per user at the end. +""" + +import csv +import sys +import argparse +from collections import defaultdict + +from portality.models.editors import EditorGroup +from portality.models.account import Account +from portality import datasets, constants +from portality.lib import isolang +from portality.core import app + + +def resolve_country_code(name: str): + """Return a 2-character country code for a given country name, or None if not found.""" + if not name: + return None + code = datasets.get_country_code(name, fail_if_not_found=True) + return code + + +def resolve_language_code(name: str): + """Return a 2-character language code (alpha2) for a given language name, or None if not found.""" + if not name: + return None + lang = isolang.find(name) + if not lang: + return None + # isolang.find returns a dict with 'alpha2' key + return lang.get('alpha2') + + +def read_pairs_from_csv(path): + """Read CSV and return list of (label, editor_group_name) pairs using the first two columns. + + Expects two columns with headers; will take the first and second header values. + """ + pairs = [] + with open(path, newline='') as fh: + reader = csv.DictReader(fh) + headers = reader.fieldnames + if not headers or len(headers) < 2: + raise ValueError(f"CSV at {path} must have at least two columns with headers") + col_label = headers[0] + col_group = headers[1] + for row in reader: + label = (row.get(col_label) or '').strip() + group = (row.get(col_group) or '').strip() + if label and group: + pairs.append((label, group)) + return pairs + + +def collate_attributes(country_pairs, language_pairs): + """Process pairs and collate desired attributes per account id. + + Returns mapping: account_id -> { 'country': set(...), 'language': set(...) } + """ + account_attrs = defaultdict(lambda: {'country': set(), 'language': set()}) + + def _collect_for_group(group_name, code, attr_type): + eg = EditorGroup.group_by_name(group_name) + if eg is None: + app.logger.warning(f"EditorGroup not found: '{group_name}'") + print(f"WARNING: EditorGroup not found: '{group_name}'") + return + + # collect maned, editor, associates + acct_ids = set() + if eg.maned: + acct_ids.add(eg.maned) + if eg.editor: + acct_ids.add(eg.editor) + for a in eg.associates or []: + acct_ids.add(a) + + for aid in acct_ids: + if not aid: + continue + account_attrs[aid][attr_type].add(code) + + # countries + for country_name, group_name in country_pairs: + code = resolve_country_code(country_name) + if code is None: + app.logger.warning(f"Unable to resolve country '{country_name}'") + print(f"WARNING: Unable to resolve country '{country_name}'") + continue + _collect_for_group(group_name, code.upper(), 'country') + + # languages + for lang_name, group_name in language_pairs: + code = resolve_language_code(lang_name) + if code is None: + app.logger.warning(f"Unable to resolve language '{lang_name}'") + print(f"WARNING: Unable to resolve language '{lang_name}'") + continue + _collect_for_group(group_name, code.upper(), 'language') + + return account_attrs + + +def apply_attributes(account_attrs, dry_run=False): + """Apply collated attributes to Account objects and save them once each. + + Returns a summary dict. + """ + summary = {'updated': 0, 'skipped_missing_account': 0} + for aid, attrs in account_attrs.items(): + acc = Account.pull(aid) + if acc is None: + summary['skipped_missing_account'] += 1 + app.logger.warning(f"Account not found: {aid}") + print(f"WARNING: Account not found: {aid}") + continue + + changed = False + # countries + for c in sorted(attrs.get('country', [])): + try: + if not acc.has_attribute(constants.USER_ATTR__COUNTRY, c): + acc.add_attribute(constants.USER_ATTR__COUNTRY, c) + changed = True + except Exception: + app.logger.exception(f"Failed to add country attribute '{c}' to account {aid}") + + for l in sorted(attrs.get('language', [])): + try: + if not acc.has_attribute(constants.USER_ATTR__LANGUAGE, l): + acc.add_attribute(constants.USER_ATTR__LANGUAGE, l) + changed = True + except Exception: + app.logger.exception(f"Failed to add language attribute '{l}' to account {aid}") + + if changed: + if dry_run: + print(f"DRY RUN: Would save account {aid} with new attributes: {attrs}") + else: + acc.save() + print(f"Saved account {aid} (added {len(attrs.get('country', []))} country(s), {len(attrs.get('language', []))} language(s))") + summary['updated'] += 1 + + return summary + + +def main(): + parser = argparse.ArgumentParser(description='Add user attributes from Editor Group CSVs') + parser.add_argument('countries_csv', help='CSV: first col country name, second col Editor Group name') + parser.add_argument('languages_csv', help='CSV: first col language name, second col Editor Group name') + parser.add_argument('--dry-run', action='store_true', help='Do not save changes, just report') + + args = parser.parse_args() + + country_pairs = read_pairs_from_csv(args.countries_csv) + language_pairs = read_pairs_from_csv(args.languages_csv) + + print(f"Read {len(country_pairs)} country rows and {len(language_pairs)} language rows") + + account_attrs = collate_attributes(country_pairs, language_pairs) + + print(f"Collated attributes for {len(account_attrs)} accounts") + + summary = apply_attributes(account_attrs, dry_run=args.dry_run) + + print("Done. Summary:") + print(summary) + + +if __name__ == '__main__': + main() + diff --git a/portality/models/account.py b/portality/models/account.py index bd733d98f2..714917a77a 100644 --- a/portality/models/account.py +++ b/portality/models/account.py @@ -219,6 +219,56 @@ def set_role(self, role): role = [role] self.data["role"] = role + ############################### + ## user attributes + + @property + def attributes(self): + return self.data.get("attributes") + + @attributes.deleter + def attributes(self): + if "attributes" in self.data: + del self.data["attributes"] + + @property + def attribute_workflow(self): + return self.data.get("attributes", {}).get(constants.USER_ATTR__WORKFLOW, []) + + @property + def attribute_language(self): + return self.data.get("attributes", {}).get(constants.USER_ATTR__LANGUAGE, []) + + @property + def attribute_country(self): + return self.data.get("attributes", {}).get(constants.USER_ATTR__COUNTRY, []) + + @property + def attribute_tag(self): + return self.data.get("attributes", {}).get(constants.USER_ATTR__TAG, []) + + def add_attribute(self, attribute_type, value): + if attribute_type not in constants.USER_ATTR__ALL: + raise ValueError("Unknown user attribute type: {}".format(attribute_type)) + if "attributes" not in self.data: + self.data["attributes"] = {} + if attribute_type not in self.data["attributes"]: + self.data["attributes"][attribute_type] = [] + if value not in self.data["attributes"][attribute_type]: + self.data["attributes"][attribute_type].append(value) + + def has_attribute(self, attribute_type, value): + if attribute_type not in constants.USER_ATTR__ALL: + raise ValueError("Unknown user attribute type: {}".format(attribute_type)) + return value in self.data.get("attributes", {}).get(attribute_type, []) + + def get_attributes(self, attribute_type): + if attribute_type not in constants.USER_ATTR__ALL: + raise ValueError("Unknown user attribute type: {}".format(attribute_type)) + return self.data.get("attributes", {}).get(attribute_type, []) + + ####################### + def prep(self): self.data['last_updated'] = dates.now_str() @@ -273,3 +323,32 @@ def is_enable_publisher_email(cls) -> bool: # TODO: in the long run this needs to move out to the user's email preferences but for now it # is here to replicate the behaviour in the code it replaces return app.config.get("ENABLE_PUBLISHER_EMAIL", False) + + @classmethod + def find_by_attributes(cls, attribute_types_and_values:list[tuple[str, str]], limit=1000): + q = AttributesQuery(attribute_types_and_values, limit) + return cls.object_query(q.query()) + + +class AttributesQuery: + def __init__(self, attribute_types_and_values, page_size): + self._tup = attribute_types_and_values + self._size = page_size + + def query(self): + musts = [] + for t, v in self._tup.items(): + if not isinstance(v, list): + v = [v] + f = {"terms": {f"attribute.{t}.exact": v}} + musts.append(f) + + q = { + "query": { + "bool": { + "must": musts + } + }, + "size": self._size + } + return q diff --git a/portality/templates-v2/_account/includes/_edit_form_js.html b/portality/templates-v2/_account/includes/_edit_form_js.html index bb6cf3ebfe..d313f79361 100644 --- a/portality/templates-v2/_account/includes/_edit_form_js.html +++ b/portality/templates-v2/_account/includes/_edit_form_js.html @@ -2,8 +2,71 @@