diff --git a/CHANGELOG.md b/CHANGELOG.md index eaa76c0df..18a1b4398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## In development ### Added +* Added processing to be able to delete rest of Items as well as that are not checked + in the AdvancedPageResult page. + Contributed by @hinashi, @userlocalhost ### Changed diff --git a/airone/lib/acl.py b/airone/lib/acl.py index 7ee9bf32f..eb197363f 100644 --- a/airone/lib/acl.py +++ b/airone/lib/acl.py @@ -1,10 +1,12 @@ import enum +from airone.lib.types import BaseIntEnum + __all__ = ["ACLType", "ACLObjType"] @enum.unique -class ACLObjType(enum.IntEnum): +class ACLObjType(BaseIntEnum): Entity = 1 << 0 EntityAttr = 1 << 1 Entry = 1 << 2 @@ -12,7 +14,7 @@ class ACLObjType(enum.IntEnum): Category = 1 << 4 -class ACLType(enum.IntEnum): +class ACLType(BaseIntEnum): Nothing = 1 << 0 Readable = 1 << 1 Writable = 1 << 2 diff --git a/airone/lib/elasticsearch.py b/airone/lib/elasticsearch.py index 23b175107..6464f29e2 100644 --- a/airone/lib/elasticsearch.py +++ b/airone/lib/elasticsearch.py @@ -10,7 +10,7 @@ from airone.lib.acl import ACLType from airone.lib.log import Logger -from airone.lib.types import AttrType +from airone.lib.types import AttrType, BaseIntEnum from entity.models import Entity from entry.settings import CONFIG from user.models import User @@ -42,7 +42,7 @@ class AdvancedSearchResults(BaseModel): @enum.unique -class FilterKey(enum.IntEnum): +class FilterKey(BaseIntEnum): CLEARED = 0 EMPTY = 1 NON_EMPTY = 2 diff --git a/airone/lib/types.py b/airone/lib/types.py index 3ac1f7062..b47c3fe1b 100644 --- a/airone/lib/types.py +++ b/airone/lib/types.py @@ -2,8 +2,14 @@ from typing import Any +class BaseIntEnum(enum.IntEnum): + @classmethod + def isin(cls, v): + return v in cls.__members__.values() + + @enum.unique -class AttrType(enum.IntEnum): +class AttrType(BaseIntEnum): OBJECT = 1 << 0 STRING = 1 << 1 TEXT = 1 << 2 diff --git a/apiclient/typescript-fetch/package.json b/apiclient/typescript-fetch/package.json index eb6f8009f..f1b33485e 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.5.0", + "version": "0.7.1", "description": "AirOne APIv2 client in TypeScript", "main": "src/autogenerated/index.ts", "scripts": { diff --git a/entry/api_v2/views.py b/entry/api_v2/views.py index 52600d979..a2b9ac5f4 100644 --- a/entry/api_v2/views.py +++ b/entry/api_v2/views.py @@ -1,3 +1,4 @@ +import json import re from collections import Counter from copy import deepcopy @@ -25,7 +26,12 @@ RequiredParameterError, YAMLParser, ) -from airone.lib.elasticsearch import AdvancedSearchResultRecord, AdvancedSearchResults, AttrHint +from airone.lib.elasticsearch import ( + AdvancedSearchResultRecord, + AdvancedSearchResults, + AttrHint, + FilterKey, +) from airone.lib.types import AttrType from api_v1.entry.serializer import EntrySearchChainSerializer from entity.models import Entity, EntityAttr @@ -836,6 +842,8 @@ class EntryAttributeValueRestoreAPI(generics.UpdateAPIView): OpenApiParameter( "ids", {"type": "array", "items": {"type": "number"}}, OpenApiParameter.QUERY ), + OpenApiParameter("isAll", OpenApiTypes.BOOL, OpenApiParameter.QUERY), + OpenApiParameter("attrinfo", OpenApiTypes.STR, OpenApiParameter.QUERY), ], ) class EntryBulkDeleteAPI(generics.DestroyAPIView): @@ -843,8 +851,26 @@ class EntryBulkDeleteAPI(generics.DestroyAPIView): # Specifying serializer_class is necessary for passing processing # of npm run generate serializer_class = EntryUpdateSerializer + internal_limit = 10000 + + def _validate_attrinfo(self): + attrinfo_raw = self.request.query_params.get("attrinfo", "[]") + try: + json_loaded_value = json.loads(attrinfo_raw) + for info in json_loaded_value: + if not any(x in info for x in ["name", "filterKey", "keyword"]): + raise RequiredParameterError("(00)Invalid attrinfo was specified") + if not FilterKey.isin(int(info["filterKey"])): + raise RequiredParameterError("(01)Invalid attrinfo was specified") + except Exception as e: + raise RequiredParameterError(e) + + return json_loaded_value def delete(self, request: Request, *args, **kwargs) -> Response: + # validate "attrinfo" parameter and save it before deleting item processing + attrinfo = self._validate_attrinfo() + ids: list[str] = self.request.query_params.getlist("ids", []) if len(ids) == 0 or not all([id.isdecimal() for id in ids]): raise RequiredParameterError("some ids are invalid") @@ -857,10 +883,43 @@ def delete(self, request: Request, *args, **kwargs) -> Response: if not all([user.has_permission(e, ACLType.Writable) for e in entries]): raise PermissionDenied("deleting some entries is not allowed") + # Run jobs that delete user specified Items + target_model = entries.first().schema if entries.first() else None for entry in entries: job: Job = Job.new_delete_entry_v2(user, entry) job.run() + # Run jobs that delete rest of Items of same Model + isAll: bool = self.request.query_params.get("isAll", False) + if isinstance(isAll, str): + isAll = isAll.lower() == "true" + + if isAll and target_model is not None: + results = AdvancedSearchService.search_entries( + request.user, + hint_entity_ids=list(set([e.schema.id for e in entries])), + hint_attrs=[ + AttrHint( + **{ + "name": x["name"], + "filter_key": FilterKey(int(x["filterKey"])), + "keyword": x["keyword"], + } + ) + for x in attrinfo + ], + limit=self.internal_limit, + ) + + entries = Entry.objects.filter( + id__in=[x.entry["id"] for x in results.ret_values], + schema=target_model, + is_active=True, + ).exclude(id__in=ids) + for entry in entries: + another_job: Job = Job.new_delete_entry_v2(user, entry) + another_job.run() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/entry/tests/test_api_v2.py b/entry/tests/test_api_v2.py index 6317173b5..e30db69ad 100644 --- a/entry/tests/test_api_v2.py +++ b/entry/tests/test_api_v2.py @@ -4852,3 +4852,99 @@ def test_destroy_entries_notify(self, mock_task): self.client.delete("/entry/api/v2/bulk_delete/?ids=%s" % entry.id, None, "application/json") self.assertTrue(mock_task.called) + + @patch("entry.tasks.delete_entry_v2.delay", Mock(side_effect=tasks.delete_entry_v2)) + def test_delete_entries_without_all_parameter(self): + # create test Items that would be deleted in this test + items = [self.add_entry(self.user, "item-%d" % i, self.entity) for i in range(5)] + + # send request to delete only items[0] + resp = self.client.delete( + "/entry/api/v2/bulk_delete/?ids=%s" % items[0].id, None, "application/json" + ) + self.assertEqual(resp.status_code, 204) + self.assertEqual(resp.content, b"") + + # check only items[0] was deleted and rest of items are still alive. + [x.refresh_from_db() for x in items] + self.assertFalse(items[0].is_active) + self.assertTrue(all(x.is_active for x in items[1:])) + + # send request to delete all items + resp = self.client.delete( + "/entry/api/v2/bulk_delete/?ids=%s&isAll=false" % items[1].id, None, "application/json" + ) + self.assertEqual(resp.status_code, 204) + self.assertEqual(resp.content, b"") + + # check only items[0] and items[1] are delete, and rest of items are still alived + [x.refresh_from_db() for x in items] + self.assertFalse(all(x.is_active for x in items[:2])) + self.assertTrue(all(x.is_active for x in items[2:])) + + @patch("entry.tasks.delete_entry_v2.delay", Mock(side_effect=tasks.delete_entry_v2)) + def test_delete_entries_with_all_parameter(self): + # create test Items that would be deleted in this test + items = [self.add_entry(self.user, "item-%d" % i, self.entity) for i in range(5)] + + # send request to delete only items[0] + resp = self.client.delete( + "/entry/api/v2/bulk_delete/?ids=%s" % items[0].id, None, "application/json" + ) + self.assertEqual(resp.status_code, 204) + self.assertEqual(resp.content, b"") + + # check only items[0] was deleted and rest of items are still alive. + [x.refresh_from_db() for x in items] + self.assertFalse(items[0].is_active) + self.assertTrue(all(x.is_active for x in items[1:])) + + # send request to delete all items + resp = self.client.delete( + "/entry/api/v2/bulk_delete/?ids=%s&isAll=true" % items[1].id, None, "application/json" + ) + self.assertEqual(resp.status_code, 204) + self.assertEqual(resp.content, b"") + + # check all items were deleted. + [x.refresh_from_db() for x in items] + self.assertFalse(any(x.is_active for x in items)) + + @patch("entry.tasks.delete_entry_v2.delay", Mock(side_effect=tasks.delete_entry_v2)) + def test_delete_entries_with_all_parameter_and_attrinfo(self): + # create test Items that would be deleted in this test + items = [ + self.add_entry( + self.user, + "item-%d" % i, + self.entity, + values={ + "val": "hoge" if i < 3 else "fuga", + }, + ) + for i in range(5) + ] + + # send request to delete all items with attrinfo + attrinfo_as_str = json.dumps( + [ + {"name": "ref", "keyword": "", "filterKey": "0"}, + {"name": "val", "keyword": "hoge", "filterKey": "3"}, + ] + ) + resp = self.client.delete( + "/entry/api/v2/bulk_delete/?ids=%s&isAll=true&attrinfo=%s" + % ( + items[0].id, + attrinfo_as_str, + ), + None, + "application/json", + ) + self.assertEqual(resp.status_code, 204) + self.assertEqual(resp.content, b"") + + # check only items, which are matched with "val=hoge", were deleted. + [x.refresh_from_db() for x in items] + self.assertFalse(any(x.is_active for x in items[:3])) + self.assertTrue(any(x.is_active for x in items[3:])) diff --git a/frontend/src/components/common/Confirmable.tsx b/frontend/src/components/common/Confirmable.tsx index 6bcb1f40a..df76e8f87 100644 --- a/frontend/src/components/common/Confirmable.tsx +++ b/frontend/src/components/common/Confirmable.tsx @@ -1,16 +1,25 @@ -import { Box, Button, Dialog, DialogActions, DialogTitle } from "@mui/material"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogTitle, + DialogContent, +} from "@mui/material"; import React, { ReactElement, FC, SyntheticEvent, useState } from "react"; interface Props { componentGenerator: (handleOpen: () => void) => ReactElement; dialogTitle: string; onClickYes: (e: SyntheticEvent) => void; + content?: ReactElement; } export const Confirmable: FC = ({ componentGenerator, dialogTitle, onClickYes, + content, }) => { const [open, setOpen] = useState(false); @@ -37,6 +46,9 @@ export const Confirmable: FC = ({ aria-describedby="alert-dialog-description" > {dialogTitle} + + <>{content} + + + > + + ): Promise { + async destroyEntries( + ids: Array, + attrinfo?: string, + isAll?: boolean, + ): Promise { return await this.entry.entryApiV2BulkDeleteDestroy( - { ids }, + { attrinfo, ids, isAll }, { headers: { "Content-Type": "application/json;charset=utf-8", diff --git a/job/models.py b/job/models.py index 97d23760c..9582246f3 100644 --- a/job/models.py +++ b/job/models.py @@ -15,6 +15,7 @@ from acl.models import ACLBase from airone.lib import auto_complement from airone.lib.log import Logger +from airone.lib.types import BaseIntEnum from entity.models import Entity from entry.models import Entry from job.settings import CONFIG as JOB_CONFIG @@ -36,7 +37,7 @@ CUSTOM_PARALLELIZABLE_OPERATIONS = [] CUSTOM_TASKS = {} - class JobOperationCustom(enum.IntEnum): # type: ignore + class JobOperationCustom(BaseIntEnum): # type: ignore pass @@ -47,7 +48,7 @@ def _support_time_default(o): @enum.unique -class JobOperation(enum.IntEnum): +class JobOperation(BaseIntEnum): # Constant to describes status of each jobs CREATE_ENTRY = 1 EDIT_ENTRY = 2 @@ -82,14 +83,14 @@ class JobOperation(enum.IntEnum): @enum.unique -class JobTarget(enum.IntEnum): +class JobTarget(BaseIntEnum): UNKNOWN = 0 ENTRY = 1 ENTITY = 2 @enum.unique -class JobStatus(enum.IntEnum): +class JobStatus(BaseIntEnum): PREPARING = 1 DONE = 2 ERROR = 3 diff --git a/package-lock.json b/package-lock.json index d9d188765..aa42a79f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@babel/preset-env": "^7.26.9", "@babel/preset-react": "^7.26.3", "@date-io/date-fns": "^1.3.13", - "@dmm-com/airone-apiclient-typescript-fetch": "^0.5.0", + "@dmm-com/airone-apiclient-typescript-fetch": "^0.7.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", @@ -1984,9 +1984,9 @@ } }, "node_modules/@dmm-com/airone-apiclient-typescript-fetch": { - "version": "0.5.0", - "resolved": "https://npm.pkg.github.com/download/@dmm-com/airone-apiclient-typescript-fetch/0.5.0/7dac7c460652cc452c41c6a0d9d76bab63981bba", - "integrity": "sha512-yj/nkiDLN30z+TLsvMBXNRQu+mfACYJpH9tixZt3sdEil3Ga+pXQXze5iN/8AiYjNsLwih5d/tvuNgaTM8LjFQ==", + "version": "0.7.1", + "resolved": "https://npm.pkg.github.com/download/@dmm-com/airone-apiclient-typescript-fetch/0.7.1/1571d7f936a97d52f7cc478f8f664593e766e677", + "integrity": "sha512-sgGsDpNeqpodn1Mt+FwZ/wunBcbDLEHZowOWocmOBX6sI8Sq0QV3IyTWFyMuV/SRvEb3odv12pB4G/yiFdIQag==", "dev": true, "license": "ISC" }, @@ -18656,9 +18656,9 @@ "dev": true }, "@dmm-com/airone-apiclient-typescript-fetch": { - "version": "0.5.0", - "resolved": "https://npm.pkg.github.com/download/@dmm-com/airone-apiclient-typescript-fetch/0.5.0/7dac7c460652cc452c41c6a0d9d76bab63981bba", - "integrity": "sha512-yj/nkiDLN30z+TLsvMBXNRQu+mfACYJpH9tixZt3sdEil3Ga+pXQXze5iN/8AiYjNsLwih5d/tvuNgaTM8LjFQ==", + "version": "0.7.1", + "resolved": "https://npm.pkg.github.com/download/@dmm-com/airone-apiclient-typescript-fetch/0.7.1/1571d7f936a97d52f7cc478f8f664593e766e677", + "integrity": "sha512-sgGsDpNeqpodn1Mt+FwZ/wunBcbDLEHZowOWocmOBX6sI8Sq0QV3IyTWFyMuV/SRvEb3odv12pB4G/yiFdIQag==", "dev": true }, "@dnd-kit/accessibility": { diff --git a/package.json b/package.json index f64de4f79..11067cda8 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@babel/preset-env": "^7.26.9", "@babel/preset-react": "^7.26.3", "@date-io/date-fns": "^1.3.13", - "@dmm-com/airone-apiclient-typescript-fetch": "^0.5.0", + "@dmm-com/airone-apiclient-typescript-fetch": "^0.7.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0",