Skip to content

Commit

Permalink
Refs #202. Updated undelete behavior
Browse files Browse the repository at this point in the history
  • Loading branch information
SBriere committed Jul 26, 2023
1 parent 1c01cba commit cbe916e
Show file tree
Hide file tree
Showing 12 changed files with 434 additions and 142 deletions.
142 changes: 104 additions & 38 deletions teraserver/python/opentera/db/SoftDeleteMixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@
from sqlalchemy.ext.declarative import DeclarativeMeta
from sqlalchemy.event import listens_for
from sqlalchemy.orm import ORMExecuteState, Session
from sqlalchemy.engine import Engine, Connection
from sqlalchemy.sql import Select
from sqlalchemy.exc import IntegrityError

from functools import cache

Expand Down Expand Up @@ -146,47 +145,44 @@ def hard_delete_method(_self):
class_attributes[hard_delete_method_name] = hard_delete_method

if generate_undelete_method:
def get_undelete_cascade_relations(_self) -> list:
return [] # By default, no relationships are automatically undeleted when undeleting

class_attributes['get_undelete_cascade_relations'] = get_undelete_cascade_relations

def undelete_method(_self):
if not getattr(_self, deleted_field_name):
print("Object " + str(_self.__class__) + " not deleted - returning.")
return
_self.handle_include_deleted_flag(True)
setattr(_self, deleted_field_name, None)

current_obj = inspect(_self.__class__)
primary_key_name = current_obj.primary_key[0].name

# Check data integrity before undeleting
for col in current_obj.columns:
if not col.foreign_keys:
continue
# If column foreign key is not nullable or there is a value for this object, we must check if
# related object is still there
col_value = getattr(_self, col.name, None)
if not col.nullable or col_value:
remote_table_name = list(col.foreign_keys)[0].column.table.name
model_class = _self.get_class_from_tablename(remote_table_name)
remote_key_name = list(col.foreign_keys)[0].column.key
related_item = model_class.query.filter(text(remote_key_name + '="' + str(col_value) + '"')).first()
if not related_item:
# A required object isn't there (soft-deleted) - throw exception to undelete it first!
raise IntegrityError('Cannot undelete: unsatisfied foreign key - ' + col.name, col_value,
remote_table_name)
# Undelete!
print("Undeleting " + str(_self.__class__))
setattr(_self, deleted_field_name, None)

# Check relationships that are cascade deleted to restore them
if handle_cascade_delete:
primary_key_name = inspect(_self.__class__).primary_key[0].name
for relation in inspect(_self.__class__).relationships.items():
print(str(_self.__class__) + " - relation " + str(relation))
if relation[1].secondary is not None:
print("-> Undeleting secondary table relationship " + relation[1].secondary.name)
# Item has a delete_at field (thus supports soft-delete)
if deleted_field_name in relation[1].entity.columns.keys():
model_class = _self.get_class_from_tablename(relation[1].secondary.name)
if model_class:
related_items = model_class.query.filter(text(primary_key_name + '=' +
str(getattr(_self, primary_key_name)))
).execution_options(include_deleted=True).all()
for item in related_items:
item_undeleter = getattr(item, undelete_method_name)
item_undeleter()

# Undelete "left-side" item of the relationship
remote_primary_key = relation[1].target.primary_key.columns[0].name
remote_model = _self.get_class_from_tablename(relation[1].target.name)
remote_item = remote_model.query.filter(text(remote_primary_key + '=' +
str(getattr(item, remote_primary_key)))
).execution_options(include_deleted=True)\
.first()
if remote_item:
print("--> Undeleting left side of secondary table " + relation[1].target.name)
item_undeleter = getattr(remote_item, undelete_method_name)
item_undeleter()

continue
# Check for parents or related items
if relation[1].back_populates:
print("--> Undeleting back_populates relationship " + str(relation[1]))
# if relation[1].cascade.delete: # Relationship has a cascade delete
# Relationship has a cascade undelete ?
if relation[1].key in _self.get_undelete_cascade_relations():
# Item has a delete_at field (thus supports soft-delete)
if deleted_field_name in relation[1].entity.columns.keys():
# Cascade undelete - must manually query to get deleted rows
Expand All @@ -198,11 +194,81 @@ def undelete_method(_self):
related_items = relation[1].entity.class_.query.execution_options(include_deleted=True).\
filter(text(remote_primary_key + '=' + str(self_key_value))).all()
for item in related_items:
print("--> Undeleting relationship " + relation[1].key)
item_undeleter = getattr(item, undelete_method_name)
item_undeleter()
continue
print("Skipped undelete")
_self.handle_include_deleted_flag(False)

