Skip to content

Commit 706c88b

Browse files
Merge pull request #1094 from syucream/feature/api-v2-tasks
Support celery tasks on APIv2 to process heavy operations smoothly
2 parents 924aac9 + 7f0b406 commit 706c88b

File tree

16 files changed

+517
-236
lines changed

16 files changed

+517
-236
lines changed

acl/tests/test_api_v2.py

+9
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import json
22

3+
from mock import mock
4+
35
from acl.models import ACLBase
46
from airone.lib.acl import ACLType
57
from airone.lib.test import AironeViewTest
68
from airone.lib.types import AttrTypeValue
9+
from entity import tasks
710
from entity.models import Entity, EntityAttr
811
from role.models import Role
912

@@ -474,6 +477,9 @@ def _put_acl():
474477
self.assertEqual(resp.status_code, 200)
475478
self.assertTrue(role.is_permitted(acl, ACLType.Full))
476479

480+
@mock.patch(
481+
"entity.tasks.create_entity_v2.delay", mock.Mock(side_effect=tasks.create_entity_v2)
482+
)
477483
def test_list_history(self):
478484
self.initialization_for_retrieve_test()
479485
self.client.post("/entity/api/v2/", json.dumps({"name": "test"}), "application/json")
@@ -594,6 +600,9 @@ def test_list_history_with_role(self):
594600
],
595601
)
596602

603+
@mock.patch(
604+
"entity.tasks.create_entity_v2.delay", mock.Mock(side_effect=tasks.create_entity_v2)
605+
)
597606
def test_list_history_with_entity_attr(self):
598607
self.initialization_for_retrieve_test()
599608

airone/lib/drf.py

+15
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import yaml
44
from django.conf import settings
5+
from rest_framework import serializers
56
from rest_framework.exceptions import APIException, ParseError, ValidationError
67
from rest_framework.parsers import BaseParser
78
from rest_framework.renderers import BaseRenderer
@@ -150,3 +151,17 @@ def _convert_error_code(detail):
150151
response.data = _convert_error_code(response.data)
151152

152153
return response
154+
155+
156+
class AironeUserDefault(serializers.CurrentUserDefault):
157+
"""
158+
It enables to get user from the custom field in the context.
159+
The original CurrentUserDefault fetches it from request context,
160+
so it fails if the context doesn't have request.
161+
"""
162+
163+
def __call__(self, serializer_field):
164+
if "_user" in serializer_field.context:
165+
return serializer_field.context["_user"]
166+
167+
return super().__call__(serializer_field)

airone/lib/job.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ def wrapper(kls, job_id):
1111
# update Job status from PREPARING to PROCEEDING
1212
job.update(Job.STATUS["PROCESSING"])
1313

14-
# running Job processing
15-
ret = func(kls, job)
14+
try:
15+
# running Job processing
16+
ret: int | tuple = func(kls, job)
17+
except Exception:
18+
ret = Job.STATUS["ERROR"]
1619

1720
# update Job status after finishing Job processing
1821
if isinstance(ret, int):

