class Audit(Snapshotable, clonable.SingleClonable, PublicDocumentable, mixins.CustomAttributable, Personable, HasOwnContext, Relatable, Roleable, issuetracker_issue.IssueTracked, WithLastDeprecatedDate, mixins.Timeboxed, mixins.BusinessObject, mixins.Folderable, Indexed, db.Model): """Audit model.""" __tablename__ = 'audits' _slug_uniqueness = False VALID_STATES = (u'Planned', u'In Progress', u'Manager Review', u'Ready for External Review', u'Completed', u'Deprecated') CLONEABLE_CHILDREN = {"AssessmentTemplate"} report_start_date = deferred(db.Column(db.Date), 'Audit') report_end_date = deferred(db.Column(db.Date), 'Audit') audit_firm_id = deferred( db.Column(db.Integer, db.ForeignKey('org_groups.id')), 'Audit') audit_firm = db.relationship('OrgGroup', uselist=False) gdrive_evidence_folder = deferred(db.Column(db.String), 'Audit') program_id = deferred( db.Column(db.Integer, db.ForeignKey('programs.id'), nullable=False), 'Audit') audit_objects = db.relationship('AuditObject', backref='audit', cascade='all, delete-orphan') object_type = db.Column(db.String(length=250), nullable=False, default='Control') assessments = db.relationship('Assessment', backref='audit') issues = db.relationship('Issue', backref='audit') archived = deferred(db.Column(db.Boolean, nullable=False, default=False), 'Audit') assessment_templates = db.relationship('AssessmentTemplate', backref='audit') _api_attrs = reflection.ApiAttributes( 'report_start_date', 'report_end_date', 'audit_firm', 'gdrive_evidence_folder', 'program', 'object_type', 'archived', reflection.Attribute('issue_tracker', create=False, update=False), reflection.Attribute('audit_objects', create=False, update=False), ) _fulltext_attrs = [ 'archived', 'report_start_date', 'report_end_date', 'audit_firm', 'gdrive_evidence_folder', ] @classmethod def indexed_query(cls): return super(Audit, cls).indexed_query().options( orm.Load(cls).undefer_group("Audit_complete", ), ) _sanitize_html = [ 'gdrive_evidence_folder', 'description', ] _include_links = [] _aliases = { "program": { "display_name": "Program", "filter_by": "_filter_by_program", "mandatory": True, }, "start_date": "Planned Start Date", "end_date": "Planned End Date", "report_start_date": "Planned Report Period from", "report_end_date": "Planned Report Period to", "notes": None, "reference_url": None, "archived": { "display_name": "Archived", "mandatory": False }, "status": { "display_name": "State", "mandatory": True, "description": "Options are:\n{}".format('\n'.join(VALID_STATES)) } } @simple_property def issue_tracker(self): """Returns representation of issue tracker related info as a dict.""" issue_obj = issuetracker_issue.IssuetrackerIssue.get_issue( 'Audit', self.id) return issue_obj.to_dict() if issue_obj is not None else {} def _clone(self, source_object): """Clone audit and all relevant attributes. Keeps the internals of actual audit cloning and everything that is related to audit itself (auditors, audit firm, context setting, custom attribute values, etc.) """ from ggrc_basic_permissions import create_audit_context data = { "title": source_object.generate_attribute("title"), "description": source_object.description, "audit_firm": source_object.audit_firm, "start_date": source_object.start_date, "end_date": source_object.end_date, "last_deprecated_date": source_object.last_deprecated_date, "program": source_object.program, "status": source_object.VALID_STATES[0], "report_start_date": source_object.report_start_date, "report_end_date": source_object.report_end_date } self.update_attrs(data) db.session.flush() create_audit_context(self) self.clone_acls(source_object) self.clone_custom_attribute_values(source_object) def clone_acls(self, audit): """Clone acl roles like auditors and audit captains Args: audit: Audit instance """ for acl in audit.access_control_list: data = { "person": acl.person, "ac_role": acl.ac_role, "object": self, "context": acl.context, } new_acl = AccessControlList(**data) db.session.add(new_acl) def clone(self, source_id, mapped_objects=None): """Clone audit with specified whitelisted children. Children that can be cloned should be specified in CLONEABLE_CHILDREN. Args: mapped_objects: A list of related objects that should also be copied and linked to a new audit. """ if not mapped_objects: mapped_objects = [] source_object = Audit.query.get(source_id) self._clone(source_object) if any(mapped_objects): related_children = source_object.related_objects(mapped_objects) for obj in related_children: obj.clone(self) @orm.validates("archived") def archived_check(self, _, value): """Only Admins and Program Managers are allowed to (un)archive Audit.""" user = get_current_user() if getattr(user, 'system_wide_role', None) in SystemWideRoles.admins: return value if self.archived is not None and self.archived != value and \ not any(acl for acl in list(self.program.access_control_list) if acl.ac_role.name == "Program Managers" and acl.person.id == user.id): raise Forbidden() return value @classmethod def _filter_by_program(cls, predicate): """Helper for filtering by program""" return Program.query.filter((Program.id == Audit.program_id) & (predicate(Program.slug) | predicate(Program.title))).exists() @classmethod def eager_query(cls): query = super(Audit, cls).eager_query() return query.options( orm.joinedload('program'), orm.subqueryload('object_people').joinedload('person'), orm.subqueryload('audit_objects'), )
class CycleTaskGroupObjectTask(roleable.Roleable, wf_mixins.CycleTaskStatusValidatedMixin, 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"]), "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) @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): 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()] _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), ) 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.joinedload('cycle').joinedload('workflow').undefer_group( 'Workflow_complete'), orm.joinedload('cycle_task_entries'), ) @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": "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 CustomAttributeDefinition(attributevalidator.AttributeValidator, base.ContextRBAC, mixins.Base, mixins.Titled, db.Model): """Custom attribute definition model. Attributes: multi_choice_mandatory: comma separated values of mandatory bitmaps. First lsb is for comment, second bit is for attachement. """ __tablename__ = 'custom_attribute_definitions' definition_type = db.Column(db.String, nullable=False) definition_id = db.Column(db.Integer) attribute_type = db.Column(db.String, nullable=False) multi_choice_options = db.Column(db.String) multi_choice_mandatory = db.Column(db.String) mandatory = db.Column(db.Boolean) helptext = db.Column(db.String) placeholder = db.Column(db.String) attribute_values = db.relationship('CustomAttributeValue', backref='custom_attribute', cascade='all, delete-orphan') @property def definition_attr(self): return '{0}_definition'.format(self.definition_type) @property def definition(self): return getattr(self, self.definition_attr) @property def value_mapping(self): return self.ValidTypes.DEFAULT_VALUE_MAPPING.get( self.attribute_type) or {} @classmethod def get_default_value_for(cls, attribute_type): return cls.ValidTypes.DEFAULT_VALUE.get(attribute_type) @builder.simple_property def default_value(self): return self.get_default_value_for(self.attribute_type) def get_indexed_value(self, value): return self.value_mapping.get(value, value) @definition.setter def definition(self, value): self.definition_id = getattr(value, 'id', None) if hasattr(value, '_inflector'): self.definition_type = value._inflector.table_singular else: self.definition_type = '' return setattr(self, self.definition_attr, value) _extra_table_args = (UniqueConstraint('definition_type', 'definition_id', 'title', name='uq_custom_attribute'), db.Index('ix_custom_attributes_title', 'title')) _include_links = [ 'definition_type', 'definition_id', 'attribute_type', 'multi_choice_options', 'multi_choice_mandatory', 'mandatory', 'helptext', 'placeholder', ] _api_attrs = reflection.ApiAttributes( reflection.Attribute("default_value", read=True, create=False, update=False), *_include_links) _sanitize_html = [ "multi_choice_options", "helptext", "placeholder", ] _reserved_names = {} def _clone(self, target): """Clone custom attribute definitions.""" data = { "title": self.title, "definition_type": self.definition_type, "definition_id": target.id, "context": target.context, "attribute_type": self.attribute_type, "multi_choice_options": self.multi_choice_options, "multi_choice_mandatory": self.multi_choice_mandatory, "mandatory": self.mandatory, "helptext": self.helptext, "placeholder": self.placeholder, } ca_definition = CustomAttributeDefinition(**data) db.session.add(ca_definition) return ca_definition class ValidTypes(object): """Class representing valid custom attribute definitions. Basically an enum, therefore no need for public methods. """ # pylint: disable=too-few-public-methods TEXT = "Text" RICH_TEXT = "Rich Text" DROPDOWN = "Dropdown" CHECKBOX = "Checkbox" DATE = "Date" MAP = "Map" DEFAULT_VALUE = { CHECKBOX: "0", RICH_TEXT: "", TEXT: "", DROPDOWN: "", DATE: "" } DEFAULT_VALUE_MAPPING = { CHECKBOX: { True: "Yes", False: "No", "0": "No", "1": "Yes", }, } class MultiChoiceMandatoryFlags(object): """Enum representing flags in multi_choice_mandatory bitmaps.""" # pylint: disable=too-few-public-methods COMMENT_REQUIRED = 0b001 EVIDENCE_REQUIRED = 0b010 URL_REQUIRED = 0b100 VALID_TYPES = { "Text": "Text", "Rich Text": "Rich Text", "Dropdown": "Dropdown", "Checkbox": "Checkbox", "Date": "Date", "Person": "Map:Person", } @validates("attribute_type") def validate_attribute_type(self, _, value): """Check that provided attribute_type is allowed.""" if value not in self.VALID_TYPES.values(): raise ValidationError("Invalid attribute_type: got {v}, " "expected one of {l}".format( v=value, l=list(self.VALID_TYPES.values()))) return value @validates("multi_choice_options") def validate_multi_choice_options(self, _, value): """Strip spaces around options in dropdown options.""" # pylint: disable=no-self-use # TODO: this should be "if value is not None" to disallow value == "" if value: value_list = [part.strip() for part in value.split(",")] value_set = set(value_list) if len(value_set) != len(value_list): raise ValidationError( "Duplicate dropdown options are not allowed: " "'{}'".format(value)) if "" in value_set: raise ValidationError( "Empty dropdown options are not allowed: '{}'".format( value)) value = ",".join(value_list) return value @validates("multi_choice_mandatory") def validate_multi_choice_mandatory(self, _, value): """Strip spaces around bitmas in dropdown options.""" # pylint: disable=no-self-use if value: value = ",".join(part.strip() for part in value.split(",")) return value def validate_assessment_title(self, name): """Check assessment title uniqueness. Assessment CAD should not match any name from assessment_template. Currently assessment templates do not have global custom attributes, but in the future global CAD on assessment templates could be applied to all generated assessments. That's why we do not differentiate between global and local CAD here. Args: name: Assessment custom attribute definition title. Raises: ValueError if name is an invalid CAD title. """ if self.definition_id: # Local Assessment CAD can match local and global Assessment Template # CAD. # NOTE: This is not the best way of checking if the current CAD is local, # since it relies on the fact that if definition_id will be set, it will # be set along with definition_type. If we manually set definition_type # then title then definition_id, this check would fail. return if not getattr(flask.g, "template_cad_names", set()): query = db.session.query(self.__class__.title).filter( self.__class__.definition_type == "assessment_template") flask.g.template_cad_names = {cad.title.lower() for cad in query} if name in flask.g.template_cad_names: raise ValueError( u"Local custom attribute '{}' " u"already exists for this object type.".format(name)) @validates("title", "definition_type") def validate_title(self, key, value): """Validate CAD title/name uniqueness. Note: title field is used for storing CAD names. CAD names need to follow 4 uniqueness rules: 1) Names must not match any attribute name on any existing object. 2) Object level CAD names must not match any global CAD name. 3) Object level CAD names can clash, but not for the same Object instance. This means we can have two CAD with a name "my cad", with different attributable_id fields. 4) Names must not match any existing custom attribute role name Third rule is handled by the database with unique key uq_custom_attribute (`definition_type`,`definition_id`,`title`). This validator should check for name collisions for 1st and 2nd rule. This validator works, because definition_type is never changed. It only gets set when the cad is created and after that only title filed can change. This makes validation using both fields possible. Args: value: custom attribute definition name Returns: value if the name passes all uniqueness checks. """ if key == "title" and self.definition_type: name = value.lower() definition_type = self.definition_type elif key == "definition_type" and self.title: name = self.title.lower() definition_type = value.lower() else: return value if name in self._get_reserved_names(definition_type): raise ReservedNameError( u"Attribute '{}' is reserved for this object type.".format( name)) if (self._get_global_cad_names(definition_type).get(name) is not None and self._get_global_cad_names(definition_type).get(name) != self.id): raise ValueError( u"Global custom attribute '{}' " u"already exists for this object type".format(name)) model_name = get_inflector_model_name_dict()[definition_type] acrs = { i.lower() for i in acr.get_custom_roles_for(model_name).values() } if name in acrs: raise ValueError( u"Custom Role with a name of '{}' " u"already exists for this object type".format(name)) if definition_type == "assessment": self.validate_assessment_title(name) return value def log_json(self): """Add extra fields to be logged in CADs.""" results = super(CustomAttributeDefinition, self).log_json() results["default_value"] = self.default_value return results