class CycleTaskGroupObjectTask(WithContact, Stateful, Slugged, Timeboxed, Relatable, Notifiable, Described, Titled, Indexed, Base, db.Model): """Cycle task model """ __tablename__ = 'cycle_task_group_object_tasks' _title_uniqueness = False IMPORTABLE_FIELDS = ( 'slug', 'title', 'description', 'start_date', 'end_date', 'finished_date', 'verified_date', 'contact', ) @classmethod def generate_slug_prefix_for(cls, obj): return "CYCLETASK" VALID_STATES = (None, 'InProgress', 'Assigned', 'Finished', 'Declined', 'Verified') # Note: this statuses are used in utils/query_helpers to filter out the tasks # that should be visible on My Tasks pages. ACTIVE_STATES = ("Assigned", "InProgress", "Finished", "Declined") PROPERTY_TEMPLATE = u"task {}" _fulltext_attrs = [ DateFullTextAttr( "due date", 'end_date', ), FullTextAttr("assignee", 'contact', ['name', 'email']), FullTextAttr("group title", 'cycle_task_group', ['title'], False), FullTextAttr("cycle title", 'cycle', ['title'], False), FullTextAttr("group assignee", lambda x: x.cycle_task_group.contact, ['email', 'name'], False), FullTextAttr("cycle assignee", lambda x: x.cycle.contact, ['email', 'name'], False), DateFullTextAttr("group due date", lambda x: x.cycle_task_group.next_due_date, with_template=False), DateFullTextAttr("cycle due date", lambda x: x.cycle.next_due_date, with_template=False), MultipleSubpropertyFullTextAttr("comment", "cycle_task_entries", ["description"]), ] AUTO_REINDEX_RULES = [ 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(JsonType(), nullable=False, default=[]) selected_response_options = db.Column(JsonType(), nullable=False, default=[]) sort_index = db.Column(db.String(length=250), default="", nullable=False) finished_date = db.Column(db.DateTime) verified_date = db.Column(db.DateTime) object_approval = association_proxy('cycle', 'workflow.object_approval') object_approval.publish_raw = True @property def cycle_task_objects_for_cache(self): """Changing task state must invalidate `workflow_state` on objects """ return [(object_.__class__.__name__, object_.id) for object_ in self.related_objects] # pylint: disable=not-an-iterable _publish_attrs = [ 'cycle', 'cycle_task_group', 'task_group_task', 'cycle_task_entries', 'sort_index', 'task_type', 'response_options', 'selected_response_options', PublishOnly('object_approval'), PublishOnly('finished_date'), PublishOnly('verified_date') ] 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, "filter_by": "_filter_by_contact", }, "secondary_contact": None, "start_date": "Start Date", "end_date": "End Date", "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, }, "status": { "display_name": "State", "mandatory": False, "description": "Options are:\n{}".format('\n'.join( (item for item in VALID_STATES if item))) } } @computed_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 @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'), )
class AssessmentTemplate(assessment.AuditRelationship, relationship.Relatable, mixins.Titled, mixins.CustomAttributable, mixins.Slugged, db.Model): """A class representing the assessment template entity. An Assessment Template is a template that allows users for easier creation of multiple Assessments that are somewhat similar to each other, avoiding the need to repeatedly define the same set of properties for every new Assessment object. """ __tablename__ = "assessment_templates" _mandatory_default_people = ("assessors", "verifiers") PER_OBJECT_CUSTOM_ATTRIBUTABLE = True # the type of the object under assessment template_object_type = db.Column(db.String, nullable=True) # whether to use the control test plan as a procedure test_plan_procedure = db.Column(db.Boolean, nullable=False, default=False) # procedure description procedure_description = db.Column(db.Text, nullable=True) # the people that should be assigned by default to each assessment created # within the releated audit default_people = db.Column(JsonType, nullable=False) # labels to show to the user in the UI for various default people values DEFAULT_PEOPLE_LABELS = { "Object Owners": "Object Owners", "Audit Lead": "Audit Lead", "Auditors": "Auditors", "Primary Assessor": "Principal Assessor", "Secondary Assessors": "Secondary Assessors", "Primary Contact": "Primary Contact", "Secondary Contact": "Secondary Contact", } _title_uniqueness = False # REST properties _publish_attrs = [ "template_object_type", "test_plan_procedure", "procedure_description", "default_people", PublishOnly("DEFAULT_PEOPLE_LABELS") ] _aliases = { "default_assessors": { "display_name": "Default Assessors", "mandatory": True, "filter_by": "_nop_filter", }, "default_verifier": { "display_name": "Default Verifier", "mandatory": True, "filter_by": "_nop_filter", }, "default_test_plan": { "display_name": "Default Test Plan", "filter_by": "_nop_filter", }, "test_plan_procedure": { "display_name": "Use Control Test Plan", "mandatory": False, }, "template_object_type": { "display_name": "Object Under Assessment", "mandatory": True, }, "template_custom_attributes": { "display_name": "Custom Attributes", "type": AttributeInfo.Type.SPECIAL_MAPPING, "filter_by": "_nop_filter", "description": ("List of custom attributes for the assessment template\n" "One attribute per line. fields are separated by commas ','\n\n" "<attribute type>, <attribute name>, [<attribute value1>, " "<attribute value2>, ...]\n\n" "Valid attribute types: Text, Rich Text, Date, Checkbox, Person," "Dropdown.\n" "attribute name: Any single line string without commas. Leading " "and trailing spaces are ignored.\n" "list of attribute values: Comma separated list, only used if " "attribute type is 'Dropdown'. Prepend '(a)' if the value has a " "mandatory attachment and/or (c) if the value requires a " "mandatory comment.\n\n" "Limitations: Dropdown values can not start with either '(a)' or" "'(c)' and attribute names can not contain commas ','."), }, } @classmethod def _nop_filter(cls, _): """No operation filter. This is used for objects for which we can not implement a normal sql query filter. Example is default_verifier field that is a json string in the db and we can not create direct queries on json fields. """ return None @classmethod def generate_slug_prefix_for(cls, obj): return "TEMPLATE" def _clone(self): """Clone Assessment Template. Returns: Instance of assessment template copy. """ data = { "title": self.title, "template_object_type": self.template_object_type, "test_plan_procedure": self.test_plan_procedure, "procedure_description": self.procedure_description, "default_people": self.default_people, } assessment_template_copy = AssessmentTemplate(**data) db.session.add(assessment_template_copy) db.session.flush() return assessment_template_copy def clone(self, target): """Clone Assessment Template and related custom attributes.""" assessment_template_copy = self._clone() rel = relationship.Relationship(source=target, destination=assessment_template_copy) db.session.add(rel) db.session.flush() for cad in self.custom_attribute_definitions: # pylint: disable=protected-access cad._clone(assessment_template_copy) return (assessment_template_copy, rel) @validates('default_people') def validate_default_people(self, key, value): """Check that default people lists are not empty. Check if the default_people contains both assessors and verifiers. The values of those fields must be truthy, and if the value is a string it must be a valid default people label. If the value is not a string, it should be a list of valid user ids, but that is too expensive to test in this validator. """ # pylint: disable=unused-argument for mandatory in self._mandatory_default_people: mandatory_value = value.get(mandatory) if (not mandatory_value or isinstance(mandatory_value, list) and any(not isinstance(p_id, (int, long)) for p_id in mandatory_value) or isinstance(mandatory_value, basestring) and mandatory_value not in self.DEFAULT_PEOPLE_LABELS): raise ValidationError( 'Invalid value for default_people.{field}. Expected a people ' 'label in string or a list of int people ids, received {value}.' .format(field=mandatory, value=mandatory_value), ) return value
class Assessment(statusable.Statusable, AuditRelationship, AutoStatusChangeable, Assignable, HasObjectState, TestPlanned, CustomAttributable, EvidenceURL, Commentable, Personable, reminderable.Reminderable, Timeboxed, Relatable, WithSimilarityScore, FinishedDate, VerifiedDate, ValidateOnComplete, Notifiable, BusinessObject, db.Model): """Class representing Assessment. Assessment is an object representing an individual assessment performed on a specific object during an audit to ascertain whether or not certain conditions were met for that object. """ __tablename__ = 'assessments' _title_uniqueness = False ASSIGNEE_TYPES = (u"Creator", u"Assessor", u"Verifier") REMINDERABLE_HANDLERS = { "statusToPerson": { "handler": reminderable.Reminderable.handle_state_to_person_reminder, "data": { statusable.Statusable.START_STATE: "Assessor", "In Progress": "Assessor" }, "reminders": { "assessment_assessor_reminder", } } } design = deferred(db.Column(db.String), "Assessment") operationally = deferred(db.Column(db.String), "Assessment") @declared_attr def object_level_definitions(self): """Set up a backref so that we can create an object level custom attribute definition without the need to do a flush to get the assessment id. This is used in the relate_ca method in hooks/assessment.py. """ return db.relationship('CustomAttributeDefinition', primaryjoin=lambda: and_( remote(CustomAttributeDefinition. definition_id) == Assessment.id, remote(CustomAttributeDefinition. definition_type) == "assessment"), foreign_keys=[ CustomAttributeDefinition.definition_id, CustomAttributeDefinition.definition_type ], backref='assessment_definition', cascade='all, delete-orphan') object = {} # we add this for the sake of client side error checking audit = {} VALID_CONCLUSIONS = frozenset( ["Effective", "Ineffective", "Needs improvement", "Not Applicable"]) # REST properties _publish_attrs = [ 'design', 'operationally', PublishOnly('audit'), PublishOnly('object') ] _tracked_attrs = { 'contact_id', 'description', 'design', 'notes', 'operationally', 'reference_url', 'secondary_contact_id', 'test_plan', 'title', 'url', 'start_date', 'end_date' } _aliases = { "owners": None, "assessment_template": { "display_name": "Template", "ignore_on_update": True, "filter_by": "_ignore_filter", "type": reflection.AttributeInfo.Type.MAPPING, }, "url": "Assessment URL", "design": "Conclusion: Design", "operationally": "Conclusion: Operation", "related_creators": { "display_name": "Creator", "mandatory": True, "filter_by": "_filter_by_related_creators", "type": reflection.AttributeInfo.Type.MAPPING, }, "related_assessors": { "display_name": "Assignee", "mandatory": True, "filter_by": "_filter_by_related_assessors", "type": reflection.AttributeInfo.Type.MAPPING, }, "related_verifiers": { "display_name": "Verifier", "filter_by": "_filter_by_related_verifiers", "type": reflection.AttributeInfo.Type.MAPPING, }, } similarity_options = similarity_options_module.ASSESSMENT def validate_conclusion(self, value): return value if value in self.VALID_CONCLUSIONS else None @validates("operationally") def validate_opperationally(self, key, value): # pylint: disable=unused-argument return self.validate_conclusion(value) @validates("design") def validate_design(self, key, value): # pylint: disable=unused-argument return self.validate_conclusion(value) @classmethod def _filter_by_related_creators(cls, predicate): return cls._get_relate_filter(predicate, "Creator") @classmethod def _filter_by_related_assessors(cls, predicate): return cls._get_relate_filter(predicate, "Assessor") @classmethod def _filter_by_related_verifiers(cls, predicate): return cls._get_relate_filter(predicate, "Verifier") @classmethod def _ignore_filter(cls, _): return None
class Person(CustomAttributable, CustomAttributeMapable, HasOwnContext, Relatable, Base, Indexed, db.Model): __tablename__ = 'people' email = deferred(db.Column(db.String, nullable=False), 'Person') name = deferred(db.Column(db.String), 'Person') language_id = deferred(db.Column(db.Integer), 'Person') company = deferred(db.Column(db.String), 'Person') object_people = db.relationship( 'ObjectPerson', backref='person', cascade='all, delete-orphan') object_owners = db.relationship( 'ObjectOwner', backref='person', cascade='all, delete-orphan') access_control_list = db.relationship( 'AccessControlList', backref='person', cascade='all, delete-orphan') language = db.relationship( 'Option', primaryjoin='and_(foreign(Person.language_id) == Option.id, ' 'Option.role == "person_language")', uselist=False, ) @staticmethod def _extra_table_args(cls): return ( db.Index('ix_people_name_email', 'name', 'email'), db.Index('uq_people_email', 'email', unique=True), ) _fulltext_attrs = [ 'company', 'email', 'name', ] _publish_attrs = [ 'company', 'email', 'language', 'name', PublishOnly('object_people'), PublishOnly('system_wide_role'), ] _sanitize_html = [ 'company', 'name', ] _include_links = [] _aliases = { "name": "Name", "email": { "display_name": "Email", "unique": True, }, "company": "Company", "user_role": { "display_name": "Role", "type": "user_role", "filter_by": "_filter_by_user_role", }, } @classmethod def _filter_by_user_role(cls, predicate): from ggrc_basic_permissions.models import Role, UserRole return UserRole.query.join(Role).filter( (UserRole.person_id == cls.id) & (UserRole.context_id == None) & # noqa predicate(Role.name) ).exists() # Methods required by Flask-Login # pylint: disable=no-self-use def is_authenticated(self): return self.system_wide_role != 'No Access' @property def user_name(self): return self.email.split("@")[0] def is_active(self): # pylint: disable=no-self-use return True # self.active def is_anonymous(self): # pylint: disable=no-self-use return False def get_id(self): return unicode(self.id) # noqa @validates('language') def validate_person_options(self, key, option): return validate_option(self.__class__.__name__, key, option, 'person_language') @validates('email') def validate_email(self, key, email): if not Person.is_valid_email(email): message = "Must provide a valid email address" raise ValidationError(message) return email @staticmethod def is_valid_email(val): # Borrowed from Django # literal form, ipv4 address (SMTP 4.1.3) email_re = re.compile( '^[-!#$%&\'*+\\.\/0-9=?A-Z^_`{|}~]+@([-0-9A-Z]+\.)+([0-9A-Z]){2,4}$', re.IGNORECASE) return email_re.match(val) if val else False @classmethod def eager_query(cls): from sqlalchemy import orm # query = super(Person, cls).eager_query() # Completely overriding eager_query to avoid eager loading of the # modified_by relationship return super(Person, cls).eager_query().options( orm.joinedload('language'), orm.subqueryload('object_people'), ) @classmethod def indexed_query(cls): from sqlalchemy import orm return super(Person, cls).indexed_query().options( orm.Load(cls).undefer_group( "Person_complete", ), ) def _display_name(self): return self.email @builder.simple_property def system_wide_role(self): """For choosing the role string to show to the user; of all the roles in the system-wide context, it shows the highest ranked one (if there are multiple) or "No Access" if there are none. """ # FIXME: This method should be in `ggrc_basic_permissions`, since it # depends on `Role` and `UserRole` objects if self.email in getattr(settings, "BOOTSTRAP_ADMIN_USERS", []): return u"Superuser" role_hierarchy = { u'Administrator': 0, u'Editor': 1, u'Reader': 2, u'Creator': 3, } unique_roles = set([ user_role.role.name for user_role in self.user_roles if user_role.role.name in role_hierarchy ]) if len(unique_roles) == 0: return u"No Access" else: # -1 as default to make items not in this list appear on top # and thus shown to the user sorted_roles = sorted(unique_roles, key=lambda x: role_hierarchy.get(x, -1)) return sorted_roles[0]
class Audit(Snapshotable, clonable.Clonable, CustomAttributable, Personable, HasOwnContext, Relatable, Timeboxed, Noted, Described, Hyperlinked, WithContact, Titled, Slugged, 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') 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) # TODO: this should be stateful mixin status = deferred(db.Column(db.Enum(*VALID_STATES), nullable=False), 'Audit') gdrive_evidence_folder = deferred(db.Column(db.String), 'Audit') program_id = deferred( db.Column(db.Integer, db.ForeignKey('programs.id'), nullable=False), 'Audit') requests = db.relationship('Request', backref='audit', cascade='all, delete-orphan') audit_objects = db.relationship('AuditObject', backref='audit', cascade='all, delete-orphan') object_type = db.Column(db.String(length=250), nullable=False, default='Control') _publish_attrs = [ 'report_start_date', 'report_end_date', 'audit_firm', 'status', 'gdrive_evidence_folder', 'program', 'requests', 'object_type', PublishOnly('audit_objects') ] _sanitize_html = [ 'gdrive_evidence_folder', 'description', ] _include_links = [] _aliases = { "program": { "display_name": "Program", "filter_by": "_filter_by_program", "mandatory": True, }, "user_role:Auditor": { "display_name": "Auditors", "type": AttributeInfo.Type.USER_ROLE, "filter_by": "_filter_by_auditor", }, "status": "Status", "start_date": "Planned Start Date", "end_date": "Planned End Date", "report_start_date": "Planned Report Period from", "report_end_date": "Planned Report Period to", "contact": { "display_name": "Internal Audit Lead", "mandatory": True, "filter_by": "_filter_by_contact", }, "secondary_contact": None, "notes": None, "url": None, "reference_url": None, } 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, "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, "contact": source_object.contact } self.update_attrs(data) db.session.flush() create_audit_context(self) self._clone_auditors(source_object) self.clone_custom_attribute_values(source_object) def _clone_auditors(self, audit): """Clone auditors of specified audit. Args: audit: Audit instance """ from ggrc_basic_permissions.models import Role, UserRole role = Role.query.filter_by(name="Auditor").first() auditors = [ ur.person for ur in UserRole.query.filter_by(role=role, context=audit.context).all() ] for auditor in auditors: user_role = UserRole(context=self.context, person=auditor, role=role) db.session.add(user_role) db.session.flush() 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) @classmethod def _filter_by_program(cls, predicate): return Program.query.filter((Program.id == Audit.program_id) & (predicate(Program.slug) | predicate(Program.title))).exists() @classmethod def _filter_by_auditor(cls, predicate): from ggrc_basic_permissions.models import Role, UserRole return UserRole.query.join( Role, Person).filter((Role.name == "Auditor") & (UserRole.context_id == cls.context_id) & (predicate(Person.name) | predicate(Person.email))).exists() @classmethod def eager_query(cls): from sqlalchemy import orm query = super(Audit, cls).eager_query() return query.options( orm.joinedload('program'), orm.subqueryload('requests'), orm.subqueryload('object_people').joinedload('person'), orm.subqueryload('audit_objects'), )
class TaskGroup(WithContact, Timeboxed, Described, Titled, Slugged, db.Model): """Workflow TaskGroup model.""" __tablename__ = 'task_groups' _title_uniqueness = False workflow_id = db.Column( db.Integer, db.ForeignKey('workflows.id', ondelete="CASCADE"), nullable=False, ) lock_task_order = db.Column(db.Boolean(), nullable=True) task_group_objects = db.relationship('TaskGroupObject', backref='task_group', cascade='all, delete-orphan') objects = association_proxy('task_group_objects', 'object', 'TaskGroupObject') task_group_tasks = db.relationship('TaskGroupTask', backref='task_group', cascade='all, delete-orphan') cycle_task_groups = db.relationship('CycleTaskGroup', backref='task_group') sort_index = db.Column(db.String(length=250), default="", nullable=False) _publish_attrs = [ 'workflow', 'task_group_objects', PublishOnly('objects'), 'task_group_tasks', 'lock_task_order', 'sort_index', # Intentionally do not include `cycle_task_groups` # 'cycle_task_groups', ] _aliases = { "title": "Summary", "description": "Details", "contact": { "display_name": "Assignee", "mandatory": True, "filter_by": "_filter_by_contact", }, "secondary_contact": None, "start_date": None, "end_date": None, "workflow": { "display_name": "Workflow", "mandatory": True, "filter_by": "_filter_by_workflow", }, "task_group_objects": { "display_name": "Objects", "type": AttributeInfo.Type.SPECIAL_MAPPING, "filter_by": "_filter_by_objects", }, } def copy(self, _other=None, **kwargs): columns = [ 'title', 'description', 'workflow', 'sort_index', 'modified_by', 'context' ] if kwargs.get('clone_people', False) and getattr(self, "contact"): columns.append("contact") else: kwargs["contact"] = get_current_user() target = self.copy_into(_other, columns, **kwargs) if kwargs.get('clone_objects', False): self.copy_objects(target, **kwargs) if kwargs.get('clone_tasks', False): self.copy_tasks(target, **kwargs) return target def copy_objects(self, target, **kwargs): # pylint: disable=unused-argument for task_group_object in self.task_group_objects: target.task_group_objects.append( task_group_object.copy( task_group=target, context=target.context, )) return target def copy_tasks(self, target, **kwargs): for task_group_task in self.task_group_tasks: target.task_group_tasks.append( task_group_task.copy( None, task_group=target, context=target.context, clone_people=kwargs.get("clone_people", False), )) return target @classmethod def _filter_by_workflow(cls, predicate): from ggrc_workflows.models import Workflow return Workflow.query.filter((Workflow.id == cls.workflow_id) & (predicate(Workflow.slug) | predicate(Workflow.title))).exists() @classmethod def _filter_by_objects(cls, predicate): parts = [] for model_name in all_models.__all__: model = getattr(all_models, model_name) query = getattr(model, "query", None) field = getattr(model, "slug", getattr(model, "email", None)) if query is None or field is None or not hasattr(model, "id"): continue parts.append( query.filter((TaskGroupObject.object_type == model_name) & (model.id == TaskGroupObject.object_id) & predicate(field)).exists()) return TaskGroupObject.query.filter( (TaskGroupObject.task_group_id == cls.id) & or_(*parts)).exists()
class Assessment(statusable.Statusable, AuditRelationship, AutoStatusChangeable, Assignable, HasObjectState, TestPlanned, CustomAttributable, EvidenceURL, Commentable, Personable, reminderable.Reminderable, Timeboxed, Relatable, WithSimilarityScore, FinishedDate, VerifiedDate, ValidateOnComplete, BusinessObject, db.Model): """Class representing Assessment. Assessment is an object representing an individual assessment performed on a specific object during an audit to ascertain whether or not certain conditions were met for that object. """ __tablename__ = 'assessments' _title_uniqueness = False ASSIGNEE_TYPES = (u"Creator", u"Assessor", u"Verifier") REMINDERABLE_HANDLERS = { "statusToPerson": { "handler": reminderable.Reminderable.handle_state_to_person_reminder, "data": { statusable.Statusable.START_STATE: "Assessor", "In Progress": "Assessor" }, "reminders": { "assessment_assessor_reminder", } } } design = deferred(db.Column(db.String), "Assessment") operationally = deferred(db.Column(db.String), "Assessment") object = {} # we add this for the sake of client side error checking audit = {} VALID_CONCLUSIONS = frozenset( ["Effective", "Ineffective", "Needs improvement", "Not Applicable"]) # REST properties _publish_attrs = [ 'design', 'operationally', PublishOnly('audit'), PublishOnly('object') ] _tracked_attrs = { 'contact_id', 'description', 'design', 'notes', 'operationally', 'reference_url', 'secondary_contact_id', 'test_plan', 'title', 'url', 'start_date', 'end_date' } _aliases = { "owners": None, "assessment_object": { "display_name": "Object", "mandatory": True, "ignore_on_update": True, "filter_by": "_ignore_filter", "type": reflection.AttributeInfo.Type.MAPPING, "description": ("A single object that will be mapped to the audit.\n" "Example:\n\nControl: Control-slug-1\n" "Market : MARKET-55"), }, "assessment_template": { "display_name": "Template", "ignore_on_update": True, "filter_by": "_ignore_filter", "type": reflection.AttributeInfo.Type.MAPPING, }, "url": "Assessment URL", "design": "Conclusion: Design", "operationally": "Conclusion: Operation", "related_creators": { "display_name": "Creator", "mandatory": True, "filter_by": "_filter_by_related_creators", "type": reflection.AttributeInfo.Type.MAPPING, }, "related_assessors": { "display_name": "Assessor", "mandatory": True, "filter_by": "_filter_by_related_assessors", "type": reflection.AttributeInfo.Type.MAPPING, }, "related_verifiers": { "display_name": "Verifier", "filter_by": "_filter_by_related_verifiers", "type": reflection.AttributeInfo.Type.MAPPING, }, } similarity_options = similarity_options_module.ASSESSMENT def validate_conclusion(self, value): return value if value in self.VALID_CONCLUSIONS else "" @validates("operationally") def validate_opperationally(self, key, value): # pylint: disable=unused-argument return self.validate_conclusion(value) @validates("design") def validate_design(self, key, value): # pylint: disable=unused-argument return self.validate_conclusion(value) @classmethod def _filter_by_related_creators(cls, predicate): return cls._get_relate_filter(predicate, "Creator") @classmethod def _filter_by_related_assessors(cls, predicate): return cls._get_relate_filter(predicate, "Assessor") @classmethod def _filter_by_related_verifiers(cls, predicate): return cls._get_relate_filter(predicate, "Verifier") @classmethod def _ignore_filter(cls, predicate): return None
class CustomAttributeValue(Base, Indexed, db.Model): """Custom attribute value model""" __tablename__ = 'custom_attribute_values' _publish_attrs = [ 'custom_attribute_id', 'attributable_id', 'attributable_type', 'attribute_value', 'attribute_object', PublishOnly('preconditions_failed'), ] _fulltext_attrs = ["attribute_value"] _sanitize_html = [ "attribute_value", ] custom_attribute_id = db.Column( db.Integer, db.ForeignKey('custom_attribute_definitions.id', ondelete="CASCADE") ) attributable_id = db.Column(db.Integer) attributable_type = db.Column(db.String) attribute_value = db.Column(db.String) # When the attibute is of a mapping type this will hold the id of the mapped # object while attribute_value will hold the type name. # For example an instance of attribute type Map:Person will have a person id # in attribute_object_id and string 'Person' in attribute_value. attribute_object_id = db.Column(db.Integer) # pylint: disable=protected-access # This is just a mapping for accessing local functions so protected access # warning is a false positive _validator_map = { "Date": lambda self: self._validate_date(), "Dropdown": lambda self: self._validate_dropdown(), "Map:Person": lambda self: self._validate_map_person(), } @property def latest_revision(self): """Latest revision of CAV (used for comment precondition check).""" # TODO: make eager_query fetch only the first Revision return self._related_revisions[0] def delere_record(self): get_indexer().delete_record(self.attributable_id, self.attributable_type, False) def get_reindex_pair(self): return (self.attributable_type, self.attributable_id) @declared_attr def _related_revisions(self): def join_function(): """Function to join CAV to its latest revision.""" resource_id = foreign(Revision.resource_id) resource_type = foreign(Revision.resource_type) return and_(resource_id == self.id, resource_type == "CustomAttributeValue") return db.relationship( Revision, primaryjoin=join_function, viewonly=True, order_by=Revision.created_at.desc(), ) @classmethod def eager_query(cls): query = super(CustomAttributeValue, cls).eager_query() query = query.options( orm.subqueryload('_related_revisions'), orm.joinedload('custom_attribute'), ) return query @property def attributable_attr(self): return '{0}_custom_attributable'.format(self.attributable_type) @property def attributable(self): return getattr(self, self.attributable_attr) @attributable.setter def attributable(self, value): self.attributable_id = value.id if value is not None else None self.attributable_type = value.__class__.__name__ if value is not None \ else None return setattr(self, self.attributable_attr, value) @property def attribute_object(self): """Fetch the object referred to by attribute_object_id. Use backrefs defined in CustomAttributeMapable. Returns: A model instance of type specified in attribute_value """ return getattr(self, self._attribute_object_attr) @attribute_object.setter def attribute_object(self, value): """Set attribute_object_id via whole object. Args: value: model instance """ if value is None: # We get here if "attribute_object" does not get resolved. # TODO: make sure None value can be set for removing CA attribute object # value return self.attribute_object_id = value.id return setattr(self, self._attribute_object_attr, value) @property def attribute_object_type(self): """Fetch the mapped object pointed to by attribute_object_id. Returns: A model of type referenced in attribute_value """ attr_type = self.custom_attribute.attribute_type if not attr_type.startswith("Map:"): return None return self.attribute_object.__class__.__name__ @property def _attribute_object_attr(self): """Compute the relationship property based on object type. Returns: Property name """ attr_type = self.custom_attribute.attribute_type if not attr_type.startswith("Map:"): return None return 'attribute_{0}'.format(self.attribute_value) @classmethod def mk_filter_by_custom(cls, obj_class, custom_attribute_id): """Get filter for custom attributable object. This returns an exists filter for the given predicate, matching it to either a custom attribute value, or a value of the matched object. Args: obj_class: Class of the attributable object. custom_attribute_id: Id of the attribute definition. Returns: A function that will generate a filter for a given predicate. """ from ggrc.models import all_models attr_def = all_models.CustomAttributeDefinition.query.filter_by( id=custom_attribute_id ).first() if attr_def and attr_def.attribute_type.startswith("Map:"): map_type = attr_def.attribute_type[4:] map_class = getattr(all_models, map_type, None) if map_class: fields = [getattr(map_class, name, None) for name in ["email", "title", "slug"]] fields = [field for field in fields if field is not None] def filter_by_mapping(predicate): return cls.query.filter( (cls.custom_attribute_id == custom_attribute_id) & (cls.attributable_type == obj_class.__name__) & (cls.attributable_id == obj_class.id) & (map_class.query.filter( (map_class.id == cls.attribute_object_id) & or_(*[predicate(f) for f in fields])).exists()) ).exists() return filter_by_mapping def filter_by_custom(predicate): return cls.query.filter( (cls.custom_attribute_id == custom_attribute_id) & (cls.attributable_type == obj_class.__name__) & (cls.attributable_id == obj_class.id) & predicate(cls.attribute_value) ).exists() return filter_by_custom def _clone(self, obj): """Clone a custom value to a new object.""" data = { "custom_attribute_id": self.custom_attribute_id, "attributable_id": obj.id, "attributable_type": self.attributable_type, "attribute_value": self.attribute_value, "attribute_object_id": self.attribute_object_id } ca_value = CustomAttributeValue(**data) db.session.add(ca_value) db.session.flush() return ca_value @staticmethod def _extra_table_args(_): return ( db.UniqueConstraint('attributable_id', 'custom_attribute_id'), ) def _validate_map_person(self): """Validate and correct mapped person values Mapped person custom attribute is only valid if both attribute_value and attribute_object_id are set. To keep the custom attribute api consistent with other types, we allow setting the value to a string containing both in this way "attribute_value:attribute_object_id". This validator checks Both scenarios and changes the string value to proper values needed by this custom attribute. Note: this validator does not check if id is a proper person id. """ if self.attribute_value and ":" in self.attribute_value: value, id_ = self.attribute_value.split(":") self.attribute_value = value self.attribute_object_id = id_ def _validate_dropdown(self): """Validate dropdown opiton.""" valid_options = set(self.custom_attribute.multi_choice_options.split(",")) if self.attribute_value: self.attribute_value = self.attribute_value.strip() if self.attribute_value not in valid_options: raise ValueError("Invalid custom attribute dropdown option: {v}, " "expected one of {l}" .format(v=self.attribute_value, l=valid_options)) def _validate_date(self): """Convert date format.""" if self.attribute_value: # Validate the date format by trying to parse it self.attribute_value = utils.convert_date_format( self.attribute_value, utils.DATE_FORMAT_ISO, utils.DATE_FORMAT_ISO, ) def validate(self): """Validate custom attribute value.""" # pylint: disable=protected-access attributable_type = self.attributable._inflector.table_singular if not self.custom_attribute: raise ValueError("Custom attribute definition not found: Can not " "validate custom attribute value") if self.custom_attribute.definition_type != attributable_type: raise ValueError("Invalid custom attribute definition used.") validator = self._validator_map.get(self.custom_attribute.attribute_type) if validator: validator(self) @computed_property def is_empty(self): """Return True if the CAV is empty or holds a logically empty value.""" # The CAV is considered empty when: # - the value is empty if not self.attribute_value: return True # - the type is Checkbox and the value is 0 if (self.custom_attribute.attribute_type == self.custom_attribute.ValidTypes.CHECKBOX and str(self.attribute_value) == "0"): return True # - the type is a mapping and the object value id is empty if (self.attribute_object_type is not None and not self.attribute_object_id): return True # Otherwise it the CAV is not empty return False @computed_property def preconditions_failed(self): """A list of requirements self introduces that are unsatisfied. Returns: [str] - a list of unsatisfied requirements; possible items are: "value" - missing mandatory value, "comment" - missing mandatory comment, "evidence" - missing mandatory evidence. """ failed_preconditions = [] if self.custom_attribute.mandatory and self.is_empty: failed_preconditions += ["value"] if (self.custom_attribute.attribute_type == self.custom_attribute.ValidTypes.DROPDOWN): failed_preconditions += self._check_dropdown_requirements() return failed_preconditions or None def _check_dropdown_requirements(self): """Check mandatory comment and mandatory evidence for dropdown CAV.""" failed_preconditions = [] options_to_flags = self._multi_choice_options_to_flags( self.custom_attribute, ) flags = options_to_flags.get(self.attribute_value) if flags: if flags.comment_required: failed_preconditions += self._check_mandatory_comment() if flags.evidence_required: failed_preconditions += self._check_mandatory_evidence() return failed_preconditions def _check_mandatory_comment(self): """Check presence of mandatory comment.""" if hasattr(self.attributable, "comments"): comment_found = any( self.custom_attribute_id == (comment .custom_attribute_definition_id) and self.latest_revision.id == comment.revision_id for comment in self.attributable.comments ) else: comment_found = False if not comment_found: return ["comment"] else: return [] def _check_mandatory_evidence(self): """Check presence of mandatory evidence.""" if hasattr(self.attributable, "object_documents"): # Note: this is a suboptimal implementation of mandatory evidence check; # it should be refactored once Evicence-CA mapping is introduced def evidence_required(cav): """Return True if an evidence is required for this `cav`.""" flags = (self._multi_choice_options_to_flags(cav.custom_attribute) .get(cav.attribute_value)) return flags and flags.evidence_required evidence_found = (len(self.attributable.object_documents) >= len([cav for cav in self.attributable .custom_attribute_values if evidence_required(cav)])) else: evidence_found = False if not evidence_found: return ["evidence"] else: return [] @staticmethod def _multi_choice_options_to_flags(cad): """Parse mandatory comment and evidence flags from dropdown CA definition. Args: cad - a CA definition object Returns: {option_value: Flags} - a dict from dropdown options values to Flags objects where Flags.comment_required and Flags.evidence_required correspond to the values from multi_choice_mandatory bitmasks """ flags = namedtuple("Flags", ["comment_required", "evidence_required"]) def make_flags(multi_choice_mandatory): flags_mask = int(multi_choice_mandatory) return flags(comment_required=flags_mask & (cad .MultiChoiceMandatoryFlags .COMMENT_REQUIRED), evidence_required=flags_mask & (cad .MultiChoiceMandatoryFlags .EVIDENCE_REQUIRED)) if not cad.multi_choice_options or not cad.multi_choice_mandatory: return {} else: return dict(zip( cad.multi_choice_options.split(","), (make_flags(mask) for mask in cad.multi_choice_mandatory.split(",")), ))