class VerifiedDate(object): """Adds 'Verified Date' which is set when status is set to 'Verified'. When object is verified the status is overridden to 'Final' and the information about verification exposed as the 'verified' boolean. Requires Stateful to be mixed in as well. """ VERIFIED_STATES = {u"Verified"} DONE_STATES = {} # pylint: disable=method-hidden # because validator only sets date per model instance @declared_attr def verified_date(cls): return deferred( db.Column(db.Date, nullable=True), cls.__name__ ) @hybrid_property def verified(self): return self.verified_date != None # noqa _publish_attrs = [ reflection.PublishOnly('verified'), reflection.PublishOnly('verified_date'), ] _aliases = { "verified_date": "Verified Date" } _fulltext_attrs = [ "verified_date", "verified", ] @validates('status') def validate_status(self, key, value): """Update verified_date on status change, make verified status final.""" # Sqlalchemy only uses one validator per status (not necessarily the # first) and ignores others. This enables cooperation between validators # since 'status' is not defined here. if hasattr(super(VerifiedDate, self), "validate_status"): value = super(VerifiedDate, self).validate_status(key, value) if (value in self.VERIFIED_STATES and self.status not in self.VERIFIED_STATES): self.verified_date = datetime.datetime.now() value = self.FINAL_STATE elif (value not in self.VERIFIED_STATES and value not in self.DONE_STATES and (self.status in self.VERIFIED_STATES or self.status in self.DONE_STATES)): self.verified_date = None return value
class Documentable(object): @declared_attr def object_documents(cls): cls.documents = association_proxy( 'object_documents', 'document', creator=lambda document: ObjectDocument( document=document, documentable_type=cls.__name__, ) ) joinstr = ('and_(foreign(ObjectDocument.documentable_id) == {type}.id, ' ' foreign(ObjectDocument.documentable_type) == "{type}")') joinstr = joinstr.format(type=cls.__name__) return db.relationship( 'ObjectDocument', primaryjoin=joinstr, backref='{0}_documentable'.format(cls.__name__), cascade='all, delete-orphan', ) _publish_attrs = [ reflection.PublishOnly('documents'), 'object_documents', ] _include_links = [ # 'object_documents', ] @classmethod def eager_query(cls): query = super(Documentable, cls).eager_query() return cls.eager_inclusions(query, Documentable._include_links).options( orm.subqueryload('object_documents'))
class FinishedDate(object): """Adds 'Finished Date' which is set when status is set to a finished state. Requires Stateful to be mixed in as well. """ NOT_DONE_STATES = None DONE_STATES = {} # pylint: disable=method-hidden # because validator only sets date per model instance @declared_attr def finished_date(cls): # pylint: disable=no-self-argument return deferred( db.Column(db.DateTime, nullable=True), cls.__name__ ) _publish_attrs = [ reflection.PublishOnly('finished_date') ] _aliases = { "finished_date": "Finished Date" } _fulltext_attrs = [ attributes.DatetimeFullTextAttr('finished_date', 'finished_date'), ] @validates('status') def validate_status(self, key, value): """Update finished_date given the right status change.""" # Sqlalchemy only uses one validator per status (not necessarily the # first) and ignores others. This enables cooperation between validators # since 'status' is not defined here. if hasattr(super(FinishedDate, self), "validate_status"): value = super(FinishedDate, self).validate_status(key, value) # pylint: disable=unsupported-membership-test # short circuit if (value in self.DONE_STATES and (self.NOT_DONE_STATES is None or self.status in self.NOT_DONE_STATES)): self.finished_date = datetime.datetime.now() elif ((self.NOT_DONE_STATES is None or value in self.NOT_DONE_STATES) and self.status in self.DONE_STATES): self.finished_date = None return value @classmethod def indexed_query(cls): return super(FinishedDate, cls).indexed_query().options( orm.Load(cls).load_only("finished_date"), )
class Folderable(object): """Mixin adding the ability to attach folders to an object""" @classmethod def late_init_folderable(cls): def make_object_folders(cls): joinstr = 'and_(foreign(ObjectFolder.folderable_id) == {type}.id, '\ 'foreign(ObjectFolder.folderable_type) == "{type}")' joinstr = joinstr.format(type=cls.__name__) return db.relationship( 'ObjectFolder', primaryjoin=joinstr, backref='{0}_folderable'.format(cls.__name__), cascade='all, delete-orphan', ) cls.object_folders = make_object_folders(cls) @simple_property def folders(self): """Returns a list of associated folders' ids""" # pylint: disable=not-an-iterable return [{"id": fobject.folder_id} for fobject in self.object_folders] _publish_attrs = [ 'object_folders', reflection.PublishOnly('folders'), ] @classmethod def eager_query(cls): from sqlalchemy import orm query = super(Folderable, cls).eager_query() return query.options(orm.subqueryload('object_folders')) def log_json(self): """Serialize to JSON""" out_json = super(Folderable, self).log_json() if hasattr(self, "object_folders"): out_json["object_folders"] = [ # pylint: disable=not-an-iterable create_stub(fold) for fold in self.object_folders if fold ] if hasattr(self, "folders"): out_json["folders"] = self.folders return out_json
class StatusValidatedMixin(mixins.Stateful): """Mixin setup statuses for Cycle and CycleTaskGroup.""" ASSIGNED = u"Assigned" IN_PROGRESS = u"InProgress" FINISHED = u"Finished" VERIFIED = u"Verified" NO_VALIDATION_STATES = [ASSIGNED, IN_PROGRESS, FINISHED] VALID_STATES = NO_VALIDATION_STATES + [VERIFIED] _publish_attrs = [ reflection.PublishOnly("is_verification_needed"), ] def is_verification_needed(self): raise NotImplementedError() @classmethod def default_status(cls): return cls.ASSIGNED def valid_statuses(self): """Return valid status for self instance.""" if self.is_verification_needed: return self.VALID_STATES return self.NO_VALIDATION_STATES @property def active_states(self): return [ i for i in self.valid_statuses() if i not in self.inactive_states ] @property def inactive_states(self): if self.is_verification_needed: return [self.VERIFIED] else: return [self.FINISHED]
class Snapshot(relationship.Relatable, mixins.Base, db.Model): """Snapshot object that holds a join of parent object, revision, child object and parent object's context. Conceptual model is that we have a parent snapshotable object (e.g. Audit) which will not create relationships to objects with automapper at the time of creation but will instead create snapshots of those objects based on the latest revision of the object at the time of create / update of the object. Objects that were supposed to be mapped are called child objects. """ __tablename__ = "snapshots" _publish_attrs = [ "parent", "child_id", "child_type", "revision", "revision_id", reflection.PublishOnly("revisions"), reflection.PublishOnly("is_latest_revision"), reflection.PublishOnly("original_object_deleted"), ] _update_attrs = [ "parent", "child_id", "child_type", "update_revision" ] _include_links = [ "revision" ] _aliases = { "attributes": "Attributes", "mappings": { "display_name": "Mappings", "type": "mapping", } } parent_id = deferred(db.Column(db.Integer, nullable=False), "Snapshot") parent_type = deferred(db.Column(db.String, nullable=False), "Snapshot") # Child ID and child type are data denormalisations - we could easily get # them from revision.content, but since that is a JSON field it will be # easier for development to just denormalise on write and not worry # about it. child_id = deferred(db.Column(db.Integer, nullable=False), "Snapshot") child_type = deferred(db.Column(db.String, nullable=False), "Snapshot") revision_id = deferred(db.Column( db.Integer, db.ForeignKey("revisions.id"), nullable=False ), "Snapshot") revision = db.relationship( "Revision", ) _update_revision = None revisions = db.relationship( "Revision", primaryjoin="and_(Revision.resource_id == foreign(Snapshot.child_id)," "Revision.resource_type == foreign(Snapshot.child_type))", uselist=True, ) @computed_property def is_latest_revision(self): """Flag if the snapshot has the latest revision.""" return self.revisions and self.revision == self.revisions[-1] @computed_property def original_object_deleted(self): """Flag if the snapshot has the latest revision.""" return self.revisions and self.revisions[-1].action == "deleted" @classmethod def eager_query(cls): query = super(Snapshot, cls).eager_query() return cls.eager_inclusions(query, Snapshot._include_links).options( orm.subqueryload('revision'), orm.subqueryload('revisions'), ) @hybrid_property def update_revision(self): return self.revision_id @update_revision.setter def update_revision(self, value): self._update_revision = value if value == "latest": _set_latest_revisions([self]) @property def parent_attr(self): return '{0}_parent'.format(self.parent_type) @property def parent(self): return getattr(self, self.parent_attr) @parent.setter def parent(self, value): self.parent_id = getattr(value, 'id', None) self.parent_type = getattr(value, 'type', None) return setattr(self, self.parent_attr, value) @staticmethod def _extra_table_args(_): return ( db.UniqueConstraint( "parent_type", "parent_id", "child_type", "child_id"), db.Index("ix_snapshots_parent", "parent_type", "parent_id"), db.Index("ix_snapshots_child", "child_type", "child_id"), )
class WorkflowState(object): """Object state mixin. This is a mixin for adding workflow_state to all objects that can be mapped to workflow tasks. """ _publish_attrs = [reflection.PublishOnly('workflow_state')] _update_attrs = [] OVERDUE = "Overdue" VERIFIED = "Verified" FINISHED = "Finished" ASSIGNED = "Assigned" IN_PROGRESS = "InProgress" UNKNOWN_STATE = None @classmethod def _get_state(cls, statusable_childs): """Get overall state of a group of tasks. Rules, the first that is true is selected: -if all are verified -> verified -if all are finished -> finished -if all are at least finished -> finished -if any are in progress or declined -> in progress -if any are assigned -> assigned The function will work correctly only for non Overdue states. If the result is overdue, it should be handled outside of this function. Args: current_tasks: list of tasks that are currently a part of an active cycle or cycles that are active in an workflow. Returns: Overall state according to the rules described above. """ states = {i.status or i.ASSIGNED for i in statusable_childs} if states in [{cls.VERIFIED}, {cls.FINISHED}, {cls.ASSIGNED}]: return states.pop() if states == {cls.FINISHED, cls.VERIFIED}: return cls.FINISHED return cls.IN_PROGRESS if states else cls.UNKNOWN_STATE @classmethod def get_object_state(cls, objs): """Get lowest state of an object Get the lowest possible state of the tasks relevant to one object. States are scanned in order: Overdue, InProgress, Finished, Assigned, Verified. Args: objs: A list of cycle group object tasks, which should all be mapped to the same object. Returns: Name of the lowest state of all active cycle tasks that relate to the given objects. """ current_tasks = [] for task in objs: if not task.cycle.is_current: continue if task.is_overdue: return cls.OVERDUE current_tasks.append(task) return cls._get_state(current_tasks) @classmethod def get_workflow_state(cls, cycles): """Get lowest state of a workflow Get the lowest possible state of the tasks relevant to a given workflow. States are scanned in order: Overdue, InProgress, Finished, Assigned, Verified. Args: cycles: list of cycles belonging to a single workflow. Returns: Name of the lowest workflow state, if there are any active cycles. Otherwise it returns None. """ current_cycles = [] for cycle_instance in cycles: if not cycle_instance.is_current: continue for task in cycle_instance.cycle_task_group_object_tasks: if task.is_overdue: return cls.OVERDUE current_cycles.append(cycle_instance) return cls._get_state(current_cycles) @builder.simple_property def workflow_state(self): return WorkflowState.get_object_state(self.cycle_task_group_object_tasks)
class Workflow(mixins.CustomAttributable, HasOwnContext, mixins.Timeboxed, mixins.Described, mixins.Titled, mixins.Notifiable, mixins.Stateful, mixins.Slugged, Indexed, db.Model): """Basic Workflow first class object. """ __tablename__ = 'workflows' _title_uniqueness = False VALID_STATES = [u"Draft", u"Active", u"Inactive"] # valid Frequency to user readable values mapping VALID_FREQUENCIES = { "one_time": "one time", "weekly": "weekly", "monthly": "monthly", "quarterly": "quarterly", "annually": "annually" } @classmethod def default_frequency(cls): return 'one_time' @orm.validates('frequency') def validate_frequency(self, _, value): """Make sure that value is listed in valid frequencies. Args: value: A string value for requested frequency Returns: default_frequency which is 'one_time' if the value is None, or the value itself. Raises: Value error, if the value is not in the VALID_FREQUENCIES """ if value is None: value = self.default_frequency() if value not in self.VALID_FREQUENCIES: message = u"Invalid state '{}'".format(value) raise ValueError(message) return value notify_on_change = deferred( db.Column(db.Boolean, default=False, nullable=False), 'Workflow') notify_custom_message = deferred( db.Column(db.Text, nullable=True), 'Workflow') frequency = deferred( db.Column(db.String, nullable=True, default=default_frequency), 'Workflow' ) object_approval = deferred( db.Column(db.Boolean, default=False, nullable=False), 'Workflow') recurrences = db.Column(db.Boolean, default=False, nullable=False) workflow_people = db.relationship( 'WorkflowPerson', backref='workflow', cascade='all, delete-orphan') people = association_proxy( 'workflow_people', 'person', 'WorkflowPerson') task_groups = db.relationship( 'TaskGroup', backref='workflow', cascade='all, delete-orphan') cycles = db.relationship( 'Cycle', backref='workflow', cascade='all, delete-orphan') next_cycle_start_date = db.Column(db.Date, nullable=True) non_adjusted_next_cycle_start_date = db.Column(db.Date, nullable=True) # this is an indicator if the workflow exists from before the change where # we deleted cycle objects, which changed how the cycle is created and # how objects are mapped to the cycle tasks is_old_workflow = deferred( db.Column(db.Boolean, default=False, nullable=True), 'Workflow') # This column needs to be deferred because one of the migrations # uses Workflow as a model and breaks since at that point in time # there is no 'kind' column yet kind = deferred( db.Column(db.String, default=None, nullable=True), 'Workflow') IS_VERIFICATION_NEEDED_DEFAULT = True is_verification_needed = db.Column( db.Boolean, default=IS_VERIFICATION_NEEDED_DEFAULT, nullable=False) @orm.validates('is_verification_needed') def validate_is_verification_needed(self, key, value): # pylint: disable=unused-argument """Validate is_verification_needed field for Workflow. It's not allowed to change is_verification_needed flag after creation. If is_verification_needed doesn't send, then is_verification_needed flag is True. """ if self.is_verification_needed is None: return self.IS_VERIFICATION_NEEDED_DEFAULT if value is None else value if value is None: return self.is_verification_needed if value != self.is_verification_needed: raise ValueError("is_verification_needed value isn't changeble") return value @builder.simple_property def workflow_state(self): return WorkflowState.get_workflow_state(self.cycles) _sanitize_html = [ 'notify_custom_message', ] _publish_attrs = [ 'workflow_people', reflection.PublishOnly('people'), 'task_groups', 'frequency', 'notify_on_change', 'notify_custom_message', 'cycles', 'object_approval', 'recurrences', 'is_verification_needed', reflection.PublishOnly('next_cycle_start_date'), reflection.PublishOnly('non_adjusted_next_cycle_start_date'), reflection.PublishOnly('workflow_state'), reflection.PublishOnly('kind'), ] _fulltext_attrs = [ ValueMapFullTextAttr( "frequency", "frequency", value_map=VALID_FREQUENCIES, ) ] _aliases = { "frequency": { "display_name": "Frequency", "mandatory": True, }, "is_verification_needed": { "display_name": "Need Verification", "mandatory": True, }, "notify_custom_message": "Custom email message", "notify_on_change": "Force real-time email updates", "workflow_owner": { "display_name": "Manager", "type": reflection.AttributeInfo.Type.USER_ROLE, "mandatory": True, "filter_by": "_filter_by_workflow_owner", }, "workflow_member": { "display_name": "Member", "type": reflection.AttributeInfo.Type.USER_ROLE, "filter_by": "_filter_by_workflow_member", }, "status": None, "start_date": None, "end_date": None, } @classmethod def _filter_by_workflow_owner(cls, predicate): return cls._filter_by_role("WorkflowOwner", predicate) @classmethod def _filter_by_workflow_member(cls, predicate): return cls._filter_by_role("WorkflowMember", predicate) def copy(self, _other=None, **kwargs): """Create a partial copy of the current workflow. """ columns = [ 'title', 'description', 'notify_on_change', 'notify_custom_message', 'frequency', 'end_date', 'start_date' ] target = self.copy_into(_other, columns, **kwargs) return target def copy_task_groups(self, target, **kwargs): """Copy all task groups and tasks mapped to this workflow. """ for task_group in self.task_groups: obj = task_group.copy( workflow=target, context=target.context, clone_people=kwargs.get("clone_people", False), clone_objects=kwargs.get("clone_objects", False), modified_by=get_current_user(), ) target.task_groups.append(obj) if kwargs.get("clone_tasks"): task_group.copy_tasks( obj, clone_people=kwargs.get("clone_people", False), clone_objects=kwargs.get("clone_objects", True) ) return target @classmethod def eager_query(cls): return super(Workflow, cls).eager_query().options( orm.subqueryload('cycles').undefer_group('Cycle_complete') .subqueryload("cycle_task_group_object_tasks") .undefer_group("CycleTaskGroupObjectTask_complete"), orm.subqueryload('task_groups'), orm.subqueryload('workflow_people'), ) @classmethod def indexed_query(cls): return super(Workflow, cls).indexed_query().options( orm.Load(cls).undefer_group( "Workflow_complete", ), ) @classmethod def ensure_backlog_workflow_exists(cls): """Ensures there is at least one backlog workflow with an active cycle. If such workflow does not exist it creates one.""" def any_active_cycle(workflows): """Checks if any active cycle exists from given workflows""" for workflow in workflows: for cur_cycle in workflow.cycles: if cur_cycle.is_current: return True return False # Check if backlog workflow already exists backlog_workflows = Workflow.query\ .filter(and_ (Workflow.kind == "Backlog", Workflow.frequency == "one_time"))\ .all() if len(backlog_workflows) > 0 and any_active_cycle(backlog_workflows): return "At least one backlog workflow already exists" # Create a backlog workflow backlog_workflow = Workflow(description="Backlog workflow", title="Backlog (one time)", frequency="one_time", status="Active", recurrences=0, kind="Backlog") # create wf context wf_ctx = backlog_workflow.get_or_create_object_context(context=1) backlog_workflow.context = wf_ctx db.session.flush(backlog_workflow) # create a cycle backlog_cycle = cycle.Cycle(description="Backlog workflow", title="Backlog (one time)", is_current=1, status="Assigned", start_date=None, end_date=None, context=backlog_workflow .get_or_create_object_context(), workflow=backlog_workflow) # create a cycletaskgroup backlog_ctg = cycle_task_group\ .CycleTaskGroup(description="Backlog workflow taskgroup", title="Backlog TaskGroup", cycle=backlog_cycle, status="InProgress", start_date=None, end_date=None, context=backlog_workflow .get_or_create_object_context()) db.session.add_all([backlog_workflow, backlog_cycle, backlog_ctg]) db.session.flush() # add fulltext entries indexer = get_indexer() indexer.create_record(indexer.fts_record_for(backlog_workflow)) return "Backlog workflow created"
class Snapshot(relationship.Relatable, mixins.Base, db.Model): """Snapshot object that holds a join of parent object, revision, child object and parent object's context. Conceptual model is that we have a parent snapshotable object (e.g. Audit) which will not create relationships to objects with automapper at the time of creation but will instead create snapshots of those objects based on the latest revision of the object at the time of create / update of the object. Objects that were supposed to be mapped are called child objects. """ __tablename__ = "snapshots" _publish_attrs = [ "parent", "child_id", "child_type", "revision", "revision_id", reflection.PublishOnly("revisions"), reflection.PublishOnly("is_latest_revision"), ] _update_attrs = ["parent", "child_id", "child_type", "update_revision"] _include_links = ["revision"] parent_id = deferred(db.Column(db.Integer, nullable=False), "Snapshot") parent_type = deferred(db.Column(db.String, nullable=False), "Snapshot") # Child ID and child type are data denormalisations - we could easily get # them from revision.content, but since that is a JSON field it will be # easier for development to just denormalise on write and not worry # about it. child_id = deferred(db.Column(db.Integer, nullable=False), "Snapshot") child_type = deferred(db.Column(db.String, nullable=False), "Snapshot") revision_id = deferred( db.Column(db.Integer, db.ForeignKey("revisions.id"), nullable=False), "Snapshot") revision = db.relationship("Revision", ) _update_revision = None revisions = db.relationship( "Revision", primaryjoin="and_(Revision.resource_id == foreign(Snapshot.child_id)," "Revision.resource_type == foreign(Snapshot.child_type))", uselist=True, ) @computed_property def is_latest_revision(self): """Flag if the snapshot has the latest revision.""" return self.revisions and self.revision == self.revisions[-1] @classmethod def eager_query(cls): query = super(Snapshot, cls).eager_query() return cls.eager_inclusions(query, Snapshot._include_links).options( orm.subqueryload('revision'), orm.subqueryload('revisions'), ) @hybrid_property def update_revision(self): return self.revision_id @update_revision.setter def update_revision(self, value): self._update_revision = value if value == "latest": latest_revision_id = get_latest_revision_id(self) if latest_revision_id: self.revision_id = latest_revision_id @property def parent_attr(self): return '{0}_parent'.format(self.parent_type) @property def parent(self): return getattr(self, self.parent_attr) @parent.setter def parent(self, value): self.parent_id = getattr(value, 'id', None) self.parent_type = getattr(value, 'type', None) return setattr(self, self.parent_attr, value) @staticmethod def _extra_table_args(_): return ( db.UniqueConstraint("parent_type", "parent_id", "child_type", "child_id"), db.Index("ix_snapshots_parent", "parent_type", "parent_id"), db.Index("ix_snapshots_child", "child_type", "child_id"), ) @classmethod def handle_post_flush(cls, session, flush_context, instances): """Handle snapshot objects on api post requests.""" # pylint: disable=unused-argument # Arguments here are set in the event listener and are mandatory. with benchmark("Snapshot pre flush handler"): snapshots = [o for o in session if isinstance(o, cls)] if not snapshots: return with benchmark("Snapshot revert attrs"): cls._revert_attrs(snapshots) new_snapshots = [ o for o in snapshots if getattr(o, "_update_revision", "") == "new" ] if new_snapshots: with benchmark("Snapshot post api set revisions"): cls._set_revisions(new_snapshots) with benchmark("Snapshot post api ensure relationships"): cls._ensure_relationships(new_snapshots) @classmethod def _revert_attrs(cls, objects): """Revert any modified attributes on snapshot. All snapshot attributes that are updatable with API calls should only be settable and not editable. This function reverts any possible edits to existing values. """ attrs = ["parent_id", "parent_type", "child_id", "child_type"] for snapshot in objects: for attr in attrs: deleted = inspect(snapshot).attrs[attr].history.deleted if deleted: setattr(snapshot, attr, deleted[0]) @classmethod def _ensure_relationships(cls, objects): """Ensure that snapshotted object is related to audit program. This function is made to handle multiple snapshots for a single audit. Args: objects: list of snapshot objects with child_id and child_type set. """ pairs = [(o.child_type, o.child_id) for o in objects] assert len({o.parent.id for o in objects}) == 1 # fail on multiple audits program = ("Program", objects[0].parent.program_id) rel = relationship.Relationship columns = db.session.query( rel.destination_type, rel.destination_id, rel.source_type, rel.source_id, ) query = columns.filter( tuple_(rel.destination_type, rel.destination_id) == (program), tuple_(rel.source_type, rel.source_id).in_(pairs)).union( columns.filter( tuple_(rel.source_type, rel.source_id) == (program), tuple_(rel.destination_type, rel.destination_id).in_(pairs))) existing_pairs = set( sum([[(r.destination_type, r.destination_id), (r.source_type, r.source_id)] for r in query], [])) # build a set of all found type-id pairs missing_pairs = set(pairs).difference(existing_pairs) cls._insert_relationships(program, missing_pairs) @classmethod def _insert_relationships(cls, program, missing_pairs): """Insert missing obj-program relationships.""" if not missing_pairs: return current_user_id = get_current_user_id() now = datetime.now() # We are doing an INSERT IGNORE INTO here to mitigate a race condition # that happens when multiple simultaneous requests create the same # automapping. If a relationship object fails our unique constraint # it means that the mapping was already created by another request # and we can safely ignore it. inserter = relationship.Relationship.__table__.insert().prefix_with( "IGNORE") db.session.execute( inserter.values([{ "id": None, "modified_by_id": current_user_id, "created_at": now, "updated_at": now, "source_type": program[0], "source_id": program[1], "destination_type": dst_type, "destination_id": dst_id, "context_id": None, "status": None, "automapping_id": None } for dst_type, dst_id in missing_pairs])) @classmethod def _set_revisions(cls, objects): """Set latest revision_id for given child_type. Args: objects: list of snapshot objects with child_id and child_type set. """ pairs = [(o.child_type, o.child_id) for o in objects] query = db.session.query( func.max(revision.Revision.id, name="id", identifier="id"), revision.Revision.resource_type, revision.Revision.resource_id, ).filter( tuple_( revision.Revision.resource_type, revision.Revision.resource_id, ).in_(pairs)).group_by( revision.Revision.resource_type, revision.Revision.resource_id, ) id_map = {(r_type, r_id): id_ for id_, r_type, r_id in query} for o in objects: o.revision_id = id_map.get((o.child_type, o.child_id))
class WorkflowState(object): """Object state mixin. This is a mixin for adding workflow_state to all objects that can be mapped to workflow tasks. """ _publish_attrs = [reflection.PublishOnly('workflow_state')] _update_attrs = [] @classmethod def _get_state(cls, current_tasks): """Get overall state of a group of tasks. Rules, the first that is true is selected: -if all are verified -> verified -if all are finished -> finished -if all are at least finished -> finished -if any are in progress or declined -> in progress -if any are assigned -> assigned The function will work correctly only for non Overdue states. If the result is overdue, it should be handled outside of this function. Args: current_tasks: list of tasks that are currently a part of an active cycle or cycles that are active in an workflow. Returns: Overall state according to the rules described above. """ states = [task.status or "Assigned" for task in current_tasks] if states.count("Verified") == len(states): resulting_state = "Verified" elif states.count("Finished") == len(states): resulting_state = "Finished" elif not set(states).intersection( {"InProgress", "Assigned", "Declined"}): resulting_state = "Finished" elif set(states).intersection( {"InProgress", "Declined", "Finished", "Verified"}): resulting_state = "InProgress" elif "Assigned" in states: resulting_state = "Assigned" else: resulting_state = None return resulting_state @classmethod def get_object_state(cls, objs): """Get lowest state of an object Get the lowest possible state of the tasks relevant to one object. States are scanned in order: Overdue, InProgress, Finished, Assigned, Verified. Args: objs: A list of cycle group object tasks, which should all be mapped to the same object. Returns: Name of the lowest state of all active cycle tasks that relate to the given objects. """ current_tasks = [task for task in objs if task.cycle.is_current] if not current_tasks: return None today = date.today() overdue_tasks = any(task.end_date and task.end_date < today and task.status != "Verified" for task in current_tasks) if overdue_tasks: return "Overdue" return cls._get_state(current_tasks) @classmethod def get_workflow_state(cls, cycles): """Get lowest state of a workflow Get the lowest possible state of the tasks relevant to a given workflow. States are scanned in order: Overdue, InProgress, Finished, Assigned, Verified. Args: cycles: list of cycles belonging to a single workflow. Returns: Name of the lowest workflow state, if there are any active cycles. Otherwise it returns None. """ current_cycles = [cycle for cycle in cycles if cycle.is_current] if not current_cycles: return None today = date.today() for cycle in current_cycles: for task in cycle.cycle_task_group_object_tasks: if (task.status != "Verified" and task.end_date is not None and task.end_date < today): return "Overdue" return cls._get_state(current_cycles) @computed_property def workflow_state(self): return WorkflowState.get_object_state( self.cycle_task_group_object_tasks)
class Workflow(mixins.CustomAttributable, HasOwnContext, mixins.Timeboxed, mixins.Described, mixins.Titled, mixins.Slugged, mixins.Stateful, mixins.Base, db.Model): """Basic Workflow first class object. """ __tablename__ = 'workflows' _title_uniqueness = False VALID_STATES = [u"Draft", u"Active", u"Inactive"] VALID_FREQUENCIES = [ "one_time", "weekly", "monthly", "quarterly", "annually" ] @classmethod def default_frequency(cls): return 'one_time' @orm.validates('frequency') def validate_frequency(self, _, value): """Make sure that value is listed in valid frequencies. Args: value: A string value for requested frequency Returns: default_frequency which is 'one_time' if the value is None, or the value itself. Raises: Value error, if the value is not None or in the VALID_FREQUENCIES array. """ if value is None: value = self.default_frequency() if value not in self.VALID_FREQUENCIES: message = u"Invalid state '{}'".format(value) raise ValueError(message) return value notify_on_change = deferred( db.Column(db.Boolean, default=False, nullable=False), 'Workflow') notify_custom_message = deferred(db.Column(db.Text, nullable=True), 'Workflow') frequency = deferred( db.Column(db.String, nullable=True, default=default_frequency), 'Workflow') object_approval = deferred( db.Column(db.Boolean, default=False, nullable=False), 'Workflow') recurrences = db.Column(db.Boolean, default=False, nullable=False) workflow_people = db.relationship('WorkflowPerson', backref='workflow', cascade='all, delete-orphan') people = association_proxy('workflow_people', 'person', 'WorkflowPerson') task_groups = db.relationship('TaskGroup', backref='workflow', cascade='all, delete-orphan') cycles = db.relationship('Cycle', backref='workflow', cascade='all, delete-orphan') next_cycle_start_date = db.Column(db.Date, nullable=True) non_adjusted_next_cycle_start_date = db.Column(db.Date, nullable=True) # this is an indicator if the workflow exists from before the change where # we deleted cycle objects, which changed how the cycle is created and # how objects are mapped to the cycle tasks is_old_workflow = deferred( db.Column(db.Boolean, default=False, nullable=True), 'Workflow') # This column needs to be deferred because one of the migrations # uses Workflow as a model and breaks since at that point in time # there is no 'kind' column yet kind = deferred(db.Column(db.String, default=None, nullable=True), 'Workflow') @computed_property def workflow_state(self): return WorkflowState.get_workflow_state(self.cycles) _sanitize_html = [ 'notify_custom_message', ] _publish_attrs = [ 'workflow_people', reflection.PublishOnly('people'), 'task_groups', 'frequency', 'notify_on_change', 'notify_custom_message', 'cycles', 'object_approval', 'recurrences', reflection.PublishOnly('next_cycle_start_date'), reflection.PublishOnly('non_adjusted_next_cycle_start_date'), reflection.PublishOnly('workflow_state'), reflection.PublishOnly('kind') ] _aliases = { "frequency": { "display_name": "Frequency", "mandatory": True, }, "notify_custom_message": "Custom email message", "notify_on_change": "Force real-time email updates", "workflow_owner": { "display_name": "Manager", "type": reflection.AttributeInfo.Type.USER_ROLE, "mandatory": True, "filter_by": "_filter_by_workflow_owner", }, "workflow_member": { "display_name": "Member", "type": reflection.AttributeInfo.Type.USER_ROLE, "filter_by": "_filter_by_workflow_member", }, "workflow_mapped": { "display_name": "No Access", "type": reflection.AttributeInfo.Type.USER_ROLE, "filter_by": "_filter_by_no_access", }, "status": None, "start_date": None, "end_date": None, } @classmethod def _filter_by_workflow_owner(cls, predicate): return cls._filter_by_role("WorkflowOwner", predicate) @classmethod def _filter_by_workflow_member(cls, predicate): return cls._filter_by_role("WorkflowMember", predicate) @classmethod def _filter_by_no_access(cls, predicate): """Get query that filters workflows with mapped users. Args: predicate: lambda function that excepts a single parameter and returns true of false. Returns: An sqlalchemy query that evaluates to true or false and can be used in filtering workflows by no_access users. """ is_no_access = not_( UserRole.query.filter((UserRole.person_id == Person.id) & (UserRole.context_id == workflow_person. WorkflowPerson.context_id)).exists()) return workflow_person.WorkflowPerson.query.filter( (cls.id == workflow_person.WorkflowPerson.workflow_id) & is_no_access).join(Person).filter( (predicate(Person.name) | predicate(Person.email))).exists() def copy(self, _other=None, **kwargs): """Create a partial copy of the current workflow. """ columns = [ 'title', 'description', 'notify_on_change', 'notify_custom_message', 'frequency', 'end_date', 'start_date' ] target = self.copy_into(_other, columns, **kwargs) return target def copy_task_groups(self, target, **kwargs): """Copy all task groups and tasks mapped to this workflow. """ for task_group in self.task_groups: obj = task_group.copy( workflow=target, context=target.context, clone_people=kwargs.get("clone_people", False), clone_objects=kwargs.get("clone_objects", False), modified_by=get_current_user(), ) target.task_groups.append(obj) if kwargs.get("clone_tasks"): task_group.copy_tasks( obj, clone_people=kwargs.get("clone_people", False), clone_objects=kwargs.get("clone_objects", True)) return target @classmethod def eager_query(cls): return super(Workflow, cls).eager_query().options( orm.subqueryload('cycles').undefer_group('Cycle_complete'). subqueryload("cycle_task_group_object_tasks").undefer_group( "CycleTaskGroupObjectTask_complete"), orm.subqueryload('task_groups'), orm.subqueryload('workflow_people'), ) @classmethod def ensure_backlog_workflow_exists(cls): """Ensures there is at least one backlog workflow with an active cycle. If such workflow does not exist it creates one.""" def any_active_cycle(workflows): """Checks if any active cycle exists from given workflows""" for workflow in workflows: for cur_cycle in workflow.cycles: if cur_cycle.is_current: return True return False # Check if backlog workflow already exists backlog_workflows = Workflow.query\ .filter(and_ (Workflow.kind == "Backlog", Workflow.frequency == "one_time"))\ .all() if len(backlog_workflows) > 0 and any_active_cycle(backlog_workflows): return "At least one backlog workflow already exists" # Create a backlog workflow backlog_workflow = Workflow(description="Backlog workflow", title="Backlog (one time)", frequency="one_time", status="Active", recurrences=0, kind="Backlog") # create wf context wf_ctx = backlog_workflow.get_or_create_object_context(context=1) backlog_workflow.context = wf_ctx db.session.flush(backlog_workflow) # create a cycle backlog_cycle = cycle.Cycle( description="Backlog workflow", title="Backlog (one time)", is_current=1, status="Assigned", start_date=None, end_date=None, context=backlog_workflow.get_or_create_object_context(), workflow=backlog_workflow) # create a cycletaskgroup backlog_ctg = cycle_task_group\ .CycleTaskGroup(description="Backlog workflow taskgroup", title="Backlog TaskGroup", cycle=backlog_cycle, status="InProgress", start_date=None, end_date=None, context=backlog_workflow .get_or_create_object_context()) db.session.add_all([backlog_workflow, backlog_cycle, backlog_ctg]) db.session.flush() # add fulltext entries get_indexer().create_record(fts_record_for(backlog_workflow)) get_indexer().create_record(fts_record_for(backlog_cycle)) get_indexer().create_record(fts_record_for(backlog_ctg)) return "Backlog workflow created"
class Documentable(object): """Base class for EvidenceURL mixin""" @declared_attr def object_documents(cls): """Returns all the associated documents""" cls.documents = association_proxy( 'object_documents', 'document', creator=lambda document: ObjectDocument( document=document, documentable_type=cls.__name__, )) joinstr = ( 'and_(foreign(ObjectDocument.documentable_id) == {type}.id, ' ' foreign(ObjectDocument.documentable_type) == "{type}")') joinstr = joinstr.format(type=cls.__name__) return db.relationship( 'ObjectDocument', primaryjoin=joinstr, backref='{0}_documentable'.format(cls.__name__), cascade='all, delete-orphan', ) _publish_attrs = [ reflection.PublishOnly('documents'), 'object_documents', ] _include_links = [ # 'object_documents', ] @classmethod def eager_query(cls): query = super(Documentable, cls).eager_query() return cls.eager_inclusions(query, Documentable._include_links).options( orm.subqueryload('object_documents')) def log_json(self): """Serialize to JSON""" out_json = super(Documentable, self).log_json() if hasattr(self, "documents"): out_json["documents"] = [ # pylint: disable=not-an-iterable create_stub(doc) for doc in self.documents if doc ] if hasattr(self, "object_documents"): out_json["object_documents"] = [ # pylint: disable=not-an-iterable create_stub(doc) for doc in self.object_documents if doc ] return out_json @classmethod def indexed_query(cls): query = super(Documentable, cls).indexed_query() return query.options( orm.subqueryload("object_documents").load_only( "id", "documentable_id", "documentable_type", ))
class CycleTaskGroupObjectTask(mixins.WithContact, wf_mixins.CycleTaskStatusValidatedMixin, mixins.Stateful, mixins.Timeboxed, relationship.Relatable, mixins.Notifiable, mixins.Described, mixins.Titled, mixins.Slugged, mixins.Base, ft_mixin.Indexed, db.Model): """Cycle task model """ __tablename__ = 'cycle_task_group_object_tasks' readable_name_alias = 'cycle task' _title_uniqueness = False IMPORTABLE_FIELDS = ( 'slug', 'title', 'description', 'start_date', 'end_date', 'finished_date', 'verified_date', 'contact', ) @classmethod def generate_slug_prefix_for(cls, obj): return "CYCLETASK" # Note: this statuses are used in utils/query_helpers to filter out the tasks # that should be visible on My Tasks pages. PROPERTY_TEMPLATE = u"task {}" _fulltext_attrs = [ ft_attributes.DateFullTextAttr( "end_date", 'end_date', ), ft_attributes.FullTextAttr("assignee", 'contact', ['name', 'email']), ft_attributes.FullTextAttr("group title", 'cycle_task_group', ['title'], False), ft_attributes.FullTextAttr("cycle title", 'cycle', ['title'], False), ft_attributes.FullTextAttr("group assignee", lambda x: x.cycle_task_group.contact, ['email', 'name'], False), ft_attributes.FullTextAttr("cycle assignee", lambda x: x.cycle.contact, ['email', 'name'], False), ft_attributes.DateFullTextAttr( "group due date", lambda x: x.cycle_task_group.next_due_date, with_template=False), ft_attributes.DateFullTextAttr("cycle due date", lambda x: x.cycle.next_due_date, with_template=False), ft_attributes.MultipleSubpropertyFullTextAttr("comments", "cycle_task_entries", ["description"]), ] AUTO_REINDEX_RULES = [ ft_mixin.ReindexRule("CycleTaskEntry", lambda x: x.cycle_task_group_object_task), ] cycle_id = db.Column( db.Integer, db.ForeignKey('cycles.id', ondelete="CASCADE"), nullable=False, ) cycle_task_group_id = db.Column( db.Integer, db.ForeignKey('cycle_task_groups.id', ondelete="CASCADE"), nullable=False, ) task_group_task_id = db.Column(db.Integer, db.ForeignKey('task_group_tasks.id'), nullable=True) task_group_task = db.relationship( "TaskGroupTask", foreign_keys="CycleTaskGroupObjectTask.task_group_task_id") task_type = db.Column(db.String(length=250), nullable=False) response_options = db.Column(types.JsonType(), nullable=False, default=[]) selected_response_options = db.Column(types.JsonType(), nullable=False, default=[]) sort_index = db.Column(db.String(length=250), default="", nullable=False) finished_date = db.Column(db.DateTime) verified_date = db.Column(db.DateTime) object_approval = association_proxy('cycle', 'workflow.object_approval') object_approval.publish_raw = True @property def cycle_task_objects_for_cache(self): """Changing task state must invalidate `workflow_state` on objects """ return [(object_.__class__.__name__, object_.id) for object_ in self.related_objects] # pylint: disable=not-an-iterable _publish_attrs = [ 'cycle', 'cycle_task_group', 'task_group_task', 'cycle_task_entries', 'sort_index', 'task_type', 'response_options', 'selected_response_options', reflection.PublishOnly('object_approval'), reflection.PublishOnly('finished_date'), reflection.PublishOnly('verified_date'), reflection.PublishOnly('allow_change_state'), ] default_description = "<ol>"\ + "<li>Expand the object review task.</li>"\ + "<li>Click on the Object to be reviewed.</li>"\ + "<li>Review the object in the Info tab.</li>"\ + "<li>Click \"Approve\" to approve the object.</li>"\ + "<li>Click \"Decline\" to decline the object.</li>"\ + "</ol>" _aliases = { "title": "Summary", "description": "Task Details", "contact": { "display_name": "Assignee", "mandatory": True, }, "secondary_contact": None, "finished_date": "Actual Finish Date", "verified_date": "Actual Verified Date", "cycle": { "display_name": "Cycle", "filter_by": "_filter_by_cycle", }, "cycle_task_group": { "display_name": "Task Group", "mandatory": True, "filter_by": "_filter_by_cycle_task_group", }, "task_type": { "display_name": "Task Type", "mandatory": True, }, "end_date": "Due Date", "start_date": "Start Date", } @builder.simple_property def related_objects(self): """Compute and return a list of all the objects related to this cycle task. Related objects are those that are found either on the "source" side, or on the "destination" side of any of the instance's relations. Returns: (list) All objects related to the instance. """ # pylint: disable=not-an-iterable sources = [r.source for r in self.related_sources] destinations = [r.destination for r in self.related_destinations] return sources + destinations @declared_attr def wfo_roles(self): """WorkflowOwner UserRoles in parent Workflow. Relies on self.context_id = parent_workflow.context_id. """ from ggrc_basic_permissions import models as bp_models def primaryjoin(): """Join UserRoles by context_id = self.context_id and role_id = WFO.""" workflow_owner_role_id = db.session.query( bp_models.Role.id, ).filter( bp_models.Role.name == "WorkflowOwner", ).subquery() ur_context_id = sa.orm.foreign(bp_models.UserRole.context_id) ur_role_id = sa.orm.foreign(bp_models.UserRole.role_id) return sa.and_(self.context_id == ur_context_id, workflow_owner_role_id == ur_role_id) return db.relationship( bp_models.UserRole, primaryjoin=primaryjoin, viewonly=True, ) @builder.simple_property def allow_change_state(self): return self.cycle.is_current and self.current_user_wfo_or_assignee() def current_user_wfo_or_assignee(self): """Current user is Workflow owner or Assignee for self.""" current_user_id = login.get_current_user_id() # pylint: disable=not-an-iterable return (current_user_id == self.contact_id or current_user_id in [ur.person_id for ur in self.wfo_roles]) @classmethod def _filter_by_cycle(cls, predicate): """Get query that filters cycle tasks by related cycles. Args: predicate: lambda function that excepts a single parameter and returns true of false. Returns: An sqlalchemy query that evaluates to true or false and can be used in filtering cycle tasks by related cycles. """ return Cycle.query.filter((Cycle.id == cls.cycle_id) & (predicate(Cycle.slug) | predicate(Cycle.title))).exists() @classmethod def _filter_by_cycle_task_group(cls, predicate): """Get query that filters cycle tasks by related cycle task groups. Args: predicate: lambda function that excepts a single parameter and returns true of false. Returns: An sqlalchemy query that evaluates to true or false and can be used in filtering cycle tasks by related cycle task groups. """ return CycleTaskGroup.query.filter( (CycleTaskGroup.id == cls.cycle_id) & (predicate(CycleTaskGroup.slug) | predicate(CycleTaskGroup.title))).exists() @classmethod def eager_query(cls): """Add cycle task entries to cycle task eager query This function adds cycle_task_entries as a join option when fetching cycles tasks, and makes sure that with one query we fetch all cycle task related data needed for generating cycle taks json for a response. Returns: a query object with cycle_task_entries added to joined load options. """ query = super(CycleTaskGroupObjectTask, cls).eager_query() return query.options( orm.joinedload('cycle').joinedload('workflow').undefer_group( 'Workflow_complete'), orm.joinedload('cycle_task_entries'), orm.subqueryload('wfo_roles'), ) @classmethod def indexed_query(cls): return super(CycleTaskGroupObjectTask, cls).indexed_query().options( orm.Load(cls).load_only("end_date", "start_date", "created_at", "updated_at"), orm.Load(cls).joinedload("cycle_task_group").load_only( "id", "title", "end_date", "next_due_date", ), orm.Load(cls).joinedload("cycle").load_only( "id", "title", "next_due_date"), orm.Load(cls).joinedload("cycle_task_group").joinedload( "contact").load_only("email", "name", "id"), orm.Load(cls).joinedload("cycle").joinedload("contact").load_only( "email", "name", "id"), orm.Load(cls).subqueryload("cycle_task_entries").load_only( "description", "id"), orm.Load(cls).joinedload("contact").load_only( "email", "name", "id"), )