From d9db813babca36b495380d5a74b4f75a1edda3d0 Mon Sep 17 00:00:00 2001 From: Ryo Okubo Date: Sat, 13 Apr 2024 23:35:45 +0900 Subject: [PATCH 1/8] Show 'ongoing' status on entity if it has non-finished jobs --- entity/api_v2/serializers.py | 7 ++++++- frontend/src/components/common/PageHeader.tsx | 11 ++++++++--- frontend/src/pages/EntryListPage.tsx | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/entity/api_v2/serializers.py b/entity/api_v2/serializers.py index 358180a55..55faf1b4d 100644 --- a/entity/api_v2/serializers.py +++ b/entity/api_v2/serializers.py @@ -18,6 +18,7 @@ from airone.lib.types import AttrTypeValue from entity.admin import EntityAttrResource, EntityResource from entity.models import Entity, EntityAttr +from job.models import Job, JobStatus from user.models import History, User from webhook.models import Webhook @@ -493,10 +494,11 @@ class EntityDetailAttribute(TypedDict): class EntityDetailSerializer(EntityListSerializer): attrs = serializers.SerializerMethodField(method_name="get_attrs") webhooks = WebhookSerializer(many=True) + has_ongoing_changes = serializers.SerializerMethodField() class Meta: model = Entity - fields = ["id", "name", "note", "status", "is_toplevel", "attrs", "webhooks", "is_public"] + fields = ["id", "name", "note", "status", "is_toplevel", "attrs", "webhooks", "is_public", "has_ongoing_changes"] @extend_schema_field(serializers.ListField(child=EntityDetailAttributeSerializer())) def get_attrs(self, obj: Entity) -> list[EntityDetailAttributeSerializer.EntityDetailAttribute]: @@ -529,6 +531,9 @@ def get_attrs(self, obj: Entity) -> list[EntityDetailAttributeSerializer.EntityD return attrinfo + def get_has_ongoing_changes(self, obj: Entity) -> bool: + return Job.objects.filter(target=obj, status__in=[JobStatus.PREPARING, JobStatus.PROCESSING]).exists() + class EntityHistorySerializer(serializers.ModelSerializer): username = serializers.SerializerMethodField() diff --git a/frontend/src/components/common/PageHeader.tsx b/frontend/src/components/common/PageHeader.tsx index 347c348b1..5ede55bf5 100644 --- a/frontend/src/components/common/PageHeader.tsx +++ b/frontend/src/components/common/PageHeader.tsx @@ -1,4 +1,5 @@ -import { Box, Divider, Typography } from "@mui/material"; +import { Box, Divider, Tooltip, Typography } from "@mui/material"; +import AutorenewIcon from '@mui/icons-material/Autorenew'; import { styled } from "@mui/material/styles"; import React, { FC } from "react"; @@ -20,7 +21,7 @@ const Fixed = styled(Box)({ const Header = styled(Box)(({ theme }) => ({ width: theme.breakpoints.values.lg, display: "flex", - alignItems: "baseline", + alignItems: "center", marginBottom: "16px", })); @@ -40,10 +41,11 @@ const ChildrenBox = styled(Box)({ interface Props { title: string; description?: string; + hasOngoingProcess?: boolean; children?: React.ReactNode; } -export const PageHeader: FC = ({ title, description, children }) => { +export const PageHeader: FC = ({ title, description, hasOngoingProcess, children }) => { return ( @@ -54,6 +56,9 @@ export const PageHeader: FC = ({ title, description, children }) => { {description} + {hasOngoingProcess && ( + + )} {children} diff --git a/frontend/src/pages/EntryListPage.tsx b/frontend/src/pages/EntryListPage.tsx index 2f65b9deb..1175fcf5a 100644 --- a/frontend/src/pages/EntryListPage.tsx +++ b/frontend/src/pages/EntryListPage.tsx @@ -31,7 +31,7 @@ export const EntryListPage: FC = ({ canCreateEntry = true }) => { - + Date: Sat, 20 Apr 2024 15:57:55 +0900 Subject: [PATCH 2/8] Perform lightweight process before sending async task --- entity/api_v2/serializers.py | 116 ++++++++++++++++++++++------------- entity/api_v2/views.py | 4 +- entity/tasks.py | 10 +-- entity/tests/test_api_v2.py | 5 +- job/models.py | 2 +- 5 files changed, 87 insertions(+), 50 deletions(-) diff --git a/entity/api_v2/serializers.py b/entity/api_v2/serializers.py index 55faf1b4d..890a9a99a 100644 --- a/entity/api_v2/serializers.py +++ b/entity/api_v2/serializers.py @@ -18,7 +18,6 @@ from airone.lib.types import AttrTypeValue from entity.admin import EntityAttrResource, EntityResource from entity.models import Entity, EntityAttr -from job.models import Job, JobStatus from user.models import History, User from webhook.models import Webhook @@ -206,49 +205,20 @@ class Meta: def _update_or_create( self, user: User, - entity_id: Optional[int], + entity: Entity, + is_create_mode: bool, validated_data: EntityCreateData | EntityUpdateData, ) -> Entity: - is_toplevel_data = validated_data.pop("is_toplevel", None) - attrs_data = validated_data.pop("attrs") - webhooks_data = validated_data.pop("webhooks") - - entity: Entity - entity, is_created_entity = Entity.objects.get_or_create( - id=entity_id, defaults={**validated_data} - ) - if not is_created_entity: - # record history for specific fields on update - updated_fields: list[str] = [] - if "name" in validated_data and entity.name != validated_data.get("name"): - entity.name = validated_data.get("name") - updated_fields.append("name") - if "note" in validated_data and entity.note != validated_data.get("note"): - entity.note = validated_data.get("note") - updated_fields.append("note") - if len(updated_fields) > 0: - entity.save(update_fields=updated_fields) - else: - entity.save_without_historical_record() - - if is_toplevel_data is None: - is_toplevel_data = (entity.status & Entity.STATUS_TOP_LEVEL) != 0 + attrs_data: list = validated_data.get("attrs", []) + webhooks_data: list = validated_data.get("webhooks", []) # register history to create, update Entity history: History - if is_created_entity: - entity.set_status(Entity.STATUS_CREATING) + if is_create_mode: history = user.seth_entity_add(entity) else: - entity.set_status(Entity.STATUS_EDITING) history = user.seth_entity_mod(entity) - # set status parameters - if is_toplevel_data: - entity.set_status(Entity.STATUS_TOP_LEVEL) - else: - entity.del_status(Entity.STATUS_TOP_LEVEL) - # create EntityAttr instances in associated with specifying data for attr_data in attrs_data: # This is necessary not to pass invalid parameter to DRF DB-register @@ -324,7 +294,7 @@ def _update_or_create( webhook.save(update_fields=["is_verified", "verification_error_details"]) # unset Editing MODE - if is_created_entity: + if is_create_mode: entity.del_status(Entity.STATUS_CREATING) if custom_view.is_custom("after_create_entity_v2"): custom_view.call_custom("after_create_entity_v2", None, user, entity) @@ -369,13 +339,12 @@ def validate_webhooks(self, webhooks: list[WebhookCreateUpdateSerializer]): return webhooks - def create(self, validated_data: EntityCreateData): + def create(self, validated_data: EntityCreateData) -> Entity: user: User | None = None if "request" in self.context: user = self.context["request"].user if "_user" in self.context: user = self.context["_user"] - if user is None: raise RequiredParameterError("user is required") @@ -385,7 +354,29 @@ def create(self, validated_data: EntityCreateData): "before_create_entity_v2", None, user, validated_data ) - return self._update_or_create(user, None, validated_data) + entity = Entity.objects.create( + name=validated_data.get("name"), + note=validated_data.get("note", ""), + created_user=validated_data.get("created_user"), + ) + + # set status parameters + if validated_data.get("is_toplevel", False): + entity.set_status(Entity.STATUS_TOP_LEVEL) + entity.set_status(Entity.STATUS_CREATING) + + return entity + + def create_remaining(self, entity: Entity, validated_data: EntityCreateData) -> Entity: + user: User | None = None + if "request" in self.context: + user = self.context["request"].user + if "_user" in self.context: + user = self.context["_user"] + if user is None: + raise RequiredParameterError("user is required") + + return self._update_or_create(user, entity, True, validated_data) class EntityUpdateSerializer(EntitySerializer): @@ -454,7 +445,38 @@ def update(self, entity: Entity, validated_data: EntityUpdateData): "before_update_entity_v2", None, user, validated_data, entity ) - return self._update_or_create(user, entity.id, validated_data) + # record history for specific fields on update + updated_fields: list[str] = [] + if "name" in validated_data and entity.name != validated_data.get("name"): + entity.name = validated_data.get("name") + updated_fields.append("name") + if "note" in validated_data and entity.note != validated_data.get("note"): + entity.note = validated_data.get("note") + updated_fields.append("note") + if len(updated_fields) > 0: + entity.save(update_fields=updated_fields) + else: + entity.save_without_historical_record() + + # set status parameters + if validated_data.pop("is_toplevel", (entity.status & Entity.STATUS_TOP_LEVEL) != 0): + entity.set_status(Entity.STATUS_TOP_LEVEL) + else: + entity.del_status(Entity.STATUS_TOP_LEVEL) + entity.set_status(Entity.STATUS_EDITING) + + return entity + + def update_remaining(self, entity: Entity, validated_data: EntityUpdateData) -> Entity: + user: User | None = None + if "request" in self.context: + user = self.context["request"].user + if "_user" in self.context: + user = self.context["_user"] + if user is None: + raise RequiredParameterError("user is required") + + return self._update_or_create(user, entity, False, validated_data) class EntityListSerializer(EntitySerializer): @@ -498,7 +520,17 @@ class EntityDetailSerializer(EntityListSerializer): class Meta: model = Entity - fields = ["id", "name", "note", "status", "is_toplevel", "attrs", "webhooks", "is_public", "has_ongoing_changes"] + fields = [ + "id", + "name", + "note", + "status", + "is_toplevel", + "attrs", + "webhooks", + "is_public", + "has_ongoing_changes", + ] @extend_schema_field(serializers.ListField(child=EntityDetailAttributeSerializer())) def get_attrs(self, obj: Entity) -> list[EntityDetailAttributeSerializer.EntityDetailAttribute]: @@ -532,7 +564,7 @@ def get_attrs(self, obj: Entity) -> list[EntityDetailAttributeSerializer.EntityD return attrinfo def get_has_ongoing_changes(self, obj: Entity) -> bool: - return Job.objects.filter(target=obj, status__in=[JobStatus.PREPARING, JobStatus.PROCESSING]).exists() + return (obj.status & Entity.STATUS_CREATING) > 0 or (obj.status & Entity.STATUS_EDITING) > 0 class EntityHistorySerializer(serializers.ModelSerializer): diff --git a/entity/api_v2/views.py b/entity/api_v2/views.py index 95d1b6797..d82af7921 100644 --- a/entity/api_v2/views.py +++ b/entity/api_v2/views.py @@ -135,8 +135,9 @@ def create(self, request: Request, *args, **kwargs) -> Response: serializer = EntityCreateSerializer(data=request.data, context={"_user": user}) serializer.is_valid(raise_exception=True) + entity = serializer.create(serializer.validated_data) - job = Job.new_create_entity_v2(user, None, params=request.data) + job = Job.new_create_entity_v2(user, entity, params=request.data) job.run() return Response(status=status.HTTP_202_ACCEPTED) @@ -150,6 +151,7 @@ def update(self, request: Request, *args, **kwargs) -> Response: instance=entity, data=request.data, context={"_user": user} ) serializer.is_valid(raise_exception=True) + serializer.update(entity, serializer.validated_data) job = Job.new_edit_entity_v2(user, entity, params=request.data) job.run() diff --git a/entity/tasks.py b/entity/tasks.py index 01b71b84c..16d80fd1a 100644 --- a/entity/tasks.py +++ b/entity/tasks.py @@ -227,11 +227,13 @@ def delete_entity(self, job: Job) -> JobStatus: @app.task(bind=True) @may_schedule_until_job_is_ready def create_entity_v2(self, job: Job) -> JobStatus: - serializer = EntityCreateSerializer(data=json.loads(job.params), context={"_user": job.user}) - if not serializer.is_valid(): + entity: Entity | None = Entity.objects.filter(id=job.target.id, is_active=True).first() + if not entity: return JobStatus.ERROR - serializer.create(serializer.validated_data) + # pass to validate the params because the entity should be already created + serializer = EntityCreateSerializer(data=json.loads(job.params), context={"_user": job.user}) + serializer.create_remaining(entity, serializer.initial_data) # update job status and save it return JobStatus.DONE @@ -250,7 +252,7 @@ def edit_entity_v2(self, job: Job) -> JobStatus: if not serializer.is_valid(): return JobStatus.ERROR - serializer.update(entity, serializer.validated_data) + serializer.update_remaining(entity, serializer.validated_data) return JobStatus.DONE diff --git a/entity/tests/test_api_v2.py b/entity/tests/test_api_v2.py index aca47fd09..6755b6750 100644 --- a/entity/tests/test_api_v2.py +++ b/entity/tests/test_api_v2.py @@ -64,6 +64,7 @@ def test_retrieve_entity(self): "attrs": [], "webhooks": [], "is_public": True, + "has_ongoing_changes": False, }, ) @@ -1303,7 +1304,7 @@ def side_effect(handler_name, entity_name, user, *args): mock_call_custom.side_effect = side_effect resp = self.client.post("/entity/api/v2/", json.dumps(params), "application/json") - self.assertEqual(resp.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertTrue(mock_call_custom.called) def side_effect(handler_name, entity_name, user, *args): @@ -2488,7 +2489,7 @@ def side_effect(handler_name, entity_name, user, *args): resp = self.client.put( "/entity/api/v2/%d/" % self.entity.id, json.dumps(params), "application/json" ) - self.assertEqual(resp.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertTrue(mock_call_custom.called) def side_effect(handler_name, entity_name, user, *args): diff --git a/job/models.py b/job/models.py index 48d8a3dd7..2974a393c 100644 --- a/job/models.py +++ b/job/models.py @@ -563,7 +563,7 @@ def new_invoke_trigger(kls, user, target_entry, recv_attrs={}, dependent_job=Non ) @classmethod - def new_create_entity_v2(kls, user, target, text="", params={}): + def new_create_entity_v2(kls, user, target: Entity, text="", params={}): return kls._create_new_job( user, target, From 542ce934f6a0c34f33bc051c5a85af16a03434f9 Mon Sep 17 00:00:00 2001 From: Ryo Okubo Date: Sat, 20 Apr 2024 16:10:15 +0900 Subject: [PATCH 3/8] Adjust frontend --- frontend/src/components/common/PageHeader.tsx | 2 +- frontend/src/pages/EntityEditPage.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/common/PageHeader.tsx b/frontend/src/components/common/PageHeader.tsx index 5ede55bf5..704f22822 100644 --- a/frontend/src/components/common/PageHeader.tsx +++ b/frontend/src/components/common/PageHeader.tsx @@ -57,7 +57,7 @@ export const PageHeader: FC = ({ title, description, hasOngoingProcess, c {description} {hasOngoingProcess && ( - + )} {children} diff --git a/frontend/src/pages/EntityEditPage.tsx b/frontend/src/pages/EntityEditPage.tsx index 105a56abc..5dcfbb213 100644 --- a/frontend/src/pages/EntityEditPage.tsx +++ b/frontend/src/pages/EntityEditPage.tsx @@ -199,6 +199,7 @@ export const EntityEditPage: FC = () => { entity?.value != null ? entity.value.name : "新規エンティティの作成" } description={entity?.value && "エンティテイティ詳細 / 編集"} + hasOngoingProcess={entity?.value?.hasOngoingChanges} > Date: Sat, 20 Apr 2024 16:10:36 +0900 Subject: [PATCH 4/8] Bump version --- apiclient/typescript-fetch/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiclient/typescript-fetch/package.json b/apiclient/typescript-fetch/package.json index f442fb381..bf10a0ac5 100644 --- a/apiclient/typescript-fetch/package.json +++ b/apiclient/typescript-fetch/package.json @@ -1,6 +1,6 @@ { "name": "@dmm-com/airone-apiclient-typescript-fetch", - "version": "0.0.14", + "version": "0.0.15", "description": "AirOne APIv2 client in TypeScript", "main": "src/autogenerated/index.ts", "scripts": { From 87488122c3dc1e71d4f75e3fad24a5c4ce997e61 Mon Sep 17 00:00:00 2001 From: Ryo Okubo Date: Sat, 20 Apr 2024 16:33:55 +0900 Subject: [PATCH 5/8] Update tests --- frontend/src/components/entry/EntryForm.test.tsx | 1 + frontend/src/pages/EntityEditPage.test.tsx | 1 + frontend/src/services/entry/Edit.test.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/frontend/src/components/entry/EntryForm.test.tsx b/frontend/src/components/entry/EntryForm.test.tsx index bc7dfecbb..35c2cba59 100644 --- a/frontend/src/components/entry/EntryForm.test.tsx +++ b/frontend/src/components/entry/EntryForm.test.tsx @@ -22,6 +22,7 @@ test("should render a component with essential props", function () { name: "bbb", note: "", isToplevel: false, + hasOngoingChanges: false, attrs: [], webhooks: [], }; diff --git a/frontend/src/pages/EntityEditPage.test.tsx b/frontend/src/pages/EntityEditPage.test.tsx index b4cd84efe..7e3926745 100644 --- a/frontend/src/pages/EntityEditPage.test.tsx +++ b/frontend/src/pages/EntityEditPage.test.tsx @@ -51,6 +51,7 @@ const entity: EntityDetail = { name: "test entity", note: "", isToplevel: false, + hasOngoingChanges: false, attrs: [], webhooks: [], }; diff --git a/frontend/src/services/entry/Edit.test.ts b/frontend/src/services/entry/Edit.test.ts index 4bb1fc419..3ac27c7e4 100644 --- a/frontend/src/services/entry/Edit.test.ts +++ b/frontend/src/services/entry/Edit.test.ts @@ -27,6 +27,7 @@ test("formalizeEntryInfo should return expect value", () => { note: "hoge", status: 0, isToplevel: true, + hasOngoingChanges: false, attrs: [ { id: 2, From 54818e5f84ad383e74ff1368342e8735155de434 Mon Sep 17 00:00:00 2001 From: hinashi Date: Mon, 22 Apr 2024 15:34:21 +0900 Subject: [PATCH 6/8] Update package.json --- apiclient/typescript-fetch/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiclient/typescript-fetch/package.json b/apiclient/typescript-fetch/package.json index bf10a0ac5..c079cd426 100644 --- a/apiclient/typescript-fetch/package.json +++ b/apiclient/typescript-fetch/package.json @@ -1,6 +1,6 @@ { "name": "@dmm-com/airone-apiclient-typescript-fetch", - "version": "0.0.15", + "version": "0.0.16", "description": "AirOne APIv2 client in TypeScript", "main": "src/autogenerated/index.ts", "scripts": { From 0c6b95dcd5f96aa16ce679394cc4f9952618213a Mon Sep 17 00:00:00 2001 From: hinashi Date: Mon, 22 Apr 2024 16:29:56 +0900 Subject: [PATCH 7/8] Updated airone client package --- .../pages/__snapshots__/ACLEditPage.test.tsx.snap | 4 ++-- .../__snapshots__/ACLHistoryPage.test.tsx.snap | 4 ++-- .../AdvancedSearchResultsPage.test.tsx.snap | 4 ++-- .../__snapshots__/EntityEditPage.test.tsx.snap | 4 ++-- .../__snapshots__/EntityHistoryPage.test.tsx.snap | 4 ++-- .../__snapshots__/EntityListPage.test.tsx.snap | 4 ++-- .../__snapshots__/EntryCopyPage.test.tsx.snap | 4 ++-- .../__snapshots__/EntryDetailsPage.test.tsx.snap | 4 ++-- .../__snapshots__/EntryEditPage.test.tsx.snap | 4 ++-- .../EntryHistoryListPage.test.tsx.snap | 4 ++-- .../__snapshots__/EntryListPage.test.tsx.snap | 4 ++-- .../__snapshots__/EntryRestorePage.test.tsx.snap | 4 ++-- .../__snapshots__/GroupEditPage.test.tsx.snap | 4 ++-- .../__snapshots__/GroupListPage.test.tsx.snap | 4 ++-- .../pages/__snapshots__/JobListPage.test.tsx.snap | 4 ++-- .../pages/__snapshots__/RoleEditPage.test.tsx.snap | 4 ++-- .../pages/__snapshots__/RoleListPage.test.tsx.snap | 4 ++-- .../__snapshots__/TriggerEditPage.test.tsx.snap | 4 ++-- .../__snapshots__/TriggerListPage.test.tsx.snap | 4 ++-- .../pages/__snapshots__/UserEditPage.test.tsx.snap | 4 ++-- .../pages/__snapshots__/UserListPage.test.tsx.snap | 4 ++-- package-lock.json | 14 +++++++------- package.json | 2 +- 23 files changed, 50 insertions(+), 50 deletions(-) diff --git a/frontend/src/pages/__snapshots__/ACLEditPage.test.tsx.snap b/frontend/src/pages/__snapshots__/ACLEditPage.test.tsx.snap index 0873bde4d..b04179583 100644 --- a/frontend/src/pages/__snapshots__/ACLEditPage.test.tsx.snap +++ b/frontend/src/pages/__snapshots__/ACLEditPage.test.tsx.snap @@ -18,7 +18,7 @@ exports[`should match snapshot 1`] = ` class="MuiBox-root css-t1gim7" >
Date: Mon, 22 Apr 2024 16:34:08 +0900 Subject: [PATCH 8/8] Fixed lint --- frontend/src/components/common/PageHeader.tsx | 13 ++++++++++--- frontend/src/pages/EntryListPage.tsx | 6 +++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/common/PageHeader.tsx b/frontend/src/components/common/PageHeader.tsx index 704f22822..598f6d00b 100644 --- a/frontend/src/components/common/PageHeader.tsx +++ b/frontend/src/components/common/PageHeader.tsx @@ -1,5 +1,5 @@ +import AutorenewIcon from "@mui/icons-material/Autorenew"; import { Box, Divider, Tooltip, Typography } from "@mui/material"; -import AutorenewIcon from '@mui/icons-material/Autorenew'; import { styled } from "@mui/material/styles"; import React, { FC } from "react"; @@ -45,7 +45,12 @@ interface Props { children?: React.ReactNode; } -export const PageHeader: FC = ({ title, description, hasOngoingProcess, children }) => { +export const PageHeader: FC = ({ + title, + description, + hasOngoingProcess, + children, +}) => { return ( @@ -57,7 +62,9 @@ export const PageHeader: FC = ({ title, description, hasOngoingProcess, c {description} {hasOngoingProcess && ( - + + + )} {children} diff --git a/frontend/src/pages/EntryListPage.tsx b/frontend/src/pages/EntryListPage.tsx index 1175fcf5a..10bc46c9d 100644 --- a/frontend/src/pages/EntryListPage.tsx +++ b/frontend/src/pages/EntryListPage.tsx @@ -31,7 +31,11 @@ export const EntryListPage: FC = ({ canCreateEntry = true }) => { - +