class Timeboxed(object): """Mixin that defines `start_date` and `end_date` fields.""" @declared_attr def start_date(cls): # pylint: disable=no-self-argument return deferred(db.Column(db.Date), cls.__name__) @declared_attr def end_date(cls): # pylint: disable=no-self-argument return deferred(db.Column(db.Date), cls.__name__) # pylint: disable=unused-argument,no-self-use @validates('start_date', 'end_date') def validate_date(self, key, value): return value.date() if isinstance(value, datetime.datetime) else value # pylint: enable=unused-argument,no-self-use # REST properties _api_attrs = reflection.ApiAttributes('start_date', 'end_date') _aliases = { "start_date": "Effective Date", "end_date": "Stop Date", } _fulltext_attrs = [ attributes.DateFullTextAttr('start_date', 'start_date'), attributes.DateFullTextAttr('end_date', 'end_date'), ] @classmethod def indexed_query(cls): return super(Timeboxed, cls).indexed_query().options( orm.Load(cls).load_only("start_date", "end_date"), )
class Timeboxed(object): """Mixin that defines `start_date` and `end_date` fields.""" @declared_attr def start_date(cls): # pylint: disable=no-self-argument return deferred(db.Column(db.Date), cls.__name__) @declared_attr def end_date(cls): # pylint: disable=no-self-argument return deferred(db.Column(db.Date), cls.__name__) # REST properties _publish_attrs = ['start_date', 'end_date'] _aliases = { "start_date": "Effective Date", "end_date": "Stop Date", } _fulltext_attrs = [ attributes.DateFullTextAttr('start_date', 'start_date'), attributes.DateFullTextAttr('end_date', 'end_date'), ] @classmethod def indexed_query(cls): return super(Timeboxed, cls).indexed_query().options( orm.Load(cls).load_only("start_date", "end_date"), )
class WithEndDate(object): """Mixin that defines `end_date`.""" # REST properties _api_attrs = reflection.ApiAttributes('end_date') _aliases = { "end_date": "Stop Date", } _fulltext_attrs = [ attributes.DateFullTextAttr('end_date', 'end_date'), ] @declared_attr def end_date(cls): return deferred(db.Column(db.Date), cls.__name__) @validates('end_date') def validate_date(self, _, value): # pylint: disable=no-self-use return value.date() if isinstance(value, datetime.datetime) else value @classmethod def indexed_query(cls): return super(WithEndDate, cls).indexed_query().options( orm.Load(cls).load_only("end_date"), )
class WithLastDeprecatedDate(object): """Mixin that defines `last_deprecated_date` field.""" # pylint: disable=method-hidden ; pylint thinks that last_deprecated_date # is overwritten in validate_status @declared_attr def last_deprecated_date(cls): # pylint: disable=no-self-argument return deferred(db.Column(db.Date), cls.__name__) # pylint: disable=unused-argument,no-self-use @validates('last_deprecated_date') def validate_date(self, key, value): return value.date() if isinstance(value, datetime.datetime) else value # pylint: enable=unused-argument,no-self-use _api_attrs = reflection.ApiAttributes( reflection.Attribute('last_deprecated_date', create=False, update=False), ) _aliases = { "last_deprecated_date": { "display_name": "Last Deprecated Date", "view_only": True, "description": "Automatically provided values" }, } _fulltext_attrs = [ attributes.DateFullTextAttr('last_deprecated_date', 'last_deprecated_date'), ] @classmethod def indexed_query(cls): return super(WithLastDeprecatedDate, cls).indexed_query().options( orm.Load(cls).load_only("last_deprecated_date"), ) AUTO_SETUP_STATUS = "Deprecated" @validates('status') def validate_status(self, key, value): """Autosetup current date as last_deprecated_date if 'Deprecated' status will setup.""" # pylint: disable=unused-argument; key is unused but passed in by ORM if hasattr(super(WithLastDeprecatedDate, self), "validate_status"): value = super(WithLastDeprecatedDate, self).validate_status(key, value) if value != self.status and value == self.AUTO_SETUP_STATUS: self.last_deprecated_date = datetime.date.today() return value
class Timeboxed(object): """Mixin that defines `start_date` and `end_date` fields.""" @declared_attr def start_date(cls): return deferred(db.Column(db.Date), cls.__name__) @declared_attr def end_date(cls): return deferred(db.Column(db.Date), cls.__name__) # REST properties _publish_attrs = ['start_date', 'end_date'] _aliases = { "start_date": "Effective Date", "end_date": "Stop Date", } _fulltext_attrs = [ attributes.DateFullTextAttr('start_date', 'start_date'), attributes.DateFullTextAttr('end_date', 'end_date'), ]
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 = [ attributes.DateFullTextAttr("verified_date", "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 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): return deferred(db.Column(db.Date, nullable=True), cls.__name__) _publish_attrs = [reflection.PublishOnly('finished_date')] _aliases = {"finished_date": "Finished Date"} _fulltext_attrs = [ attributes.DateFullTextAttr('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
class Risk(synchronizable.Synchronizable, synchronizable.RoleableSynchronizable, mixins.ExternalCustomAttributable, Relatable, PublicDocumentable, comment.ExternalCommentable, mixins.TestPlanned, mixins.LastDeprecatedTimeboxed, mixins.base.ContextRBAC, mixins.BusinessObject, mixins.Folderable, Indexed, db.Model): """Basic Risk model.""" __tablename__ = 'risks' # GGRCQ attributes external_id = db.Column(db.Integer, nullable=False) due_date = db.Column(db.Date, nullable=True) created_by_id = db.Column(db.Integer, nullable=False) review_status = deferred(db.Column(db.String, nullable=True), "Risk") review_status_display_name = deferred(db.Column(db.String, nullable=True), "Risk") # pylint: disable=no-self-argument @declared_attr def created_by(cls): """Relationship to user referenced by created_by_id.""" return utils.person_relationship(cls.__name__, "created_by_id") last_submitted_at = db.Column(db.DateTime, nullable=True) last_submitted_by_id = db.Column(db.Integer, nullable=True) @declared_attr def last_submitted_by(cls): """Relationship to user referenced by last_submitted_by_id.""" return utils.person_relationship(cls.__name__, "last_submitted_by_id") last_verified_at = db.Column(db.DateTime, nullable=True) last_verified_by_id = db.Column(db.Integer, nullable=True) @declared_attr def last_verified_by(cls): """Relationship to user referenced by last_verified_by_id.""" return utils.person_relationship(cls.__name__, "last_verified_by_id") # Overriding mixin to make mandatory @declared_attr def description(cls): # pylint: disable=no-self-argument return deferred(db.Column(db.Text, nullable=False, default=u""), cls.__name__) risk_type = db.Column(db.Text, nullable=True) threat_source = db.Column(db.Text, nullable=True) threat_event = db.Column(db.Text, nullable=True) vulnerability = db.Column(db.Text, nullable=True) @validates('review_status') def validate_review_status(self, _, value): """Add explicit non-nullable validation.""" # pylint: disable=no-self-use if value is None: raise exceptions.ValidationError( "Review status for the object is not specified") return value @validates('review_status_display_name') def validate_review_status_display_name(self, _, value): """Add explicit non-nullable validation.""" # pylint: disable=no-self-use # pylint: disable=invalid-name if value is None: raise exceptions.ValidationError( "Review status display for the object is not specified") return value _sanitize_html = [ 'risk_type', 'threat_source', 'threat_event', 'vulnerability' ] _fulltext_attrs = [ 'risk_type', 'threat_source', 'threat_event', 'vulnerability', 'review_status_display_name', attributes.DateFullTextAttr('due_date', 'due_date'), attributes.DatetimeFullTextAttr('last_submitted_at', 'last_submitted_at'), attributes.DatetimeFullTextAttr('last_verified_at', 'last_verified_at'), attributes.FullTextAttr("created_by", "created_by", ["email", "name"]), attributes.FullTextAttr("last_submitted_by", "last_submitted_by", ["email", "name"]), attributes.FullTextAttr("last_verified_by", "last_verified_by", ["email", "name"]) ] _custom_publish = { 'created_by': ggrc_utils.created_by_stub, 'last_submitted_by': ggrc_utils.last_submitted_by_stub, 'last_verified_by': ggrc_utils.last_verified_by_stub, } _api_attrs = reflection.ApiAttributes( 'risk_type', 'threat_source', 'threat_event', 'vulnerability', 'external_id', 'due_date', reflection.ExternalUserAttribute('created_by', force_create=True), reflection.ExternalUserAttribute('last_submitted_by', force_create=True), reflection.ExternalUserAttribute('last_verified_by', force_create=True), 'last_submitted_at', 'last_verified_at', 'external_slug', 'review_status', 'review_status_display_name', ) _aliases = { "description": { "display_name": "Description", "mandatory": True }, "risk_type": { "display_name": "Risk Type", "mandatory": False }, "threat_source": { "display_name": "Threat Source", "mandatory": False }, "threat_event": { "display_name": "Threat Event", "mandatory": False }, "vulnerability": { "display_name": "Vulnerability", "mandatory": False }, "documents_file": None, "status": { "display_name": "State", "mandatory": False, "description": "Options are: \n {}".format('\n'.join( mixins.BusinessObject.VALID_STATES)) }, "review_status": { "display_name": "Review State", "mandatory": False, "filter_only": True, }, "review_status_display_name": { "display_name": "Review Status", "mandatory": False, }, "due_date": { "display_name": "Due Date", "mandatory": False, }, "created_by": { "display_name": "Created By", "mandatory": False, }, "last_submitted_at": { "display_name": "Last Owner Reviewed Date", "mandatory": False, }, "last_submitted_by": { "display_name": "Last Owner Reviewed By", "mandatory": False, }, "last_verified_at": { "display_name": "Last Compliance Reviewed Date", "mandatory": False, }, "last_verified_by": { "display_name": "Last Compliance Reviewed By", "mandatory": False, }, } def log_json(self): res = super(Risk, self).log_json() res["created_by"] = ggrc_utils.created_by_stub(self) res["last_submitted_by"] = ggrc_utils.last_submitted_by_stub(self) res["last_verified_by"] = ggrc_utils.last_verified_by_stub(self) return res
class CycleTaskGroupObjectTask( roleable.Roleable, wf_mixins.CycleTaskStatusValidatedMixin, wf_mixins.WorkflowCommentable, mixins.WithLastDeprecatedDate, mixins.Timeboxed, relationship.Relatable, mixins.Notifiable, mixins.Described, mixins.Titled, mixins.Slugged, mixins.Base, base.ContextRBAC, 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', 'status', '__acl__:Task Assignees', '__acl__:Task Secondary Assignees', ) @classmethod def generate_slug_prefix(cls): 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("group title", 'cycle_task_group', ['title'], False), ft_attributes.FullTextAttr("object_approval", 'object_approval', with_template=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"]), ft_attributes.BooleanFullTextAttr("needs verification", "is_verification_needed", with_template=False, true_value="Yes", false_value="No"), "folder", ] # The app should not pass to the json representation of # relationships to the internal models IGNORED_RELATED_TYPES = ["CalendarEvent"] _custom_publish = { "related_sources": lambda obj: [ rel.log_json() for rel in obj.related_sources if rel.source_type not in obj.IGNORED_RELATED_TYPES ], "related_destinations": lambda obj: [ rel.log_json() for rel in obj.related_destinations if rel.destination_type not in obj.IGNORED_RELATED_TYPES ] } 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) # This parameter is overridden by cycle task group backref, but is here to # ensure pylint does not complain _cycle_task_group = None @hybrid.hybrid_property def cycle_task_group(self): """Getter for cycle task group foreign key.""" return self._cycle_task_group @cycle_task_group.setter def cycle_task_group(self, cycle_task_group): """Setter for cycle task group foreign key.""" if not self._cycle_task_group and cycle_task_group: relationship.Relationship(source=cycle_task_group, destination=self) self._cycle_task_group = cycle_task_group @hybrid.hybrid_property def object_approval(self): return self.cycle.workflow.object_approval @object_approval.expression def object_approval(cls): # pylint: disable=no-self-argument return sa.select([ Workflow.object_approval, ]).where( sa.and_( (Cycle.id == cls.cycle_id), (Cycle.workflow_id == Workflow.id))).label('object_approval') @builder.simple_property def folder(self): """Simple property for cycle folder.""" if self.cycle: return self.cycle.folder return "" @builder.simple_property def is_in_history(self): """Used on UI to disable editing finished CycleTask which is in history""" return not self.cycle.is_current @property def cycle_task_objects_for_cache(self): """Get all related objects for this CycleTaskGroupObjectTask Returns: List of tuples with (related_object_type, related_object_id) """ return [(object_.__class__.__name__, object_.id) for object_ in self.related_objects()] _api_attrs = reflection.ApiAttributes( 'cycle', 'cycle_task_group', 'task_group_task', 'cycle_task_entries', 'sort_index', 'task_type', 'response_options', 'selected_response_options', reflection.Attribute('related_sources', create=False, update=False), reflection.Attribute('related_destinations', create=False, update=False), reflection.Attribute('object_approval', create=False, update=False), reflection.Attribute('finished_date', create=False, update=False), reflection.Attribute('verified_date', create=False, update=False), reflection.Attribute('allow_change_state', create=False, update=False), reflection.Attribute('folder', create=False, update=False), reflection.Attribute('workflow', create=False, update=False), reflection.Attribute('workflow_title', create=False, update=False), reflection.Attribute('cycle_task_group_title', create=False, update=False), reflection.Attribute('is_in_history', create=False, update=False), ) 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", "finished_date": { "display_name": "Actual Finish Date", "description": ("Make sure that 'Actual Finish Date' isn't set, " "if cycle task state is <'Assigned' / " "'In Progress' / 'Declined' / 'Deprecated'>. " "Type double dash '--' into " "'Actual Finish Date' cell to remove it.") }, "verified_date": { "display_name": "Actual Verified Date", "description": ("Make sure that 'Actual Verified Date' isn't set, " "if cycle task state is <'Assigned' / " "'In Progress' / 'Declined' / 'Deprecated' / " "'Finished'>. Type double dash '--' into " "'Actual Verified Date' cell to remove it.") }, "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 cycle_task_group_title(self): """Property. Returns parent CycleTaskGroup title.""" return self.cycle_task_group.title @builder.simple_property def workflow_title(self): """Property. Returns parent Workflow's title.""" return self.workflow.title @builder.simple_property def workflow(self): """Property which returns parent workflow object.""" return self.cycle.workflow @builder.simple_property def allow_change_state(self): return self.cycle.is_current and self.current_user_wfa_or_assignee() def current_user_wfa_or_assignee(self): """Current user is WF Admin, Assignee or Secondary Assignee for self.""" wfa_ids = self.workflow.get_person_ids_for_rolename("Admin") ta_ids = self.get_person_ids_for_rolename("Task Assignees") tsa_ids = self.get_person_ids_for_rolename("Task Secondary Assignees") return login.get_current_user_id() in set().union( wfa_ids, ta_ids, tsa_ids) @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.subqueryload('cycle_task_entries'), orm.joinedload('cycle').undefer_group('Cycle_complete'), orm.joinedload('cycle').joinedload('workflow').undefer_group( 'Workflow_complete'), orm.joinedload('cycle').joinedload('workflow').joinedload( '_access_control_list'), orm.joinedload('cycle_task_group').undefer_group( 'CycleTaskGroup_complete'), ) @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", "is_verification_needed", ), 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("cycle").joinedload( "workflow").undefer_group("Workflow_complete"), ) def log_json(self): out_json = super(CycleTaskGroupObjectTask, self).log_json() out_json["folder"] = self.folder return out_json @classmethod def bulk_update(cls, src): """Update statuses for bunch of tasks in a bulk. Args: src: input json with next structure: [{"status": "Assigned", "id": 1}, {"status": "In Progress", "id": 2}] Returns: list of updated_instances """ new_prv_state_map = { cls.DEPRECATED: (cls.ASSIGNED, cls.IN_PROGRESS, cls.FINISHED, cls.VERIFIED, cls.DECLINED), cls.IN_PROGRESS: (cls.ASSIGNED, ), cls.FINISHED: (cls.IN_PROGRESS, cls.DECLINED), cls.VERIFIED: (cls.FINISHED, ), cls.DECLINED: (cls.FINISHED, ), cls.ASSIGNED: (), } uniq_states = set([item['state'] for item in src]) if len(list(uniq_states)) != 1: raise BadRequest("Request's JSON contains multiple statuses for " "CycleTasks") new_state = uniq_states.pop() LOGGER.info("Do bulk update CycleTasks with '%s' status", new_state) if new_state not in cls.VALID_STATES: raise BadRequest("Request's JSON contains invalid statuses for " "CycleTasks") prv_states = new_prv_state_map[new_state] all_ids = {item['id'] for item in src} # Eagerly loading is needed to get user permissions for CycleTask faster updatable_objects = cls.eager_query().filter( cls.id.in_(list(all_ids)), cls.status.in_(prv_states)) if new_state in (cls.VERIFIED, cls.DECLINED): updatable_objects = [ obj for obj in updatable_objects if obj.cycle.is_verification_needed ] # Bulk update works only on MyTasks page. Don't need to check for # WorkflowMembers' permissions here. User should update only his own tasks. updatable_objects = [ obj for obj in updatable_objects if obj.current_user_wfa_or_assignee() ] # Queries count is constant because we are using eager query for objects. for obj in updatable_objects: obj.status = new_state obj.modified_by_id = login.get_current_user_id() return updatable_objects
class CycleTaskGroup(mixins.WithContact, mixins.Stateful, mixins.Slugged, mixins.Timeboxed, mixins.Described, mixins.Titled, mixins.Base, index_mixin.Indexed, db.Model): """Cycle Task Group model. """ __tablename__ = 'cycle_task_groups' _title_uniqueness = False @classmethod def generate_slug_prefix_for(cls, obj): # pylint: disable=unused-argument return "CYCLEGROUP" VALID_STATES = ( u'Assigned', u'InProgress', u'Finished', u'Verified', u'Declined') cycle_id = db.Column( db.Integer, db.ForeignKey('cycles.id', ondelete="CASCADE"), nullable=False, ) task_group_id = db.Column( db.Integer, db.ForeignKey('task_groups.id'), nullable=True) cycle_task_group_tasks = db.relationship( 'CycleTaskGroupObjectTask', backref='cycle_task_group', cascade='all, delete-orphan' ) sort_index = db.Column( db.String(length=250), default="", nullable=False) next_due_date = db.Column(db.Date) _publish_attrs = [ 'cycle', 'task_group', 'cycle_task_group_tasks', 'sort_index', 'next_due_date' ] _aliases = { "cycle": { "display_name": "Cycle", "filter_by": "_filter_by_cycle", }, } PROPERTY_TEMPLATE = u"group {}" _fulltext_attrs = [ attributes.MultipleSubpropertyFullTextAttr( "task title", 'cycle_task_group_tasks', ["title"], False ), attributes.MultipleSubpropertyFullTextAttr( "task assignee", lambda instance: [t.contact for t in instance.cycle_task_group_tasks], ["name", "email"], False ), attributes.DateMultipleSubpropertyFullTextAttr( "task due date", "cycle_task_group_tasks", ["end_date"], False ), attributes.DateFullTextAttr("due date", 'next_due_date',), attributes.FullTextAttr("assignee", "contact", ['name', 'email']), attributes.FullTextAttr("cycle title", 'cycle', ['title'], False), attributes.FullTextAttr("cycle assignee", lambda x: x.cycle.contact, ['email', 'name'], False), attributes.DateFullTextAttr("cycle due date", lambda x: x.cycle.next_due_date, with_template=False), attributes.MultipleSubpropertyFullTextAttr( "task comments", lambda instance: itertools.chain(*[ t.cycle_task_entries for t in instance.cycle_task_group_tasks ]), ["description"], False ), ] AUTO_REINDEX_RULES = [ index_mixin.ReindexRule( "CycleTaskGroupObjectTask", lambda x: x.cycle_task_group ), index_mixin.ReindexRule( "Person", _query_filtered_by_contact ), index_mixin.ReindexRule( "Person", lambda x: [i.cycle for i in _query_filtered_by_contact(x)] ), ] @classmethod def _filter_by_cycle(cls, predicate): """Get query that filters 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 task groups by related cycle. """ return Cycle.query.filter( (Cycle.id == cls.cycle_id) & (predicate(Cycle.slug) | predicate(Cycle.title)) ).exists() @classmethod def indexed_query(cls): return super(CycleTaskGroup, cls).indexed_query().options( orm.Load(cls).load_only( "next_due_date", ), orm.Load(cls).subqueryload("cycle_task_group_tasks").load_only( "id", "title", "end_date" ), orm.Load(cls).joinedload("cycle").load_only( "id", "title", "next_due_date" ), orm.Load(cls).subqueryload("cycle_task_group_tasks").joinedload( "contact" ).load_only( "email", "name", "id" ), orm.Load(cls).subqueryload("cycle_task_group_tasks").joinedload( "cycle_task_entries" ).load_only( "description", "id" ), orm.Load(cls).joinedload("cycle").joinedload( "contact" ).load_only( "email", "name", "id" ), orm.Load(cls).joinedload("contact").load_only( "email", "name", "id" ), ) @classmethod def eager_query(cls): """Add cycle tasks and objects to cycle task group eager query. Make sure we load all cycle task group relevant data in a single query. Returns: a query object with cycle_task_group_tasks added to joined load options. """ query = super(CycleTaskGroup, cls).eager_query() return query.options( orm.joinedload('cycle_task_group_tasks') )
class CycleTaskGroup(roleable.Roleable, relationship.Relatable, mixins.WithContact, wf_mixins.CycleTaskGroupRelatedStatusValidatedMixin, mixins.Slugged, mixins.Timeboxed, mixins.Described, mixins.Titled, base.ContextRBAC, mixins.Base, index_mixin.Indexed, db.Model): """Cycle Task Group model. """ __tablename__ = 'cycle_task_groups' _title_uniqueness = False @classmethod def generate_slug_prefix(cls): # pylint: disable=unused-argument return "CYCLEGROUP" cycle_id = db.Column( db.Integer, db.ForeignKey('cycles.id', ondelete="CASCADE"), nullable=False, ) task_group_id = db.Column( db.Integer, db.ForeignKey('task_groups.id'), nullable=True) cycle_task_group_tasks = db.relationship( 'CycleTaskGroupObjectTask', backref='_cycle_task_group', cascade='all, delete-orphan' ) next_due_date = db.Column(db.Date) _api_attrs = reflection.ApiAttributes( 'cycle', 'task_group', 'cycle_task_group_tasks', 'next_due_date' ) _aliases = { "cycle": { "display_name": "Cycle", "filter_by": "_filter_by_cycle", }, } PROPERTY_TEMPLATE = u"group {}" _fulltext_attrs = [ attributes.DateFullTextAttr("due date", 'next_due_date',), attributes.FullTextAttr("assignee", "contact", ['email', 'name']), attributes.FullTextAttr("cycle title", 'cycle', ['title'], False), attributes.FullTextAttr( "cycle assignee", lambda x: x.cycle.contact, ['email', 'name'], False), attributes.DateFullTextAttr( "cycle due date", lambda x: x.cycle.next_due_date, with_template=False), attributes.MultipleSubpropertyFullTextAttr( "task title", "cycle_task_group_tasks", ["title"], False), attributes.MultipleSubpropertyFullTextAttr( "task assignees", "_task_assignees", ["email", "name"], False), attributes.MultipleSubpropertyFullTextAttr( "task state", "cycle_task_group_tasks", ["status"], False), attributes.MultipleSubpropertyFullTextAttr( "task secondary assignees", "_task_secondary_assignees", ["email", "name"], False), attributes.DateMultipleSubpropertyFullTextAttr( "task due date", "cycle_task_group_tasks", ["end_date"], False), attributes.MultipleSubpropertyFullTextAttr( "task comment", lambda instance: itertools.chain(*[ t.comments for t in instance.cycle_task_group_tasks ]), ["description"], False), ] # This parameter is overridden by cycle backref, but is here to ensure # pylint does not complain _cycle = None @hybrid.hybrid_property def cycle(self): """Getter for cycle foreign key.""" return self._cycle @cycle.setter def cycle(self, cycle): """Set cycle foreign key and relationship.""" if not self._cycle and cycle: relationship.Relationship(source=cycle, destination=self) self._cycle = cycle @property def workflow(self): """Property which returns parent workflow object.""" return self.cycle.workflow @property def _task_assignees(self): """Property. Return the list of persons as assignee of related tasks.""" people = set() for ctask in self.cycle_task_group_tasks: people.update(ctask.get_persons_for_rolename("Task Assignees")) return list(people) @property def _task_secondary_assignees(self): """Property. Returns people list as Secondary Assignee of related tasks.""" people = set() for ctask in self.cycle_task_group_tasks: people.update(ctask.get_persons_for_rolename("Task Secondary Assignees")) return list(people) AUTO_REINDEX_RULES = [ index_mixin.ReindexRule( "CycleTaskGroupObjectTask", lambda x: x.cycle_task_group ), index_mixin.ReindexRule( "Person", _query_filtered_by_contact ), index_mixin.ReindexRule( "Person", lambda x: [i.cycle for i in _query_filtered_by_contact(x)] ), ] @classmethod def _filter_by_cycle(cls, predicate): """Get query that filters 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 task groups by related cycle. """ return Cycle.query.filter( (Cycle.id == cls.cycle_id) & (predicate(Cycle.slug) | predicate(Cycle.title)) ).exists() @classmethod def indexed_query(cls): return super(CycleTaskGroup, cls).indexed_query().options( orm.Load(cls).load_only( "next_due_date", ), orm.Load(cls).subqueryload("cycle_task_group_tasks").load_only( "id", "title", "end_date", "status", ), orm.Load(cls).subqueryload("cycle_task_group_tasks").subqueryload( "_access_control_list", ).load_only( "ac_role_id", ).subqueryload( "access_control_people", ).load_only( "person_id", ), orm.Load(cls).subqueryload("cycle_task_group_tasks"), orm.Load(cls).joinedload("cycle").load_only( "id", "title", "next_due_date", ), orm.Load(cls).joinedload("cycle").joinedload( "contact" ).load_only( "name", "email", "id", ), orm.Load(cls).joinedload("contact").load_only( "name", "email", "id", ), ) @classmethod def eager_query(cls, **kwargs): """Add cycle tasks and objects to cycle task group eager query. Make sure we load all cycle task group relevant data in a single query. Returns: a query object with cycle_task_group_tasks added to joined load options. """ query = super(CycleTaskGroup, cls).eager_query(**kwargs) return query.options( orm.subqueryload("cycle_task_group_tasks"), orm.joinedload("cycle").undefer_group("Cycle_complete"), orm.joinedload("cycle").joinedload("contact") )
class Cycle(roleable.Roleable, relationship.Relatable, mixins.WithContact, wf_mixins.CycleStatusValidatedMixin, mixins.Timeboxed, mixins.Described, mixins.Titled, base.ContextRBAC, mixins.Slugged, mixins.Notifiable, ft_mixin.Indexed, db.Model): """Workflow Cycle model """ # pylint: disable=too-many-instance-attributes __tablename__ = 'cycles' _title_uniqueness = False workflow_id = db.Column( db.Integer, db.ForeignKey('workflows.id', ondelete="CASCADE"), nullable=False, ) cycle_task_groups = db.relationship('CycleTaskGroup', backref='_cycle', cascade='all, delete-orphan') cycle_task_group_object_tasks = db.relationship( 'CycleTaskGroupObjectTask', backref='cycle', cascade='all, delete-orphan') is_current = db.Column(db.Boolean, default=True, nullable=False) next_due_date = db.Column(db.Date) # This parameter is overridden by workflow backref, but is here to ensure # pylint does not complain _workflow = None @hybrid.hybrid_property def workflow(self): """Getter for workflow foreign key.""" return self._workflow @workflow.setter def workflow(self, workflow): """Set workflow foreign key and relationship.""" if not self._workflow and workflow: relationship.Relationship(source=workflow, destination=self) self._workflow = workflow @property def is_done(self): """Check if cycle's done Overrides StatusValidatedMixin method because cycle's is_done state depends on is_verification_needed flag """ if super(Cycle, self).is_done: return True if self.cycle_task_group_object_tasks: return False return True @builder.simple_property def folder(self): """Get the workflow folder.""" if self.workflow: return self.workflow.folder return "" _api_attrs = reflection.ApiAttributes( 'workflow', 'cycle_task_groups', 'is_current', 'next_due_date', reflection.Attribute('folder', create=False, update=False), ) _aliases = { "cycle_workflow": { "display_name": "Workflow", "filter_by": "_filter_by_cycle_workflow", }, "contact": "Assignee", "secondary_contact": None, } PROPERTY_TEMPLATE = u"cycle {}" _fulltext_attrs = [ "folder", ft_attributes.DateFullTextAttr("due date", "next_due_date"), ft_attributes.MultipleSubpropertyFullTextAttr("group title", "cycle_task_groups", ["title"], False), ft_attributes.MultipleSubpropertyFullTextAttr( "group assignee", lambda instance: [g.contact for g in instance.cycle_task_groups], ["email", "name"], False), ft_attributes.DateMultipleSubpropertyFullTextAttr( "group due date", 'cycle_task_groups', ["next_due_date"], False), ft_attributes.MultipleSubpropertyFullTextAttr( "task title", 'cycle_task_group_object_tasks', ["title"], False), ft_attributes.MultipleSubpropertyFullTextAttr( "task state", 'cycle_task_group_object_tasks', ["status"], False), ft_attributes.DateMultipleSubpropertyFullTextAttr( "task due date", "cycle_task_group_object_tasks", ["end_date"], False), ft_attributes.MultipleSubpropertyFullTextAttr("task assignees", "_task_assignees", ["name", "email"], False), ft_attributes.MultipleSubpropertyFullTextAttr( "task secondary assignees", "_task_secondary_assignees", ["name", "email"], False), ft_attributes.MultipleSubpropertyFullTextAttr( "task comment", lambda instance: itertools.chain( *[t.comments for t in instance.cycle_task_group_object_tasks]), ["description"], False), ] @property def _task_assignees(self): """Property. Return the list of persons as assignee of related tasks.""" people = set() for ctask in self.cycle_task_group_object_tasks: people.update(ctask.get_persons_for_rolename("Task Assignees")) return list(people) @property def _task_secondary_assignees(self): """Property. Returns people list as Secondary Assignee of related tasks.""" people = set() for ctask in self.cycle_task_group_object_tasks: people.update( ctask.get_persons_for_rolename("Task Secondary Assignees")) return list(people) AUTO_REINDEX_RULES = [ ft_mixin.ReindexRule("CycleTaskGroup", lambda x: x.cycle), ft_mixin.ReindexRule("CycleTaskGroupObjectTask", lambda x: x.cycle_task_group.cycle), ft_mixin.ReindexRule("Person", _query_filtered_by_contact), ] @classmethod def _filter_by_cycle_workflow(cls, predicate): """Filter by cycle workflow.""" from ggrc_workflows.models.workflow import Workflow return Workflow.query.filter((Workflow.id == cls.workflow_id) & (predicate(Workflow.slug) | predicate(Workflow.title))).exists() @classmethod def eager_query(cls, **kwargs): """Add cycle task groups to cycle eager query This function adds cycle_task_groups as a join option when fetching cycles, and makes sure we fetch all cycle related data needed for generating cycle json, in one query. Returns: a query object with cycle_task_groups added to joined load options. """ query = super(Cycle, cls).eager_query(**kwargs) return query.options( orm.joinedload('cycle_task_groups'), orm.Load(cls).joinedload("workflow").undefer_group( "Workflow_complete"), ) @classmethod def indexed_query(cls): return super(Cycle, cls).indexed_query().options( orm.Load(cls).load_only("next_due_date"), orm.Load(cls).subqueryload( "cycle_task_group_object_tasks").load_only( "end_date", "id", "status", "title", ), orm.Load(cls).subqueryload("cycle_task_groups").load_only( "id", "title", "next_due_date", ), orm.Load(cls).subqueryload( "cycle_task_group_object_tasks", ).subqueryload( "_access_control_list").load_only( "ac_role_id", ).subqueryload( "access_control_people").load_only("person_id", ), orm.Load(cls).subqueryload("cycle_task_group_object_tasks"), orm.Load(cls).subqueryload("cycle_task_groups").joinedload( "contact").load_only( "name", "email", "id", ), orm.Load(cls).joinedload("workflow").undefer_group( "Workflow_complete", ), ) def _get_cycle_url(self, widget_name): return urljoin( get_url_root(), "workflows/{workflow_id}#{widget_name}/cycle/{cycle_id}".format( workflow_id=self.workflow.id, cycle_id=self.id, widget_name=widget_name)) @property def cycle_url(self): return self._get_cycle_url("current") @property def cycle_inactive_url(self): return self._get_cycle_url("history") def log_json(self): out_json = super(Cycle, self).log_json() out_json["folder"] = self.folder return out_json
class CycleTaskGroupObjectTask(roleable.Roleable, 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', 'status', '__acl__:Task Assignees', ) @classmethod def generate_slug_prefix(cls): 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("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"]), "folder", ] 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 @builder.simple_property def folder(self): if self.cycle: return self.cycle.folder return "" @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 _api_attrs = reflection.ApiAttributes( 'cycle', 'cycle_task_group', 'task_group_task', 'cycle_task_entries', 'sort_index', 'task_type', 'response_options', 'selected_response_options', reflection.Attribute('object_approval', create=False, update=False), reflection.Attribute('finished_date', create=False, update=False), reflection.Attribute('verified_date', create=False, update=False), reflection.Attribute('allow_change_state', create=False, update=False), reflection.Attribute('folder', create=False, update=False), ) 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", "finished_date": { "display_name": "Actual Finish Date", "description": ("Make sure that 'Actual Finish Date' isn't set, " "if cycle task state is <'Assigned' / " "'In Progress' / 'Declined' / 'Deprecated'>. " "Type double dash '--' into " "'Actual Finish Date' cell to remove it.") }, "verified_date": { "display_name": "Actual Verified Date", "description": ("Make sure that 'Actual Verified Date' isn't set, " "if cycle task state is <'Assigned' / " "'In Progress' / 'Declined' / 'Deprecated' / " "'Finished'>. Type double dash '--' into " "'Actual Verified Date' cell to remove it.") }, "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.""" wfo_person_ids = {ur.person_id for ur in self.wfo_roles} assignees_ids = { p.id for p in self.get_persons_for_rolename("Task Assignees") } return login.get_current_user_id() in (wfo_person_ids | assignees_ids) @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("cycle").joinedload( "workflow").undefer_group("Workflow_complete"), ) def log_json(self): out_json = super(CycleTaskGroupObjectTask, self).log_json() out_json["folder"] = self.folder return out_json @classmethod def bulk_update(cls, src): """Update statuses for bunch of tasks in a bulk. Args: src: input json with next structure: [{"status": "Assigned", "id": 1}, {"status": "InProgress", "id": 2}] Returns: list of updated_instances """ new_prv_state_map = { cls.DEPRECATED: (cls.ASSIGNED, cls.IN_PROGRESS, cls.FINISHED, cls.VERIFIED, cls.DECLINED), cls.IN_PROGRESS: (cls.ASSIGNED, ), cls.FINISHED: (cls.IN_PROGRESS, cls.DECLINED), cls.VERIFIED: (cls.FINISHED, ), cls.DECLINED: (cls.FINISHED, ), cls.ASSIGNED: (), } uniq_states = set([item['state'] for item in src]) if len(list(uniq_states)) != 1: raise BadRequest("Request's JSON contains multiple statuses for " "CycleTasks") new_state = uniq_states.pop() LOGGER.info("Do bulk update CycleTasks with '%s' status", new_state) if new_state not in cls.VALID_STATES: raise BadRequest("Request's JSON contains invalid statuses for " "CycleTasks") prv_states = new_prv_state_map[new_state] all_ids = {item['id'] for item in src} # Eagerly loading is needed to get user permissions for CycleTask faster updatable_objects = cls.eager_query().filter( cls.id.in_(list(all_ids)), cls.status.in_(prv_states)) if new_state in (cls.VERIFIED, cls.DECLINED): updatable_objects = [ obj for obj in updatable_objects if obj.cycle.is_verification_needed ] # Bulk update works only on MyTasks page. Don't need to check for # WorkflowMembers' permissions here. User should update only his own tasks. updatable_objects = [ obj for obj in updatable_objects if obj.current_user_wfo_or_assignee() ] # Queries count is constant because we are using eager query for objects. for obj in updatable_objects: obj.status = new_state obj.modified_by_id = login.get_current_user_id() return updatable_objects
class CycleTaskGroup(roleable.Roleable, mixins.WithContact, wf_mixins.CycleTaskGroupRelatedStatusValidatedMixin, mixins.Slugged, mixins.Timeboxed, mixins.Described, mixins.Titled, base.ContextRBAC, mixins.Base, index_mixin.Indexed, db.Model): """Cycle Task Group model. """ __tablename__ = 'cycle_task_groups' _title_uniqueness = False @classmethod def generate_slug_prefix(cls): # pylint: disable=unused-argument return "CYCLEGROUP" cycle_id = db.Column( db.Integer, db.ForeignKey('cycles.id', ondelete="CASCADE"), nullable=False, ) task_group_id = db.Column(db.Integer, db.ForeignKey('task_groups.id'), nullable=True) cycle_task_group_tasks = db.relationship('CycleTaskGroupObjectTask', backref='cycle_task_group', cascade='all, delete-orphan') sort_index = db.Column(db.String(length=250), default="", nullable=False) next_due_date = db.Column(db.Date) _api_attrs = reflection.ApiAttributes('cycle', 'task_group', 'cycle_task_group_tasks', 'sort_index', 'next_due_date') _aliases = { "cycle": { "display_name": "Cycle", "filter_by": "_filter_by_cycle", }, } PROPERTY_TEMPLATE = u"group {}" _fulltext_attrs = [ attributes.DateFullTextAttr( "due date", 'next_due_date', ), attributes.FullTextAttr("assignee", "contact", ['email', 'name']), attributes.FullTextAttr("cycle title", 'cycle', ['title'], False), attributes.FullTextAttr("cycle assignee", lambda x: x.cycle.contact, ['email', 'name'], False), attributes.DateFullTextAttr("cycle due date", lambda x: x.cycle.next_due_date, with_template=False), ] @property def workflow(self): """Property which returns parent workflow object.""" return self.cycle.workflow @property def _task_assignees(self): """Property. Return the list of persons as assignee of related tasks.""" people = set() for ctask in self.cycle_task_group_tasks: people.update(ctask.get_persons_for_rolename("Task Assignees")) return list(people) @property def _task_secondary_assignees(self): """Property. Returns people list as Secondary Assignee of related tasks.""" people = set() for ctask in self.cycle_task_group_tasks: people.update( ctask.get_persons_for_rolename("Task Secondary Assignees")) return list(people) AUTO_REINDEX_RULES = [ index_mixin.ReindexRule("Person", _query_filtered_by_contact), index_mixin.ReindexRule( "Person", lambda x: [i.cycle for i in _query_filtered_by_contact(x)]), ] @classmethod def _filter_by_cycle(cls, predicate): """Get query that filters 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 task groups by related cycle. """ return Cycle.query.filter((Cycle.id == cls.cycle_id) & (predicate(Cycle.slug) | predicate(Cycle.title))).exists() @classmethod def indexed_query(cls): return super(CycleTaskGroup, cls).indexed_query().options( orm.Load(cls).load_only("next_due_date", ), orm.Load(cls).joinedload("cycle").load_only( "id", "title", "next_due_date"), orm.Load(cls).joinedload("cycle").joinedload("contact").load_only( "email", "name", "id"), orm.Load(cls).joinedload("contact").load_only( "email", "name", "id"), ) @classmethod def eager_query(cls): """Add cycle tasks and objects to cycle task group eager query. Make sure we load all cycle task group relevant data in a single query. Returns: a query object with cycle_task_group_tasks added to joined load options. """ query = super(CycleTaskGroup, cls).eager_query() return query.options(orm.joinedload('cycle_task_group_tasks'))
class Issue(Roleable, mixins.TestPlanned, mixins.CustomAttributable, PublicDocumentable, Personable, mixins.LastDeprecatedTimeboxed, Relatable, Commentable, AuditRelationship, WithAction, issue_tracker.IssueTrackedWithUrl, mixins.base.ContextRBAC, mixins.BusinessObject, mixins.Folderable, Indexed, db.Model): """Issue Model.""" __tablename__ = 'issues' FIXED = "Fixed" FIXED_AND_VERIFIED = "Fixed and Verified" VALID_STATES = mixins.BusinessObject.VALID_STATES + ( FIXED, FIXED_AND_VERIFIED, ) # REST properties _api_attrs = reflection.ApiAttributes( reflection.Attribute("audit", create=False, update=False), reflection.Attribute("allow_map_to_audit", create=False, update=False), reflection.Attribute("allow_unmap_from_audit", create=False, update=False), reflection.Attribute("due_date"), ) _aliases = { "due_date": { "display_name": "Due Date" }, "test_plan": { "display_name": "Remediation Plan" }, "issue_tracker": { "display_name": "Ticket Tracker", "view_only": True, "mandatory": False, }, "status": { "display_name": "State", "mandatory": False, "description": "Options are: \n{} ".format('\n'.join(VALID_STATES)) }, "audit": None, "documents_file": None, } _fulltext_attrs = [ attributes.DateFullTextAttr('due_date', 'due_date'), ] audit_id = deferred( db.Column(db.Integer, db.ForeignKey('audits.id'), nullable=True), 'Issue') due_date = db.Column(db.Date) @builder.simple_property def allow_map_to_audit(self): """False if self.audit or self.audit_id is set, True otherwise.""" return self.audit_id is None and self.audit is None @builder.simple_property def allow_unmap_from_audit(self): """False if Issue is mapped to any Assessment/Snapshot, True otherwise.""" from ggrc.models import all_models restricting_types = {all_models.Assessment, all_models.Snapshot} restricting_types = set(m.__name__.lower() for m in restricting_types) # pylint: disable=not-an-iterable restricting_srcs = (rel.source_type.lower() in restricting_types for rel in self.related_sources if rel not in db.session.deleted) restricting_dsts = (rel.destination_type.lower() in restricting_types for rel in self.related_destinations if rel not in db.session.deleted) return not any(itertools.chain(restricting_srcs, restricting_dsts)) def log_json(self): out_json = super(Issue, self).log_json() out_json["folder"] = self.folder return out_json @classmethod def _populate_query(cls, query): return query.options( orm.Load(cls).joinedload("audit").undefer_group("Audit_complete"), ) @classmethod def indexed_query(cls): return super(Issue, cls).indexed_query().options( orm.Load(cls).load_only("due_date"), ) @classmethod def eager_query(cls): return cls._populate_query(super(Issue, cls).eager_query())
class Cycle(roleable.Roleable, mixins.WithContact, wf_mixins.CycleStatusValidatedMixin, mixins.Timeboxed, mixins.Described, mixins.Titled, base.ContextRBAC, mixins.Slugged, mixins.Notifiable, ft_mixin.Indexed, db.Model): """Workflow Cycle model """ __tablename__ = 'cycles' _title_uniqueness = False workflow_id = db.Column( db.Integer, db.ForeignKey('workflows.id', ondelete="CASCADE"), nullable=False, ) cycle_task_groups = db.relationship('CycleTaskGroup', backref='cycle', cascade='all, delete-orphan') cycle_task_group_object_tasks = db.relationship( 'CycleTaskGroupObjectTask', backref='cycle', cascade='all, delete-orphan') cycle_task_entries = db.relationship('CycleTaskEntry', backref='cycle', cascade='all, delete-orphan') is_current = db.Column(db.Boolean, default=True, nullable=False) next_due_date = db.Column(db.Date) @property def is_done(self): """Check if cycle's done Overrides StatusValidatedMixin method because cycle's is_done state depends on is_verification_needed flag """ if super(Cycle, self).is_done: return True if self.cycle_task_group_object_tasks: return False return True @builder.simple_property def folder(self): """Get the workflow folder.""" if self.workflow: return self.workflow.folder return "" _api_attrs = reflection.ApiAttributes( 'workflow', 'cycle_task_groups', 'is_current', 'next_due_date', reflection.Attribute('folder', create=False, update=False), ) _aliases = { "cycle_workflow": { "display_name": "Workflow", "filter_by": "_filter_by_cycle_workflow", }, "contact": "Assignee", "secondary_contact": None, } PROPERTY_TEMPLATE = u"cycle {}" _fulltext_attrs = [ ft_attributes.DateFullTextAttr("due date", "next_due_date"), "folder", ] @property def _task_assignees(self): """Property. Return the list of persons as assignee of related tasks.""" people = set() for ctask in self.cycle_task_group_object_tasks: people.update(ctask.get_persons_for_rolename("Task Assignees")) return list(people) @property def _task_secondary_assignees(self): """Property. Returns people list as Secondary Assignee of related tasks.""" people = set() for ctask in self.cycle_task_group_object_tasks: people.update( ctask.get_persons_for_rolename("Task Secondary Assignees")) return list(people) AUTO_REINDEX_RULES = [ ft_mixin.ReindexRule("Person", _query_filtered_by_contact), ] @classmethod def _filter_by_cycle_workflow(cls, predicate): """Filter by cycle workflow.""" from ggrc_workflows.models.workflow import Workflow return Workflow.query.filter((Workflow.id == cls.workflow_id) & (predicate(Workflow.slug) | predicate(Workflow.title))).exists() @classmethod def eager_query(cls): """Add cycle task groups to cycle eager query This function adds cycle_task_groups as a join option when fetching cycles, and makes sure we fetch all cycle related data needed for generating cycle json, in one query. Returns: a query object with cycle_task_groups added to joined load options. """ query = super(Cycle, cls).eager_query() return query.options( orm.joinedload('cycle_task_groups'), orm.Load(cls).joinedload("workflow").undefer_group( "Workflow_complete"), ) @classmethod def indexed_query(cls): return super(Cycle, cls).indexed_query().options( orm.Load(cls).load_only("next_due_date"), orm.Load(cls).joinedload("workflow").undefer_group( "Workflow_complete"), ) def _get_cycle_url(self, widget_name): return urljoin( get_url_root(), "workflows/{workflow_id}#{widget_name}/cycle/{cycle_id}".format( workflow_id=self.workflow.id, cycle_id=self.id, widget_name=widget_name)) @property def cycle_url(self): return self._get_cycle_url("current") @property def cycle_inactive_url(self): return self._get_cycle_url("history") def log_json(self): out_json = super(Cycle, self).log_json() out_json["folder"] = self.folder return out_json
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(cls): 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 _api_attrs = reflection.ApiAttributes( 'cycle', 'cycle_task_group', 'task_group_task', 'cycle_task_entries', 'sort_index', 'task_type', 'response_options', 'selected_response_options', reflection.Attribute('object_approval', create=False, update=False), reflection.Attribute('finished_date', create=False, update=False), reflection.Attribute('verified_date', create=False, update=False), reflection.Attribute('allow_change_state', create=False, update=False), ) 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"), )
class Cycle(mixins.WithContact, wf_mixins.CycleStatusValidatedMixin, mixins.Timeboxed, mixins.Described, mixins.Titled, mixins.Slugged, mixins.Notifiable, ft_mixin.Indexed, db.Model): """Workflow Cycle model """ __tablename__ = 'cycles' _title_uniqueness = False workflow_id = db.Column( db.Integer, db.ForeignKey('workflows.id', ondelete="CASCADE"), nullable=False, ) cycle_task_groups = db.relationship( 'CycleTaskGroup', backref='cycle', cascade='all, delete-orphan') cycle_task_group_object_tasks = db.relationship( 'CycleTaskGroupObjectTask', backref='cycle', cascade='all, delete-orphan') cycle_task_entries = db.relationship( 'CycleTaskEntry', backref='cycle', cascade='all, delete-orphan') is_current = db.Column(db.Boolean, default=True, nullable=False) next_due_date = db.Column(db.Date) @property def is_done(self): """Check if cycle's done Overrides StatusValidatedMixin method because cycle's is_done state depends on is_verification_needed flag """ if super(Cycle, self).is_done: return True if self.cycle_task_group_object_tasks: return False return True _api_attrs = reflection.ApiAttributes( 'workflow', 'cycle_task_groups', 'is_current', 'next_due_date', ) _aliases = { "cycle_workflow": { "display_name": "Workflow", "filter_by": "_filter_by_cycle_workflow", }, "contact": "Assignee", "secondary_contact": None, } PROPERTY_TEMPLATE = u"cycle {}" _fulltext_attrs = [ ft_attributes.MultipleSubpropertyFullTextAttr( "group title", "cycle_task_groups", ["title"], False, ), ft_attributes.MultipleSubpropertyFullTextAttr( "group assignee", lambda instance: [g.contact for g in instance.cycle_task_groups], ["name", "email"], False, ), ft_attributes.DateMultipleSubpropertyFullTextAttr( "group due date", 'cycle_task_groups', ["next_due_date"], False, ), ft_attributes.MultipleSubpropertyFullTextAttr( "task title", 'cycle_task_group_object_tasks', ["title"], False, ), ft_attributes.MultipleSubpropertyFullTextAttr( "task assignee", lambda instance: [t.contact for t in instance.cycle_task_group_object_tasks], ["name", "email"], False ), ft_attributes.DateMultipleSubpropertyFullTextAttr( "task due date", "cycle_task_group_object_tasks", ["end_date"], False ), ft_attributes.DateFullTextAttr("due date", "next_due_date"), ft_attributes.MultipleSubpropertyFullTextAttr( "task comments", lambda instance: list(itertools.chain(*[ t.cycle_task_entries for t in instance.cycle_task_group_object_tasks ])), ["description"], False ), ] AUTO_REINDEX_RULES = [ ft_mixin.ReindexRule("CycleTaskGroup", lambda x: x.cycle), ft_mixin.ReindexRule("CycleTaskGroupObjectTask", lambda x: x.cycle_task_group.cycle), ft_mixin.ReindexRule("Person", _query_filtered_by_contact) ] @classmethod def _filter_by_cycle_workflow(cls, predicate): from ggrc_workflows.models.workflow import Workflow return Workflow.query.filter( (Workflow.id == cls.workflow_id) & (predicate(Workflow.slug) | predicate(Workflow.title)) ).exists() @classmethod def eager_query(cls): """Add cycle task groups to cycle eager query This function adds cycle_task_groups as a join option when fetching cycles, and makes sure we fetch all cycle related data needed for generating cycle json, in one query. Returns: a query object with cycle_task_groups added to joined load options. """ query = super(Cycle, cls).eager_query() return query.options( orm.joinedload('cycle_task_groups'), ) @classmethod def indexed_query(cls): return super(Cycle, cls).indexed_query().options( orm.Load(cls).load_only("next_due_date"), orm.Load(cls).subqueryload("cycle_task_group_object_tasks").load_only( "id", "title", "end_date" ), orm.Load(cls).subqueryload("cycle_task_groups").load_only( "id", "title", "end_date", "next_due_date", ), orm.Load(cls).subqueryload("cycle_task_group_object_tasks").joinedload( "contact" ).load_only( "email", "name", "id" ), orm.Load(cls).subqueryload("cycle_task_group_object_tasks").joinedload( "cycle_task_entries" ).load_only( "description", "id" ), orm.Load(cls).subqueryload("cycle_task_groups").joinedload( "contact" ).load_only( "email", "name", "id" ), orm.Load(cls).joinedload("contact").load_only( "email", "name", "id" ), )