# Check secondary relationships and restore them if both ends are now undeleted
if relation[1].secondary is not None:
if deleted_field_name in relation[1].entity.columns.keys():
model_class = _self.get_class_from_tablename(relation[1].secondary.name)
related_items = model_class.query.execution_options(include_deleted=True).\
filter(text(primary_key_name + '=' + str(getattr(_self, primary_key_name)))).all()
for item in related_items:
# Check if other side of the relationship is present and, if so, restores it
remote_primary_key = relation[1].target.primary_key.columns[0].name
remote_model = _self.get_class_from_tablename(relation[1].target.name)
remote_item = remote_model.query.filter(
text(remote_primary_key + '=' + str(getattr(item, remote_primary_key)))).first()
if remote_item:
print("--> Undeleting relationship with " + relation[1].target.name)
item_undeleter = getattr(item, undelete_method_name)
item_undeleter()

# _self.handle_include_deleted_flag(True)
# setattr(_self, deleted_field_name, None)
# print("Undeleting " + str(_self.__class__))
# if handle_cascade_delete:
# primary_key_name = inspect(_self.__class__).primary_key[0].name
# for relation in inspect(_self.__class__).relationships.items():
# print(str(_self.__class__) + " - relation " + str(relation))
# if relation[1].secondary is not None:
# print("-> Undeleting secondary table relationship " + relation[1].secondary.name)
# # Item has a delete_at field (thus supports soft-delete)
# if deleted_field_name in relation[1].entity.columns.keys():
# model_class = _self.get_class_from_tablename(relation[1].secondary.name)
# if model_class:
# related_items = model_class.query.filter(text(primary_key_name + '=' +
# str(getattr(_self, primary_key_name)))
# ).execution_options(include_deleted=True).all()
# for item in related_items:
# item_undeleter = getattr(item, undelete_method_name)
# item_undeleter()
#
# # Undelete "left-side" item of the relationship
# remote_primary_key = relation[1].target.primary_key.columns[0].name
# remote_model = _self.get_class_from_tablename(relation[1].target.name)
# remote_item = remote_model.query.filter(text(remote_primary_key + '=' +
# str(getattr(item, remote_primary_key)))
# ).execution_options(include_deleted=True)\
# .first()
# if remote_item:
# print("--> Undeleting left side of secondary table " + relation[1].target.name)
# item_undeleter = getattr(remote_item, undelete_method_name)
# item_undeleter()
#
# continue
# # Check for parents or related items
# if relation[1].back_populates:
# print("--> Undeleting back_populates relationship " + str(relation[1]))
# # if relation[1].cascade.delete: # Relationship has a cascade delete
# # Item has a delete_at field (thus supports soft-delete)
# if deleted_field_name in relation[1].entity.columns.keys():
# # Cascade undelete - must manually query to get deleted rows
# remote_primary_key = list(relation[1].remote_side)[0].name
# local_primary_key = list(relation[1].local_columns)[0].name
# self_key_value = getattr(_self, local_primary_key)
# if not self_key_value:
# continue
# related_items = relation[1].entity.class_.query.execution_options(include_deleted=True).\
# filter(text(remote_primary_key + '=' + str(self_key_value))).all()
# for item in related_items:
# item_undeleter = getattr(item, undelete_method_name)
# item_undeleter()
# continue
# print("Skipped undelete")
# _self.handle_include_deleted_flag(False)

class_attributes[undelete_method_name] = undelete_method

Expand Down
7 changes: 5 additions & 2 deletions teraserver/python/opentera/db/models/TeraSession.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ class TeraSession(BaseModel, SoftDeleteMixin):
back_populates="participant_sessions", lazy="selectin")
session_users = relationship("TeraUser", secondary="t_sessions_users", back_populates="user_sessions",
lazy="selectin")
session_devices = relationship("TeraDevice", secondary="t_sessions_devices",
back_populates="device_sessions", lazy="selectin")
session_devices = relationship("TeraDevice", secondary="t_sessions_devices", back_populates="device_sessions",
lazy="selectin")

session_creator_user = relationship('TeraUser')
session_creator_device = relationship('TeraDevice')
Expand Down Expand Up @@ -457,3 +457,6 @@ def update(cls, update_id: int, values: dict):
# Dumps dictionnary into json
values['session_parameters'] = json.dumps(values['session_parameters'])
super().update(update_id=update_id, values=values)