apiclient/typescript-fetch/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@dmm-com/airone-apiclient-typescript-fetch",
3-
"version": "0.0.9",
3+
"version": "0.0.10",
44
"description": "AirOne APIv2 client in TypeScript",
55
"main": "src/autogenerated/index.ts",
66
"scripts": {

entity/api_v2/serializers.py

+22-7
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from rest_framework.exceptions import PermissionDenied, ValidationError
1212

1313
import custom_view
14+
from airone.lib import drf
1415
from airone.lib.acl import ACLType
1516
from airone.lib.drf import DuplicatedObjectExistsError, ObjectNotExistsError, RequiredParameterError
1617
from airone.lib.log import Logger
@@ -69,7 +70,7 @@ def validate(self, webhook):
6970

7071

7172
class EntityAttrCreateSerializer(serializers.ModelSerializer):
72-
created_user = serializers.HiddenField(default=serializers.CurrentUserDefault())
73+
created_user = serializers.HiddenField(default=drf.AironeUserDefault())
7374

7475
class Meta:
7576
model = EntityAttr
@@ -141,7 +142,7 @@ def validate(self, attr):
141142
if "type" in attr and attr["type"] != entity_attr.type:
142143
raise ValidationError("type cannot be changed")
143144

144-
user: User = self.context["request"].user
145+
user: User = self.context.get("_user") or self.context["request"].user
145146
if attr["is_deleted"] and not user.has_permission(entity_attr, ACLType.Full):
146147
raise PermissionDenied("Does not have permission to delete")
147148
if not attr["is_deleted"] and not user.has_permission(entity_attr, ACLType.Writable):
@@ -204,7 +205,7 @@ def _update_or_create(
204205

205206
entity: Entity
206207
entity, is_created_entity = Entity.objects.get_or_create(
207-
id=entity_id, defaults={**validated_data}
208+
id=entity_id, created_user=user, defaults={**validated_data}
208209
)
209210
if not is_created_entity:
210211
# record history for specific fields on update
@@ -324,11 +325,10 @@ class EntityCreateSerializer(EntitySerializer):
324325
child=EntityAttrCreateSerializer(), write_only=True, required=False, default=[]
325326
)
326327
webhooks = WebhookCreateUpdateSerializer(many=True, write_only=True, required=False, default=[])
327-
created_user = serializers.HiddenField(default=serializers.CurrentUserDefault())
328328

329329
class Meta:
330330
model = Entity
331-
fields = ["id", "name", "note", "is_toplevel", "attrs", "webhooks", "created_user"]
331+
fields = ["id", "name", "note", "is_toplevel", "attrs", "webhooks"]
332332
extra_kwargs = {"note": {"write_only": True}}
333333

334334
def validate_name(self, name):
@@ -353,8 +353,16 @@ def validate_webhooks(self, webhooks: list[WebhookCreateUpdateSerializer]):
353353
return webhooks
354354

355355
def create(self, validated_data: EntityCreateData):
356-
user: User = self.context["request"].user
356+
user: User | None = None
357+
if "request" in self.context:
358+
user = self.context["request"].user
359+
if "_user" in self.context:
360+
user = self.context["_user"]
361+
362+
if user is None:
363+
raise RequiredParameterError("user is required")
357364

365+
validated_data["created_user"] = user
358366
if custom_view.is_custom("before_create_entity_V2"):
359367
validated_data = custom_view.call_custom(
360368
"before_create_entity_v2", None, user, validated_data
@@ -415,7 +423,14 @@ def validate_webhooks(self, webhooks: list[WebhookCreateUpdateSerializer]):
415423
return webhooks
416424

417425
def update(self, entity: Entity, validated_data: EntityUpdateData):
418-
user: User = self.context["request"].user
426+
user: User | None = None
427+
if "request" in self.context:
428+
user = self.context["request"].user
429+
if "_user" in self.context:
430+
user = self.context["_user"]
431+
432+
if user is None:
433+
raise RequiredParameterError("user is required")
419434

420435
if custom_view.is_custom("before_update_entity_v2"):
421436
validated_data = custom_view.call_custom(

entity/api_v2/views.py

+45-23
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
from rest_framework.response import Response
1414
from rest_framework.serializers import Serializer
1515

16-
import custom_view
1716
from airone.lib.acl import ACLType, get_permitted_objects
1817
from airone.lib.drf import ObjectNotExistsError, YAMLParser, YAMLRenderer
1918
from airone.lib.http import http_get
@@ -29,6 +28,7 @@
2928
from entity.models import Entity, EntityAttr
3029
from entry.api_v2.serializers import EntryBaseSerializer, EntryCreateSerializer
3130
from entry.models import Entry
31+
from job.models import Job
3232
from user.models import History, User
3333

3434

@@ -110,8 +110,8 @@ class EntityAPI(viewsets.ModelViewSet):
110110
def get_serializer_class(self):
111111
serializer = {
112112
"list": EntityListSerializer,
113-
"create": EntityCreateSerializer,
114-
"update": EntityUpdateSerializer,
113+
"create": serializers.Serializer,
114+
"update": serializers.Serializer,
115115
}
116116
return serializer.get(self.action, EntityDetailSerializer)
117117

@@ -129,9 +129,36 @@ def get_queryset(self):
129129

130130
return Entity.objects.filter(**filter_condition).exclude(**exclude_condition)
131131

132-
def destroy(self, request, pk):
132+
@extend_schema(request=EntityCreateSerializer)
133+
def create(self, request, *args, **kwargs):
134+
user: User = request.user
135+
136+
serializer = EntityCreateSerializer(data=request.data, context={"_user": user})
137+
serializer.is_valid(raise_exception=True)
138+
139+
job = Job.new_create_entity_v2(user, None, params=request.data)
140+
job.run()
141+
142+
return Response(status=status.HTTP_202_ACCEPTED)
143+
144+
@extend_schema(request=EntityUpdateSerializer)
145+
def update(self, request, *args, **kwargs):
146+
user: User = request.user
133147
entity: Entity = self.get_object()
148+
149+
serializer = EntityUpdateSerializer(
150+
instance=entity, data=request.data, context={"_user": user}
151+
)
152+
serializer.is_valid(raise_exception=True)
153+
154+
job = Job.new_edit_entity_v2(user, entity, params=request.data)
155+
job.run()
156+
157+
return Response(status=status.HTTP_202_ACCEPTED)
158+
159+
def destroy(self, request, *args, **kwargs):
134160
user: User = request.user
161+
entity: Entity = self.get_object()
135162

136163
if not entity.is_active:
137164
raise ObjectNotExistsError("specified entity has already been deleted")
@@ -141,24 +168,10 @@ def destroy(self, request, pk):
141168
"cannot delete Entity because one or more Entries are not deleted"
142169
)
143170

144-
if custom_view.is_custom("before_delete_entity_v2"):
145-
custom_view.call_custom("before_delete_entity_v2", None, user, entity)
146-
147-
# register operation History for deleting entity
148-
history: History = user.seth_entity_del(entity)
149-
150-
entity.delete()
151-
152-
# Delete all attributes which target Entity have
153-
entity_attr: EntityAttr
154-
for entity_attr in entity.attrs.filter(is_active=True):
155-
history.del_attr(entity_attr)
156-
entity_attr.delete()
157-
158-
if custom_view.is_custom("after_delete_entity_v2"):
159-
custom_view.call_custom("after_delete_entity_v2", None, user, entity)
171+
job = Job.new_delete_entity_v2(user, entity, params=request.data)
172+
job.run()
160173

161-
return Response(status=status.HTTP_204_NO_CONTENT)
174+
return Response(status=status.HTTP_202_ACCEPTED)
162175

163176

164177
class EntityEntryAPI(viewsets.ModelViewSet):
@@ -172,7 +185,7 @@ class EntityEntryAPI(viewsets.ModelViewSet):
172185

173186
def get_serializer_class(self):
174187
serializer = {
175-
"create": EntryCreateSerializer,
188+
"create": serializers.Serializer,
176189
}
177190
return serializer.get(self.action, EntryBaseSerializer)
178191

@@ -182,9 +195,18 @@ def get_queryset(self):
182195
raise Http404
183196
return self.queryset.filter(schema=entity)
184197

198+
@extend_schema(request=EntryCreateSerializer)
185199
def create(self, request, entity_id):
200+
user: User = request.user
186201
request.data["schema"] = entity_id
187-
return super().create(request)
202+
203+
serializer = EntryCreateSerializer(data=request.data, context={"_user": user})
204+
serializer.is_valid(raise_exception=True)
205+
206+
job = Job.new_create_entry_v2(user, None, params=request.data)
207+
job.run()
208+
209+
return Response(status=status.HTTP_202_ACCEPTED)
188210

189211

190212
class EntityHistoryAPI(viewsets.ReadOnlyModelViewSet):

entity/tasks.py

+62-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import json
22

3+
import custom_view
34
from airone.celery import app
5+
from airone.lib.job import may_schedule_until_job_is_ready
46
from airone.lib.types import AttrTypeValue
7+
from entity.api_v2.serializers import EntityCreateSerializer, EntityUpdateSerializer
58
from entity.models import Entity, EntityAttr
69
from job.models import Job
7-
from user.models import User
10+
from user.models import History, User
811

912

1013
@app.task(bind=True)
@@ -237,3 +240,61 @@ def delete_entity(self, job_id):
237240

238241
# update job status and save it
239242
job.update(Job.STATUS["DONE"])
243+
244+
245+
@app.task(bind=True)
246+
@may_schedule_until_job_is_ready
247+
def create_entity_v2(self, job: Job):
248+
serializer = EntityCreateSerializer(data=json.loads(job.params), context={"_user": job.user})
249+
if not serializer.is_valid():
250+
return Job.STATUS["ERROR"]
251+
252+
serializer.create(serializer.validated_data)
253+
254+
# update job status and save it
255+
return Job.STATUS["DONE"]
256+
257+
258+
@app.task(bind=True)
259+
@may_schedule_until_job_is_ready
260+
def edit_entity_v2(self, job: Job):
261+
entity: Entity | None = Entity.objects.filter(id=job.target.id, is_active=True).first()
262+
if not entity:
263+
job.update(Job.STATUS["ERROR"])
264+
return
265+
266+
serializer = EntityUpdateSerializer(
267+
instance=entity, data=json.loads(job.params), context={"_user": job.user}
268+
)
269+
if not serializer.is_valid():
270+
return Job.STATUS["ERROR"]
271+
272+
serializer.update(entity, serializer.validated_data)
273+
274+
return Job.STATUS["DONE"]
275+
276+
277+
@app.task(bind=True)
278+
@may_schedule_until_job_is_ready
279+
def delete_entity_v2(self, job: Job):
280+
entity: Entity | None = Entity.objects.filter(id=job.target.id, is_active=True).first()
281+
if not entity:
282+
return Job.STATUS["ERROR"]
283+
284+
if custom_view.is_custom("before_delete_entity_v2"):
285+
custom_view.call_custom("before_delete_entity_v2", None, job.user, entity)
286+
287+
# register operation History for deleting entity
288+
history: History = job.user.seth_entity_del(entity)
289+
entity.delete()
290+
291+
# Delete all attributes which target Entity have
292+
entity_attr: EntityAttr
293+
for entity_attr in entity.attrs.filter(is_active=True):
294+
history.del_attr(entity_attr)
295+
entity_attr.delete()
296+
297+
if custom_view.is_custom("after_delete_entity_v2"):
298+
custom_view.call_custom("after_delete_entity_v2", None, job.user, entity)
299+
300+
return Job.STATUS["DONE"]

0 commit comments

Comments
 (0)