diff --git a/backend/alembic/versions/5e46d4ff6f21_change_type.py b/backend/alembic/versions/5e46d4ff6f21_change_type.py new file mode 100644 index 0000000..453a034 --- /dev/null +++ b/backend/alembic/versions/5e46d4ff6f21_change_type.py @@ -0,0 +1,24 @@ +"""ChangeType for restoring deleted items + +Revision ID: 5e46d4ff6f21 +Revises: 8a3f36ffa8df +Create Date: 2023-09-25 15:28:13.752183 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5e46d4ff6f21' +down_revision = '8a3f36ffa8df' +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("ALTER TYPE changetype ADD VALUE IF NOT EXISTS 'RESTORE'") + + +def downgrade(): + pass diff --git a/backend/crud.py b/backend/crud.py index ef8392c..397a437 100644 --- a/backend/crud.py +++ b/backend/crud.py @@ -88,10 +88,13 @@ def get_schema(db: Session, id_or_slug: Union[int, str]) -> Schema: def _check_bound_schema_id(db: Session, schema_id: int): try: - db.query(Schema.id).filter(Schema.id == schema_id, Schema.deleted == False).one() + schema = db.query(Schema).filter(Schema.id == schema_id).one() except NoResultFound: raise MissingSchemaException(obj_id=schema_id) + if schema.deleted: + raise SchemaIsDeletedException(obj_id=schema_id) + def create_schema(db: Session, data: SchemaCreateSchema, commit: bool = True) -> Schema: try: @@ -143,7 +146,7 @@ def create_schema(db: Session, data: SchemaCreateSchema, commit: bool = True) -> def delete_schema(db: Session, id_or_slug: Union[int, str], commit: bool = True) -> Schema: - q = select(Schema).where(Schema.deleted == False) + q = select(Schema) if isinstance(id_or_slug, int): q = q.where(Schema.id == id_or_slug) else: @@ -152,6 +155,8 @@ def delete_schema(db: Session, id_or_slug: Union[int, str], commit: bool = True) schema = db.execute(q).scalar() if schema is None: raise MissingSchemaException(obj_id=id_or_slug) + if schema.deleted: + raise NoOpChangeException(f"Schema with id {schema.id} is already deleted") db.execute(update(Entity) .where(Entity.schema_id == schema.id, Entity.deleted == False) @@ -645,10 +650,12 @@ def update_entity(db: Session, id_or_slug: Union[str, int], schema_id: int, data q = select(Entity).where(Entity.schema_id == schema_id) q = q.where(Entity.id == id_or_slug) if isinstance(id_or_slug, int) else q.where(Entity.slug == id_or_slug) e = db.execute(q).scalar() - if e is None or e.deleted: + if e is None: raise MissingEntityException(obj_id=id_or_slug) if e.schema.deleted: raise MissingSchemaException(obj_id=e.schema.id) + if e.deleted: + raise EntityIsDeletedException(obj_id=e.id) slug = data.pop('slug', e.slug) name = data.pop('name', e.name) @@ -716,7 +723,7 @@ def update_entity(db: Session, id_or_slug: Union[str, int], schema_id: int, data def delete_entity(db: Session, id_or_slug: Union[int, str], schema_id: int, commit: bool = True) -> Entity: - q = select(Entity).where(Entity.deleted == False).where(Entity.schema_id == schema_id) + q = select(Entity).where(Entity.schema_id == schema_id) if isinstance(id_or_slug, int): q = q.where(Entity.id == id_or_slug) else: @@ -724,9 +731,30 @@ def delete_entity(db: Session, id_or_slug: Union[int, str], schema_id: int, comm e = db.execute(q).scalar() if e is None: raise MissingEntityException(obj_id=id_or_slug) + if e.deleted: + raise NoOpChangeException(f"Entity with id {e.id} is already deleted") e.deleted = True if commit: db.commit() else: db.flush() return e + + +def restore_entity(db: Session, id_or_slug: Union[int, str], schema_id: int, commit: bool = True) -> Entity: + q = select(Entity).where(Entity.schema_id == schema_id) + if isinstance(id_or_slug, int): + q = q.where(Entity.id == id_or_slug) + else: + q = q.where(Entity.slug == id_or_slug) + e = db.execute(q).scalar() + if e is None: + raise MissingEntityException(obj_id=id_or_slug) + if not e.deleted: + raise NoOpChangeException(f"Entity with id {e.id} is not deleted") + e.deleted = False + if commit: + db.commit() + else: + db.flush() + return e diff --git a/backend/dynamic_routes.py b/backend/dynamic_routes.py index 559c784..ea47e9c 100644 --- a/backend/dynamic_routes.py +++ b/backend/dynamic_routes.py @@ -20,7 +20,7 @@ from .traceability.entity import create_entity_create_request, create_entity_update_request, \ create_entity_delete_request, apply_entity_create_request, apply_entity_update_request, \ - apply_entity_delete_request + apply_entity_delete_request, create_entity_restore_request, apply_entity_restore_request factory = EntityModelFactory() @@ -261,6 +261,7 @@ def route_update_entity(router: APIRouter, schema: Schema): * there already exists an entity that has same value for unique field and this entity is not deleted ''' }, + 410: {"description": "Entity cannot be updated because it was deleted"}, 422: { 'description': '''Can be returned when: @@ -288,6 +289,8 @@ def update_entity(id_or_slug: Union[int, str], return change_request except exceptions.NoOpChangeException as e: raise HTTPException(status.HTTP_208_ALREADY_REPORTED, str(e)) + except exceptions.EntityIsDeletedException as e: + raise HTTPException(status.HTTP_410_GONE, str(e)) except (exceptions.MissingEntityException, exceptions.MissingSchemaException) as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) except (exceptions.EntityExistsException, exceptions.UniqueValueException) as e: @@ -314,25 +317,33 @@ def route_delete_entity(router: APIRouter, schema: Schema): responses={ 200: {"description": "Entity was deleted"}, 202: {"description": "Request to delete entity was stored"}, + 208: {"description": "Entity was not changed because request contained no changes"}, 404: { 'description': "entity with provided id/slug doesn't exist on current schema" } } ) def delete_entity(id_or_slug: Union[int, str], response: Response, + restore: bool = False, db: Session = Depends(get_db), user: User = Depends(req_permission)): + create_fun, apply_fun = create_entity_delete_request, apply_entity_delete_request + if restore: + create_fun, apply_fun = create_entity_restore_request, apply_entity_restore_request + try: - change_request = create_entity_delete_request( + change_request = create_fun( db=db, id_or_slug=id_or_slug, schema_id=schema.id, created_by=user, commit=False ) if not schema.reviewable: - return apply_entity_delete_request( + return apply_fun( db=db, change_request=change_request, reviewed_by=user, comment='Autosubmit' )[1] db.commit() response.status_code = status.HTTP_202_ACCEPTED return change_request + except exceptions.NoOpChangeException as e: + raise HTTPException(status.HTTP_208_ALREADY_REPORTED, str(e)) except exceptions.MissingEntityException as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) diff --git a/backend/exceptions.py b/backend/exceptions.py index 7668792..e44b3cb 100644 --- a/backend/exceptions.py +++ b/backend/exceptions.py @@ -27,7 +27,7 @@ def __str__(self) -> str: return f'Group with name {self.name} already exists' -class MissingObjectException(Exception): +class ObjectException(Exception): obj_type: str = "Object" def __init__(self, obj_id: Union[int, str], obj_type: Optional[str] = None): @@ -35,14 +35,35 @@ def __init__(self, obj_id: Union[int, str], obj_type: Optional[str] = None): self.obj_type = obj_type or self.obj_type def __str__(self) -> str: - return f"{self.obj_type} with id {self.obj_id} doesn't exist or was deleted" + raise NotImplementedError("Define in subclasses") + + +class MissingObjectException(ObjectException): + def __str__(self) -> str: + return f"{self.obj_type} with id {self.obj_id} doesn't exist" + + +class DeletedObjectException(ObjectException): + def __str__(self): + return f"{self.obj_type} with id {self.obj_id} is deleted" + class MissingSchemaException(MissingObjectException): obj_type = 'Schema' + +class SchemaIsDeletedException(DeletedObjectException): + obj_type = 'Schema' + + class MissingEntityException(MissingObjectException): obj_type = 'Entity' + +class EntityIsDeletedException(DeletedObjectException): + obj_type = 'Entity' + + class MissingAttributeException(MissingObjectException): obj_type = 'Attribute' @@ -65,6 +86,11 @@ def __str__(self) -> str: return f'There is no entity delete request with id {self.obj_id}' +class MissingEntityRestoreRequestException(MissingObjectException): + def __str__(self) -> str: + return f'There is no entity restore request with id {self.obj_id}' + + class MissingEntityCreateRequestException(MissingObjectException): def __str__(self) -> str: return f'There is no entity create request with id {self.obj_id}' diff --git a/backend/general_routes.py b/backend/general_routes.py index 372f6d1..2297ccc 100644 --- a/backend/general_routes.py +++ b/backend/general_routes.py @@ -86,7 +86,24 @@ def get_schemas( @router.post( '/schema', response_model=schemas.SchemaForListSchema, - tags=['General routes'] + tags=['General routes'], + summary="Create a schema", + responses={ + 200: {"description": "Schema was created"}, + 404: { + 'description': """Can be returned when: + + * A schema for foreign key binding does not exist + * No attribute exists for a given ID + """ + }, + 409: {"description": """Can be returned when: + + * Another schema uses the chosen slug already + * Duplicate use of attributes + """}, + 410: {"description": "The scheme to bind a foreign key to is deleted"} + } ) def create_schema(data: schemas.SchemaCreateSchema, request: Request, db: Session = Depends(get_db), user: User = Depends(authorized_user(RequirePermission(permission=PermissionType.CREATE_SCHEMA)))): @@ -100,14 +117,14 @@ def create_schema(data: schemas.SchemaCreateSchema, request: Request, db: Sessio return schema except exceptions.SchemaExistsException as e: raise HTTPException(status.HTTP_409_CONFLICT, str(e)) - except exceptions.MissingAttributeException as e: + except (exceptions.MissingAttributeException, exceptions.MissingSchemaException) as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) except exceptions.MultipleAttributeOccurencesException as e: raise HTTPException(status.HTTP_409_CONFLICT, str(e)) except exceptions.NoSchemaToBindException as e: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(e)) - except exceptions.MissingSchemaException as e: - raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) + except exceptions.SchemaIsDeletedException as e: + raise HTTPException(status.HTTP_410_GONE, str(e)) @router.get( @@ -161,7 +178,13 @@ def update_schema( '/schema/{id_or_slug}', response_model=schemas.SchemaDetailSchema, response_model_exclude=['attributes', 'attr_defs'], - tags=['General routes'] + tags=['General routes'], + summary="Delete schema", + responses={ + 200: {"description": "Schema was deleted"}, + 208: {"description": "Schema is already deleted"}, + 404: {"description": "Schema does not exist"}, + } ) def delete_schema(id_or_slug: Union[int, str], db: Session = Depends(get_db), user: User = Depends(authorized_user(RequirePermission(permission=PermissionType.DELETE_SCHEMA)))): @@ -173,6 +196,8 @@ def delete_schema(id_or_slug: Union[int, str], db: Session = Depends(get_db), reviewed_by=user, comment='Autosubmit') db.commit() return schema + except exceptions.NoOpChangeException as e: + raise HTTPException(status.HTTP_208_ALREADY_REPORTED, str(e)) except exceptions.MissingSchemaException as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) diff --git a/backend/tests/test_crud_entity.py b/backend/tests/test_crud_entity.py index 128bb61..8900101 100644 --- a/backend/tests/test_crud_entity.py +++ b/backend/tests/test_crud_entity.py @@ -637,6 +637,23 @@ def test_no_raise_on_non_unique_if_existing_is_deleted(self, dbsession): e = dbsession.execute(select(Entity).where(Entity.slug == 'Jack')).scalar() assert e.get('nickname', dbsession).value == 'jane' + def test_raise_on_update_deleted(self, dbsession): + """ + Test that updating a deleted entity warns about the entity already being deleted + """ + e = self.get_default_entity(dbsession) + delete_entity(dbsession, id_or_slug=e.id, schema_id=e.schema_id) + + dbsession.refresh(e) + assert e.deleted is True + + data = {'slug': e.slug} + with pytest.raises(EntityIsDeletedException): + update_entity(dbsession, id_or_slug=e.id, schema_id=e.schema_id, data=data) + + dbsession.refresh(e) + assert e.deleted is True + class TestEntityDelete(DefaultMixin): def asserts_after_entity_delete(self, db: Session): @@ -665,5 +682,33 @@ def test_raise_on_already_deleted(self, dbsession): entity = self.get_default_entity(dbsession) entity.deleted = True dbsession.flush() - with pytest.raises(MissingEntityException): + with pytest.raises(NoOpChangeException): delete_entity(dbsession, id_or_slug=entity.id, schema_id=entity.schema_id) + + +class TestRestoreEntity(DefaultMixin): + def asserts_after_entity_restore(self, db: Session): + entities = db.execute(select(Entity)).scalars().all() + assert len(entities) == 2 + e = self.get_default_entity(db) + assert e.deleted is False + + def test_restore_by_id(self, dbsession): + e = self.get_default_entity(dbsession) + e.deleted = True + dbsession.flush() + restore_entity(dbsession, id_or_slug=e.id, schema_id=e.schema_id) + self.asserts_after_entity_restore(db=dbsession) + + def test_restore_by_slug(self, dbsession): + e = self.get_default_entity(dbsession) + e.deleted = True + dbsession.flush() + restore_entity(dbsession, id_or_slug=e.slug, schema_id=e.schema_id) + self.asserts_after_entity_restore(db=dbsession) + + def test_restore_not_deleted(self, dbsession): + e = self.get_default_entity(dbsession) + + with pytest.raises(NoOpChangeException): + restore_entity(dbsession, id_or_slug=e.slug, schema_id=e.schema_id) diff --git a/backend/tests/test_crud_schema.py b/backend/tests/test_crud_schema.py index d932a43..ebe3ce2 100644 --- a/backend/tests/test_crud_schema.py +++ b/backend/tests/test_crud_schema.py @@ -7,7 +7,7 @@ get_entities, update_entity, get_entity from ..exceptions import SchemaExistsException, MissingSchemaException, RequiredFieldException, \ NoOpChangeException, ListedToUnlistedException, MultipleAttributeOccurencesException, \ - InvalidAttributeChange + InvalidAttributeChange, SchemaIsDeletedException from ..models import Schema, AttributeDefinition, Attribute, AttrType, Entity from .. schemas import AttrDefSchema, SchemaCreateSchema, AttrTypeMapping, SchemaUpdateSchema @@ -151,7 +151,7 @@ def test_raise_on_passed_deleted_schema_for_binding(self, dbsession): ) sch = SchemaCreateSchema(name='Test', slug='test', attributes=[attr_def]) - with pytest.raises(MissingSchemaException): + with pytest.raises(SchemaIsDeletedException): create_schema(dbsession, data=sch) def test_raise_on_multiple_attrs_with_same_name(self, dbsession): @@ -656,7 +656,7 @@ def test_raise_on_already_deleted(self, dbsession): schema = self.get_default_schema(dbsession) schema.deleted = True dbsession.flush() - with pytest.raises(MissingSchemaException): + with pytest.raises(NoOpChangeException): delete_schema(dbsession, id_or_slug=schema.id) def test_raise_on_delete_nonexistent(self, dbsession): diff --git a/backend/tests/test_dynamic_routes.py b/backend/tests/test_dynamic_routes.py index 57bfbbd..2ad3164 100644 --- a/backend/tests/test_dynamic_routes.py +++ b/backend/tests/test_dynamic_routes.py @@ -133,7 +133,7 @@ def test_raise_on_fk_entity_doesnt_exist(self, dbsession, authorized_client): } response = authorized_client.post(f'/entity/person', json=p1) assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert "doesn't exist" in response.json()['detail'] def test_raise_on_fk_entity_is_deleted(self, dbsession, authorized_client): jack = self.get_default_entity(dbsession) @@ -148,7 +148,7 @@ def test_raise_on_fk_entity_is_deleted(self, dbsession, authorized_client): } response = authorized_client.post(f'/entity/person', json=p1) assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert "doesn't exist" in response.json()['detail'] def test_raise_on_fk_entity_from_wrong_schema(self, dbsession, authorized_client): schema = Schema(name='Test', slug='test') @@ -193,7 +193,7 @@ def test_get_entity(self, dbsession, client): def test_raise_on_entity_doesnt_exist(self, dbsession, client): response = client.get('/entity/person/99999999') assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert "doesn't exist" in response.json()['detail'] def test_raise_on_entity_doesnt_belong_to_schema(self, dbsession, client): s = Schema(name='test', slug='test') @@ -203,7 +203,7 @@ def test_raise_on_entity_doesnt_belong_to_schema(self, dbsession, client): response = client.get(f'/entity/person/{e.id}') assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert "doesn't exist" in response.json()['detail'] class TestRouteGetEntities(DefaultMixin): @@ -408,11 +408,11 @@ def test_update(self, dbsession, authorized_client): def test_raise_on_entity_doesnt_exist(self, dbsession, authorized_client): response = authorized_client.put('/entity/person/99999999999', json={}) assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert "doesn't exist" in response.json()['detail'] response = authorized_client.put('/entity/person/qwertyuiop', json={}) assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert "doesn't exist" in response.json()['detail'] s = Schema(name='test', slug='test') e = Entity(slug='test', schema=s, name='test') @@ -420,15 +420,18 @@ def test_raise_on_entity_doesnt_exist(self, dbsession, authorized_client): dbsession.commit() response = authorized_client.put('/entity/person/test', json={}) assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert "doesn't exist" in response.json()['detail'] - def test_raise_on_schema_is_deleted(self, dbsession, authorized_client): + def test_raise_on_deleted_entity(self, dbsession, authorized_client): entity = self.get_default_entity(dbsession) entity.deleted = True dbsession.commit() - response = authorized_client.put(f'/entity/person/{entity.id}', json={}) - assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + + data = {'slug': entity.slug, 'name': 'Foo Bær'} + response = authorized_client.put(f'entity/person/{entity.id}', json=data) + dbsession.refresh(entity) + assert entity.deleted is True + assert response.status_code == 410 def test_raise_on_entity_already_exists(self, dbsession, authorized_client): data = {'slug': 'Jane'} @@ -467,7 +470,7 @@ def test_raise_on_fk_entity_doesnt_exist(self, dbsession, authorized_client): data = {'friends': [9999999999]} response = authorized_client.put('/entity/person/Jack', json=data) assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert "doesn't exist" in response.json()['detail'] def test_raise_on_fk_entity_is_from_wrong_schema(self, dbsession, authorized_client): s = Schema(name='test', slug='test') @@ -527,4 +530,21 @@ def test_raise_on_already_deleted(self, dbsession, authorized_client): entity.deleted = True dbsession.commit() response = authorized_client.delete(f'/entity/person/{entity.id}') - assert response.status_code == 404 + assert response.status_code == 208 + + def test_restore(self, dbsession, authorized_client): + entity = self.get_default_entity(dbsession) + entity.deleted = True + dbsession.commit() + dbsession.refresh(entity) + assert entity.deleted is True + response = authorized_client.delete(f'/entity/person/{entity.slug}?restore=1') + assert response.status_code == 200 + assert response.json() == {'id': entity.id, 'slug': 'Jack', 'name': 'Jack', 'deleted': False} + dbsession.refresh(entity) + assert entity.deleted is False + + def test_restore_on_nondeleted(self, dbsession, authorized_client): + entity = self.get_default_entity(dbsession) + response = authorized_client.delete(f'/entity/person/{entity.slug}?restore=1') + assert response.status_code == 208 diff --git a/backend/tests/test_general_routes.py b/backend/tests/test_general_routes.py index b2b710f..2f54639 100644 --- a/backend/tests/test_general_routes.py +++ b/backend/tests/test_general_routes.py @@ -52,7 +52,7 @@ def test_get_attribute(self, dbsession: Session, client: TestClient): def test_raise_on_attribute_doesnt_exist(self, dbsession: Session, client: TestClient): response = client.get('/attributes/123456789') assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert "doesn't exist" in response.json()['detail'] class TestRouteSchemasGet(DefaultMixin): @@ -171,11 +171,11 @@ def test_get_schema(self, dbsession: Session, client: TestClient): def test_raise_on_schema_doesnt_exist(self, dbsession, client): response = client.get('/schema/12345678') assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert "doesn't exist" in response.json()['detail'] response = client.get('/schema/qwertyui') assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert "doesn't exist" in response.json()['detail'] class TestRouteSchemaCreate(DefaultMixin): @@ -287,7 +287,7 @@ def test_raise_on_nonexistent_schema_when_binding(self, dbsession: Session, auth } response = authorized_client.post('/schema', json=data) assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert "doesn't exist" in response.json()['detail'] def test_raise_on_passed_deleted_schema_for_binding(self, dbsession: Session, authorized_client: TestClient): schema = self.get_default_schema(dbsession) @@ -309,8 +309,8 @@ def test_raise_on_passed_deleted_schema_for_binding(self, dbsession: Session, au ] } response = authorized_client.post('/schema', json=data) - assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert response.status_code == 410 + assert "is deleted" in response.json()['detail'] def test_raise_on_multiple_attrs_with_same_name(self, dbsession: Session, authorized_client: TestClient): data = { @@ -411,7 +411,7 @@ def test_raise_on_schema_doesnt_exist(self, dbsession, authorized_client): } response = authorized_client.put('/schema/12345678', json=data) assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert "doesn't exist" in response.json()['detail'] def test_raise_on_existing_slug_or_name(self, dbsession: Session, authorized_client: TestClient): new_sch = Schema(name='Test', slug='test') @@ -489,7 +489,7 @@ def test_raise_on_nonexistent_schema_when_binding(self, dbsession: Session, auth schema = self.get_default_schema(dbsession) response = authorized_client.put(f'/schema/{schema.id}', json=data) assert response.status_code == 404 - assert "doesn't exist or was deleted" in response.json()['detail'] + assert "doesn't exist" in response.json()['detail'] def test_raise_on_schema_not_passed_when_binding(self, dbsession: Session, authorized_client: TestClient): data = { @@ -578,7 +578,7 @@ def test_raise_on_already_deleted(self, dbsession: Session, authorized_client: T schema.deleted = True dbsession.commit() response = authorized_client.delete(f'/schema/{schema.id}') - assert response.status_code == 404 + assert response.status_code == 208 @pytest.mark.parametrize('id_or_slug', [1234567, 'qwerty']) def test_raise_on_delete_nonexistent(self, dbsession, authorized_client, id_or_slug): diff --git a/backend/traceability/entity.py b/backend/traceability/entity.py index c3e070a..c0f028c 100644 --- a/backend/traceability/entity.py +++ b/backend/traceability/entity.py @@ -16,7 +16,8 @@ from ..enum import ModelVariant from ..exceptions import MissingChangeException, MissingEntityCreateRequestException, \ AttributeNotDefinedException, MissingEntityUpdateRequestException, NoOpChangeException, \ - MissingEntityDeleteRequestException, MissingChangeRequestException + MissingEntityDeleteRequestException, MissingChangeRequestException, \ + MissingEntityRestoreRequestException from ..models import Entity, AttributeDefinition, Schema, Attribute from ..schemas.entity import EntityModelFactory from ..schemas.traceability import EntityChangeDetailSchema @@ -538,3 +539,70 @@ def apply_entity_delete_request(db: Session, change_request: ChangeRequest, revi change_request.comment = comment db.commit() return True, entity + + +def create_entity_restore_request(db: Session, id_or_slug: Union[int, str], schema_id: int, + created_by: User, commit: bool = True) -> ChangeRequest: + crud.restore_entity(db=db, id_or_slug=id_or_slug, schema_id=schema_id, commit=False) + db.rollback() + schema = crud.get_schema(db=db, id_or_slug=schema_id) + entity = crud.get_entity_model(db=db, id_or_slug=id_or_slug, schema=schema) + + change_request = ChangeRequest( + created_by=created_by, + created_at=datetime.now(timezone.utc), + object_type=EditableObjectType.ENTITY, + object_id=entity.id, + change_type=ChangeType.RESTORE + ) + db.add(change_request) + + val = ChangeValueBool(old_value=entity.deleted, new_value=False) + db.add(val) + db.flush() + change = Change( + change_request=change_request, + data_type=ChangeAttrType.BOOL, + change_type=ChangeType.RESTORE, + content_type=ContentType.ENTITY, + object_id=entity.id, + field_name='deleted', + value_id=val.id + ) + db.add(change) + if commit: + db.commit() + else: + db.flush() + return change_request + + +def apply_entity_restore_request(db: Session, change_request: ChangeRequest, reviewed_by: User, + comment: Optional[str]) -> Tuple[bool, Entity]: + change = db.execute( + select(Change) + .where(Change.change_request_id == change_request.id) + .where(Change.data_type == ChangeAttrType.BOOL) + .where(Change.change_type == ChangeType.RESTORE) + .where(Change.content_type == ContentType.ENTITY) + .where(Change.field_name == 'deleted') + .where(Change.object_id != None) + ).scalar() + + if change is None: + raise MissingEntityRestoreRequestException(obj_id=change_request.id) + entity = crud.get_entity_by_id(db=db, entity_id=change.object_id) + v = db.execute(select(ChangeValueBool).where(ChangeValueBool.id == change.value_id)).scalar() + v.old_value = entity.deleted + entity = crud.restore_entity( + db=db, + id_or_slug=entity.id, + schema_id=entity.schema_id, + commit=False + ) + change_request.status = ChangeStatus.APPROVED + change_request.reviewed_by = reviewed_by + change_request.reviewed_at = datetime.now(timezone.utc) + change_request.comment = comment + db.commit() + return True, entity diff --git a/backend/traceability/enum.py b/backend/traceability/enum.py index 592ef04..894e2e0 100644 --- a/backend/traceability/enum.py +++ b/backend/traceability/enum.py @@ -28,3 +28,4 @@ class ChangeType(enum.Enum): CREATE = 'CREATE' UPDATE = 'UPDATE' DELETE = 'DELETE' + RESTORE = 'RESTORE' diff --git a/frontend/src/components/EntityList.vue b/frontend/src/components/EntityList.vue index 5eb02ae..7f34abd 100644 --- a/frontend/src/components/EntityList.vue +++ b/frontend/src/components/EntityList.vue @@ -1,5 +1,6 @@ -
- + + @@ -231,7 +241,26 @@ export default { if (response) { this.updatePendingRequests(); } + this.$emit("update"); + }, + async deleteEntity() { + if (this.entity?.id) { + await this.$api.deleteEntity({ + schemaSlug: this.schema.slug, + entityIdOrSlug: this.entity.id + }); + this.$emit("update"); + } }, + async restoreEntity() { + if (this.entity?.id) { + await this.$api.restoreEntity({ + schemaSlug: this.schema.slug, + entityIdOrSlug: this.entity.id + }); + this.$emit("update"); + } + } }, } diff --git a/frontend/src/plugins/api.js b/frontend/src/plugins/api.js index aff7f5f..1565091 100644 --- a/frontend/src/plugins/api.js +++ b/frontend/src/plugins/api.js @@ -247,6 +247,18 @@ class API { return response; } + + async restoreEntity({schemaSlug, entityIdOrSlug} = {}){ + const response = await this._fetch({ + url: `${this.base}/entity/${schemaSlug}/${entityIdOrSlug}?restore=1`, + method: 'DELETE' + }); + if (response !== null) { + this.alerts.push("success", `Entity restored: ${response.name}`); + } + return response; + } + async getChangeRequests({page = 1, size = 10, schemaSlug, entityIdOrSlug} = {}) { const params = new URLSearchParams(); params.set('page', page); diff --git a/frontend/src/utils.js b/frontend/src/utils.js index 093b6c3..d1ee66d 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -51,6 +51,7 @@ export const CHANGE_STATUS_MAP = { APPROVED: 'success', add: 'add', create: 'add', - delete: 'remove', - update: 'mode_edit' + delete: 'delete', + update: 'mode_edit', + restore: 'restore_from_trash' }