def get_undelete_cascade_relations(self):
return ['session_events', 'session_assets', 'session_tests']
10 changes: 5 additions & 5 deletions teraserver/python/tests/opentera/db/models/BaseModelsTest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ def setUpClass(cls):
cls._flask_app.config.update({'PROPAGATE_EXCEPTIONS': True})
cls._db_man = DBManager(cls._config, app=cls._flask_app)
# Setup DB in RAM
# filename = 'D:\\temp\\opentera.db'
# import os
# os.remove(filename)
# cls._db_man.open_local({'filename': filename}, echo=False, ram=False)
cls._db_man.open_local({}, echo=False, ram=True)
filename = 'D:\\temp\\opentera.db'
import os
os.remove(filename)
cls._db_man.open_local({'filename': filename}, echo=False, ram=False)
# cls._db_man.open_local({}, echo=False, ram=True)

# Creating default users / tests. Time-consuming, only once per test file.
with cls._flask_app.app_context():
Expand Down
65 changes: 51 additions & 14 deletions teraserver/python/tests/opentera/db/models/test_TeraAsset.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from opentera.db.models.TeraUser import TeraUser
from opentera.db.models.TeraParticipant import TeraParticipant
from tests.opentera.db.models.BaseModelsTest import BaseModelsTest
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.exc import IntegrityError


class TeraAssetTest(BaseModelsTest):
Expand Down Expand Up @@ -316,17 +316,13 @@ def test_undelete(self):

# Create new asset
asset = TeraAssetTest.new_test_asset(id_session=ses.id_session,
service_uuid=TeraService.get_service_by_id(1).service_uuid)
service_uuid=TeraService.get_service_by_id(1).service_uuid,
id_user=id_user, id_participant=id_participant, id_device=id_device)
self.assertIsNotNone(asset.id_asset)
id_asset = asset.id_asset

# Delete
# Asset will be deleted with the session
TeraSession.delete(id_session)
TeraParticipant.delete(id_participant)
TeraDevice.delete(id_device)
TeraUser.delete(id_user)
# TeraAsset.delete(id_asset)
TeraAsset.delete(id_asset)
# Make sure it is deleted
# Warning, it was deleted, object is not valid anymore
self.assertIsNone(TeraAsset.get_asset_by_id(id_asset))
Expand All @@ -340,14 +336,55 @@ def test_undelete(self):
self.assertIsNotNone(asset)
self.assertIsNone(asset.deleted_at)

ses = TeraSession.get_session_by_id(id_session)
self.assertIsNotNone(ses)
# Now, delete again but with its dependencies...
# Asset will be deleted with the session
TeraSession.delete(id_session)
TeraParticipant.delete(id_participant)
TeraDevice.delete(id_device)
TeraUser.delete(id_user)

# Exception should be thrown when trying to undelete
with self.assertRaises(IntegrityError) as cm:
TeraAsset.undelete(id_asset)

# Restore participant
TeraParticipant.undelete(id_participant)
participant = TeraParticipant.get_participant_by_id(id_participant)
self.assertIsNotNone(participant)

# Restore asset - still has dependencies issues...
with self.assertRaises(IntegrityError) as cm:
TeraAsset.undelete(id_asset)

# Restore user
TeraUser.undelete(id_user)
user = TeraUser.get_user_by_id(id_user)
self.assertIsNotNone(user)

# Restore asset - still has dependencies issues...
with self.assertRaises(IntegrityError) as cm:
TeraAsset.undelete(id_asset)

# Restore device
TeraDevice.undelete(id_device)
device = TeraDevice.get_device_by_id(id_device)
self.assertIsNotNone(device)
participant = TeraParticipant.get_participant_by_id(id_participant)
self.assertIsNotNone(participant)

# Restore asset - still has dependencies issues...
with self.assertRaises(IntegrityError) as cm:
TeraAsset.undelete(id_asset)

# Restore session
TeraSession.undelete(id_session)

ses = TeraSession.get_session_by_id(id_session)
self.assertIsNotNone(ses)

# Asset was restored with the session...
self.db.session.expire_all()
asset = TeraAsset.get_asset_by_id(id_asset)
self.assertIsNotNone(asset)
self.assertIsNone(asset.deleted_at)

@staticmethod
def new_test_asset(id_session: int, service_uuid: str, id_device: int | None = None,
Expand All @@ -361,9 +398,9 @@ def new_test_asset(id_session: int, service_uuid: str, id_device: int | None = N
if id_participant:
asset.id_participant = id_participant
if id_user:
asset.id_user = id_user,
asset.id_user = id_user
if id_service:
asset.id_service = id_service,
asset.id_service = id_service
asset.asset_service_uuid = service_uuid
asset.asset_type = 'application/test'
TeraAsset.insert(asset)
Expand Down
Loading

0 comments on commit cbe916e

Please sign in to comment.