diff --git a/api_v1/tests/test_api.py b/api_v1/tests/test_api.py index 9aeba3a68..683e41be5 100644 --- a/api_v1/tests/test_api.py +++ b/api_v1/tests/test_api.py @@ -19,6 +19,8 @@ from group.models import Group from job.models import Job, JobOperation from role.models import Role +from trigger import tasks as trigger_tasks +from trigger.models import TriggerCondition from user.models import User @@ -754,11 +756,25 @@ def test_get_entry(self): self.assertEqual(len(results[0]["attrs"]), entry.attrs.count()) self.assertEqual( [x for x in results[0]["attrs"] if x["name"] == "group"], - [{"name": "group", "value": "group1"}], + [ + { + "id": entry.attrs.get(schema__name="group").id, + "schema_id": entry.attrs.get(schema__name="group").schema.id, + "name": "group", + "value": "group1", + } + ], ) self.assertEqual( [x for x in results[0]["attrs"] if x["name"] == "groups"], - [{"name": "groups", "value": ["group1", "group2"]}], + [ + { + "id": entry.attrs.get(schema__name="groups").id, + "schema_id": entry.attrs.get(schema__name="groups").schema.id, + "name": "groups", + "value": ["group1", "group2"], + } + ], ) # the case to specify only 'entry' parameter @@ -1277,3 +1293,109 @@ def test_update_entry_that_has_deleted_attribute(self): } resp = self.client.post("/api/v1/entry", json.dumps(params), "application/json") self.assertEqual(resp.status_code, 200) + + @mock.patch( + "trigger.tasks.may_invoke_trigger.delay", + mock.Mock(side_effect=trigger_tasks.may_invoke_trigger), + ) + def test_create_entry_when_trigger_is_set(self): + user = self.guest_login() + + # Initialize Entity, Entries and TriggerConditoin + ref_entity = Entity.objects.create(name="Referred Entity", created_user=user) + ref_entry = Entry.objects.create(name="ref0", schema=ref_entity, created_user=user) + params = self.ALL_TYPED_ATTR_PARAMS_FOR_CREATING_ENTITY.copy() + for param in params: + if param["type"] & AttrTypeValue["object"]: + param["ref"] = ref_entity + + entity = self.create_entity(**{"user": user, "name": "Entity", "attrs": params}) + TriggerCondition.register( + entity, + [ + {"attr_id": entity.attrs.get(name="val").id, "cond": "hoge"}, + {"attr_id": entity.attrs.get(name="ref").id, "cond": ref_entry.id}, + {"attr_id": entity.attrs.get(name="bool").id, "cond": True}, + {"attr_id": entity.attrs.get(name="vals").id, "cond": "hoge"}, + {"attr_id": entity.attrs.get(name="text").id, "cond": "hoge"}, + {"attr_id": entity.attrs.get(name="refs").id, "cond": ref_entry.id}, + ], + [{"attr_id": entity.attrs.get(name="vals").id, "values": ["fuga", "piyo"]}], + ) + + # send a request to create an Entry with value that invoke TriggerAction + params = { + "name": "entry1", + "entity": entity.name, + "attrs": { + "val": "hoge", + "ref": "ref0", + "bool": True, + "vals": ["hoge", "fuga"], + "text": "hoge", + "refs": ["ref0"], + }, + } + resp = self.client.post("/api/v1/entry", json.dumps(params), "application/json") + self.assertEqual(resp.status_code, 200) + + # check trigger action was worked properly + job_query = Job.objects.filter(operation=JobOperation.MAY_INVOKE_TRIGGER.value) + self.assertEqual(job_query.count(), 1) + self.assertEqual(job_query.first().status, Job.STATUS["DONE"]) + + # check created Entry has expected value that is set by TriggerAction + entry = Entry.objects.get(id=resp.json()["result"]) + self.assertEqual(entry.name, "entry1") + self.assertEqual(entry.get_attrv("val").value, "hoge") + self.assertEqual( + [x.referral.id for x in entry.get_attrv("refs").data_array.all()], [ref_entry.id] + ) + self.assertEqual( + [x.value for x in entry.get_attrv("vals").data_array.all()], ["fuga", "piyo"] + ) + + @mock.patch( + "trigger.tasks.may_invoke_trigger.delay", + mock.Mock(side_effect=trigger_tasks.may_invoke_trigger), + ) + def test_update_entry_when_trigger_is_set(self): + user = self.guest_login() + + # Initialize Entity, Entry and TriggerConditoin + entity = self.create_entity( + **{ + "user": user, + "name": "Entity", + "attrs": self.ALL_TYPED_ATTR_PARAMS_FOR_CREATING_ENTITY, + } + ) + entry = self.add_entry(user, "entry", entity) + TriggerCondition.register( + entity, + [{"attr_id": entity.attrs.get(name="val").id, "cond": "hoge"}], + [{"attr_id": entity.attrs.get(name="vals").id, "values": ["fuga", "piyo"]}], + ) + + # send a request to edit created Entry with value toinvoke TriggerAction + params = { + "id": entry.id, + "name": "Changing Entry name", + "entity": entity.name, + "attrs": { + "val": "hoge", + }, + } + resp = self.client.post("/api/v1/entry", json.dumps(params), "application/json") + self.assertEqual(resp.status_code, 200) + + # check trigger action was worked properly + job_query = Job.objects.filter(operation=JobOperation.MAY_INVOKE_TRIGGER.value) + self.assertEqual(job_query.count(), 1) + self.assertEqual(job_query.first().status, Job.STATUS["DONE"]) + + # check created Entry has expected value that is set by TriggerAction + self.assertEqual(entry.get_attrv("val").value, "hoge") + self.assertEqual( + [x.value for x in entry.get_attrv("vals").data_array.all()], ["fuga", "piyo"] + ) diff --git a/api_v1/views.py b/api_v1/views.py index f0b29930e..70ad4666d 100644 --- a/api_v1/views.py +++ b/api_v1/views.py @@ -7,6 +7,7 @@ import custom_view from airone.lib.acl import ACLType +from airone.lib.types import AttrTypeValue from entity.models import Entity from entry.models import Entry from entry.settings import CONFIG as ENTRY_CONFIG @@ -16,6 +17,41 @@ class EntryAPI(APIView): + def _be_compatible_with_trigger(self, entry, request_params): + entry_dict = entry.to_dict(self.request.user, with_metainfo=True) + + def _get_value(attrname, attrtype, value): + if isinstance(value, list): + return [_get_value(attrname, attrtype, x) for x in value] + + elif attrtype & AttrTypeValue["named"]: + [co_value] = list(value.values()) + + return co_value["id"] if co_value else None + + elif attrtype & AttrTypeValue["object"]: + return value["id"] if value else None + + else: + return value + + trigger_params = [] + for attrname in request_params["attrs"].keys(): + try: + [(entity_attr_id, attrtype, attrvalue)] = [ + (x["schema_id"], x["value"]["type"], x["value"]["value"]) + for x in entry_dict["attrs"] + if x["name"] == attrname + ] + except ValueError: + continue + + trigger_params.append( + {"id": entity_attr_id, "value": _get_value(attrname, attrtype, attrvalue)} + ) + + return trigger_params + def post(self, request, format=None): sel = PostEntrySerializer(data=request.data) @@ -135,6 +171,12 @@ def _update_entry_name(entry): # register target Entry to the Elasticsearch entry.register_es() + # Create job for TriggerAction. Before calling, it's necessary to make parameters to pass + # to TriggerAction from raw_request_data by _be_compatible_with_trigger() method. + Job.new_invoke_trigger( + request.user, entry, self._be_compatible_with_trigger(entry, raw_request_data) + ).run() + entry.del_status(Entry.STATUS_CREATING | Entry.STATUS_EDITING) return Response(dict({"result": entry.id}, **resp_data)) diff --git a/entry/models.py b/entry/models.py index b50286484..5d18521c3 100644 --- a/entry/models.py +++ b/entry/models.py @@ -1628,6 +1628,8 @@ def to_dict(self, user, with_metainfo=False): if attrv is None: returning_attrs.append( { + "id": attr.id, + "schema_id": attr.schema.id, "name": attr.schema.name, "value": AttributeValue.get_default_value(attr), } @@ -1636,6 +1638,8 @@ def to_dict(self, user, with_metainfo=False): else: returning_attrs.append( { + "id": attr.id, + "schema_id": attr.schema.id, "name": attr.schema.name, "value": attrv.get_value(serialize=True, with_metainfo=with_metainfo), } diff --git a/entry/tests/test_model.py b/entry/tests/test_model.py index bd5309cca..68b4d4851 100644 --- a/entry/tests/test_model.py +++ b/entry/tests/test_model.py @@ -3773,10 +3773,11 @@ def test_to_dict_entry_with_metainfo_param(self): }, ] for info in expected_attrinfos: - self.assertEqual( - [x["value"] for x in ret_dict["attrs"] if x["name"] == info["name"]], - [info["value"]], - ) + ret_attr_infos = [x for x in ret_dict["attrs"] if x["name"] == info["name"]] + self.assertEqual(len(ret_attr_infos), 1) + self.assertEqual(ret_attr_infos[0]["value"], info["value"]) + self.assertIn("id", ret_attr_infos[0]) + self.assertIn("schema_id", ret_attr_infos[0]) def test_to_dict_entry_for_checking_permission(self): admin_user = User.objects.create(username="admin", is_superuser=True) @@ -3829,7 +3830,12 @@ def test_to_dict_entry_for_checking_permission(self): "name": entries[2].name, "entity": {"id": public_entity.id, "name": public_entity.name}, "attrs": [ - {"name": "attr1", "value": "hoge"}, + { + "id": entries[2].attrs.get(schema__name="attr1").id, + "schema_id": entries[2].attrs.get(schema__name="attr1").schema.id, + "name": "attr1", + "value": "hoge", + }, ], }, ) diff --git a/entry/tests/test_view.py b/entry/tests/test_view.py index 7e300c4f7..fdc0748ca 100644 --- a/entry/tests/test_view.py +++ b/entry/tests/test_view.py @@ -31,6 +31,8 @@ from group.models import Group from job.models import Job, JobOperation from role.models import Role +from trigger import tasks as trigger_tasks +from trigger.models import TriggerCondition from user.models import User @@ -355,10 +357,20 @@ def test_post_create_entry(self): # checks created jobs and its params are as expected jobs = Job.objects.filter(user=user, target=entry) job_expectations = [ - {"operation": JobOperation.CREATE_ENTRY, "status": Job.STATUS["DONE"]}, + { + "operation": JobOperation.CREATE_ENTRY, + "status": Job.STATUS["DONE"], + "dependent_job": None, + }, { "operation": JobOperation.NOTIFY_CREATE_ENTRY, "status": Job.STATUS["DONE"], + "dependent_job": None, + }, + { + "operation": JobOperation.MAY_INVOKE_TRIGGER, + "status": Job.STATUS["PREPARING"], + "dependent_job": jobs.get(operation=JobOperation.CREATE_ENTRY.value), }, ] self.assertEqual(jobs.count(), len(job_expectations)) @@ -367,7 +379,7 @@ def test_post_create_entry(self): self.assertEqual(obj.target.id, entry.id) self.assertEqual(obj.target_type, Job.TARGET_ENTRY) self.assertEqual(obj.status, expectation["status"]) - self.assertIsNone(obj.dependent_job) + self.assertEqual(obj.dependent_job, expectation["dependent_job"]) # checks specify part of attribute parameter then set AttributeValue # which is only specified one @@ -885,14 +897,25 @@ def test_post_edit_with_valid_param(self): # checks created jobs and its params are as expected jobs = Job.objects.filter(user=user, target=entry) job_expectations = [ - {"operation": JobOperation.EDIT_ENTRY, "status": Job.STATUS["DONE"]}, + { + "operation": JobOperation.EDIT_ENTRY, + "status": Job.STATUS["DONE"], + "dependent_job": None, + }, { "operation": JobOperation.REGISTER_REFERRALS, "status": Job.STATUS["PREPARING"], + "dependent_job": None, }, { "operation": JobOperation.NOTIFY_UPDATE_ENTRY, "status": Job.STATUS["DONE"], + "dependent_job": None, + }, + { + "operation": JobOperation.MAY_INVOKE_TRIGGER, + "status": Job.STATUS["PREPARING"], + "dependent_job": jobs.get(operation=JobOperation.EDIT_ENTRY.value), }, ] self.assertEqual(jobs.count(), len(job_expectations)) @@ -901,7 +924,7 @@ def test_post_edit_with_valid_param(self): self.assertEqual(obj.target.id, entry.id) self.assertEqual(obj.target_type, Job.TARGET_ENTRY) self.assertEqual(obj.status, expectation["status"]) - self.assertIsNone(obj.dependent_job) + self.assertEqual(obj.dependent_job, expectation["dependent_job"]) # checks specify part of attribute parameter then set AttributeValue # which is only specified one @@ -1902,6 +1925,62 @@ def test_create_entry_with_role_attributes(self): self.assertFalse(entry.attrs.get(schema__name="Role").is_updated(role)) self.assertFalse(entry.attrs.get(schema__name="RoleArray").is_updated([role])) + @patch( + "entry.tasks.create_entry_attrs.delay", + Mock(side_effect=tasks.create_entry_attrs), + ) + @patch( + "trigger.tasks.may_invoke_trigger.delay", + Mock(side_effect=trigger_tasks.may_invoke_trigger), + ) + def test_create_entry_with_trigger_configuration(self): + user = self.guest_login() + + # initialize Entity which has Role related Attributes + entity = self.create_entity( + user, + "Personal Information", + attrs=[ + {"name": "address", "type": AttrTypeValue["string"]}, + {"name": "age", "type": AttrTypeValue["string"]}, + ], + ) + + # register TriggerAction configuration before creating an Entry + TriggerCondition.register( + entity, + [{"attr_id": entity.attrs.get(name="age").id, "cond": "0"}], + [{"attr_id": entity.attrs.get(name="address").id, "value": "Tokyo"}], + ) + + # create an Entry to invoke TriggerAction + params = { + "entry_name": "Jhon Doe", + "attrs": [ + { + "id": str(entity.attrs.get(name="age").id), + "type": str(AttrTypeValue["string"]), + "value": [{"data": "0", "index": 0}], + "referral_key": [], + } + ], + } + resp = self.client.post( + reverse("entry:do_create", args=[entity.id]), + json.dumps(params), + "application/json", + ) + + # check trigger action was worked properly + job_query = Job.objects.filter(operation=JobOperation.MAY_INVOKE_TRIGGER.value) + self.assertEqual(job_query.count(), 1) + self.assertEqual(job_query.first().status, Job.STATUS["DONE"]) + + # check created Entry's attributes are set properly by TriggerAction + entry = Entry.objects.get(id=resp.json().get("entry_id")) + self.assertEqual(entry.get_attrv("age").value, "0") + self.assertEqual(entry.get_attrv("address").value, "Tokyo") + @patch("entry.tasks.edit_entry_attrs.delay", Mock(side_effect=tasks.edit_entry_attrs)) def test_edit_entry_with_role_attributes(self): user = self.guest_login() @@ -1948,6 +2027,60 @@ def test_edit_entry_with_role_attributes(self): self.assertFalse(updatedEntry.attrs.get(schema__name="Role").is_updated(role)) self.assertFalse(updatedEntry.attrs.get(schema__name="RoleArray").is_updated([role])) + @patch("entry.tasks.edit_entry_attrs.delay", Mock(side_effect=tasks.edit_entry_attrs)) + @patch( + "trigger.tasks.may_invoke_trigger.delay", + Mock(side_effect=trigger_tasks.may_invoke_trigger), + ) + def test_edit_entry_with_trigger_configuration(self): + user = self.guest_login() + + # initialize Entity and Entry which will be updated by TriggerAction + entity = self.create_entity( + user, + "Personal Information", + attrs=[ + {"name": "age", "type": AttrTypeValue["string"]}, + {"name": "address", "type": AttrTypeValue["string"]}, + ], + ) + entry = self.add_entry(user, "Jhon Doe", entity) + + # register TriggerAction configuration before creating an Entry + TriggerCondition.register( + entity, + [{"attr_id": entity.attrs.get(name="age").id, "cond": "0"}], + [{"attr_id": entity.attrs.get(name="address").id, "value": "Tokyo"}], + ) + + # send request for editing Entry to invoke TriggerAction + sending_params = { + "entry_name": "entry", + "attrs": [ + { + "entity_attr_id": str(entity.attrs.get(name="age").id), + "id": str(entry.attrs.get(schema__name="age").id), + "value": [{"data": "0", "index": 0}], + } + ], + } + resp = self.client.post( + reverse("entry:do_edit", args=[entry.id]), + json.dumps(sending_params), + "application/json", + ) + self.assertEqual(resp.status_code, 200) + + # check trigger action was worked properly + job_query = Job.objects.filter(operation=JobOperation.MAY_INVOKE_TRIGGER.value) + self.assertEqual(job_query.count(), 1) + self.assertEqual(job_query.first().status, Job.STATUS["DONE"]) + + # check updated Entry's attributes are set properly by TriggerAction + self.assertEqual(resp.json().get("entry_id"), entry.id) + self.assertEqual(entry.get_attrv("age").value, "0") + self.assertEqual(entry.get_attrv("address").value, "Tokyo") + @patch( "entry.tasks.create_entry_attrs.delay", Mock(side_effect=tasks.create_entry_attrs), @@ -5144,10 +5277,20 @@ def test_run_create_entry_task_duplicately(self): # checks created jobs and its params are as expected jobs = Job.objects.filter(user=user, target=entry) job_expectations = [ - {"operation": JobOperation.CREATE_ENTRY, "status": Job.STATUS["DONE"]}, + { + "operation": JobOperation.CREATE_ENTRY, + "status": Job.STATUS["DONE"], + "dependent_job": None, + }, { "operation": JobOperation.NOTIFY_CREATE_ENTRY, "status": Job.STATUS["PREPARING"], + "dependent_job": None, + }, + { + "operation": JobOperation.MAY_INVOKE_TRIGGER, + "status": Job.STATUS["PREPARING"], + "dependent_job": jobs.get(operation=JobOperation.CREATE_ENTRY.value), }, ] self.assertEqual(jobs.count(), len(job_expectations)) @@ -5156,7 +5299,7 @@ def test_run_create_entry_task_duplicately(self): self.assertEqual(obj.target.id, entry.id) self.assertEqual(obj.target_type, Job.TARGET_ENTRY) self.assertEqual(obj.status, expectation["status"]) - self.assertIsNone(obj.dependent_job) + self.assertEqual(obj.dependent_job, expectation["dependent_job"]) # Rerun creating that entry job (This is core processing of this test) job_create = Job.objects.get(user=user, operation=JobOperation.CREATE_ENTRY.value) diff --git a/entry/views.py b/entry/views.py index 75195b290..dc5f325ed 100644 --- a/entry/views.py +++ b/entry/views.py @@ -251,6 +251,9 @@ def do_create(request, entity_id, recv_data): job_create_entry = Job.new_create(request.user, entry, params=recv_data) job_create_entry.run() + # Create job for TriggerAction + Job.new_invoke_trigger(request.user, entry, recv_data.get("attrs", []), job_create_entry).run() + return JsonResponse( { "entry_id": entry.id, @@ -355,6 +358,9 @@ def do_edit(request, entry_id, recv_data): job_edit_entry = Job.new_edit(request.user, entry, params=recv_data) job_edit_entry.run() + # Create job for TriggerAction + Job.new_invoke_trigger(request.user, entry, recv_data.get("attrs", []), job_edit_entry).run() + # running job of re-register referrals because of chaning entry's name if job_register_referrals: job_register_referrals.dependent_job = job_edit_entry diff --git a/job/models.py b/job/models.py index e88e54345..aa849fe81 100644 --- a/job/models.py +++ b/job/models.py @@ -262,7 +262,7 @@ def run(self, will_delay=True): return method(self.id) @classmethod - def _create_new_job(kls, user, target, operation, text, params) -> "Job": + def _create_new_job(kls, user, target, operation, text, params, depend_on=None) -> "Job": t_type = kls.TARGET_UNKNOWN if isinstance(target, Entry): t_type = kls.TARGET_ENTRY @@ -281,6 +281,9 @@ def _create_new_job(kls, user, target, operation, text, params) -> "Job": .last() ) + if dependent_job is None and depend_on is not None: + dependent_job = depend_on + params = { "user": user, "target": target, @@ -527,9 +530,14 @@ def new_notify_delete_entry(kls, user, target, text="", params={}): ) @classmethod - def new_invoke_trigger(kls, user, target_entry, recv_attrs={}): + def new_invoke_trigger(kls, user, target_entry, recv_attrs={}, dependent_job=None): return kls._create_new_job( - user, target_entry, JobOperation.MAY_INVOKE_TRIGGER.value, "", json.dumps(recv_attrs) + user, + target_entry, + JobOperation.MAY_INVOKE_TRIGGER.value, + "", + json.dumps(recv_attrs), + dependent_job, ) def set_cache(self, value): diff --git a/trigger/models.py b/trigger/models.py index e08e01279..6cc4d2604 100644 --- a/trigger/models.py +++ b/trigger/models.py @@ -324,13 +324,35 @@ def _do_check_condition(input: InputTriggerCondition): return any([_do_check_condition(input) for input in input_list]) - def is_match_condition(self, recv_value) -> bool: + def is_match_condition(self, raw_recv_value) -> bool: """ This checks specified value, which is compatible with APIv2 standard, matches with this condition. """ + def _compatible_with_apiv1(recv_value): + """ + This method retrieve value from recv_value that is specified by user. This processing + is necessary to compatible with both API versions (v1 and v2) + """ + if isinstance(recv_value, list) and all( + [isinstance(x, dict) and "data" in x for x in recv_value] + ): + # In this case, the recv_value is compatible with APIv1 standard + # it's necessary to convert it to APIv2 standard + if self.attr.type & AttrTypeValue["array"]: + return [x["data"] for x in recv_value] + else: + return recv_value[0]["data"] + + else: + # In this case, the recv_value is compatible with APIv2 standard + # and this method designed for it. + return recv_value + # This is a helper method when AttrType is "object" or "named_object" + recv_value = _compatible_with_apiv1(raw_recv_value) + def _is_match_object(val): if isinstance(val, int) or isinstance(val, str): if self.ref_cond and self.ref_cond.is_active: @@ -399,10 +421,20 @@ def register(cls, entity: Entity, conditions: list, actions: list) -> TriggerPar @classmethod def get_invoked_actions(cls, entity: Entity, recv_data: list): + # The APIv1 and APIv2 format is different. + # In the APIv2, the "id" parameter in the recv_data variable means EntityAttr ID. + # But in the APIv1, the "id" parameter in the recv_data variable means Attribute ID + # of Entry. So, it's necessary to refer "entity_attr_id" parameter to be compatible + # with both API versions. + if all(["entity_attr_id" in x for x in recv_data]): + # This is for APIv1 + params = [{"attr_id": int(x["entity_attr_id"]), "value": x["value"]} for x in recv_data] + else: + # This is for APIv2 + params = [{"attr_id": int(x["id"]), "value": x["value"]} for x in recv_data] + actions = [] for parent_condition in TriggerParent.objects.filter(entity=entity): - actions += parent_condition.get_actions( - [{"attr_id": int(x["id"]), "value": x["value"]} for x in recv_data] - ) + actions += parent_condition.get_actions(params) return actions