From 581a4ec432b58625a21094a54e5db9793f3cd667 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Thu, 28 May 2026 15:25:09 +0100 Subject: [PATCH 1/7] Add historic skeleton restore endpoint --- .../applications/catmaid/control/skeleton.py | 137 +++++++++++++++- .../catmaid/control/transaction.py | 4 + django/applications/catmaid/history.py | 155 +++++++++++++++--- django/applications/catmaid/locks.py | 2 + .../catmaid/tests/apis/test_skeletons.py | 117 ++++++++++++- django/applications/catmaid/urls.py | 3 +- 6 files changed, 391 insertions(+), 27 deletions(-) diff --git a/django/applications/catmaid/control/skeleton.py b/django/applications/catmaid/control/skeleton.py index cc31a517bd..77382e415e 100644 --- a/django/applications/catmaid/control/skeleton.py +++ b/django/applications/catmaid/control/skeleton.py @@ -16,7 +16,7 @@ from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest, Http404, \ JsonResponse, StreamingHttpResponse from django.shortcuts import get_object_or_404 -from django.db import connection +from django.db import connection, transaction from django.db.models import Q from django.views.decorators.cache import never_cache from django.utils.decorators import method_decorator @@ -26,7 +26,9 @@ from rest_framework.response import Response from rest_framework.views import APIView -from catmaid.history import add_log_entry +from catmaid import locks +from catmaid.history import add_log_entry, Transaction, \ + find_latest_deleted_skeleton_transaction, undelete_neuron from catmaid.control import tracing from catmaid.models import (Project, UserRole, Class, ClassInstance, Review, ClassInstanceClassInstance, Relation, Sampler, Treenode, @@ -37,7 +39,7 @@ compartmentalize_skeletongroup_by_confidence from catmaid.control.authentication import check_user_role, requires_user_role, \ can_edit_class_instance_or_fail, can_edit_or_fail, can_edit_all_or_fail, \ - PermissionError + PermissionError, user_domain from catmaid.control.common import (insert_into_log, get_class_to_id_map, get_relation_to_id_map, _create_relation, get_request_bool, get_request_list, Echo, get_last_concept_id) @@ -5152,6 +5154,135 @@ def post(self, request:Request, project_id, skeleton_id) -> JsonResponse: }) +RESTORABLE_SKELETON_DELETE_LABELS = { + 'neurons.remove', + 'skeletons.remove', +} + + +def can_restore_historic_skeleton_or_fail(user, project_id, tx): + """Check edit rights against owners of rows restored from a history tx.""" + if user.is_superuser: + return True + + cursor = connection.cursor() + cursor.execute(""" + WITH historic_owner AS ( + SELECT user_id AS owner_id + FROM class_instance__history + WHERE project_id = %(project_id)s + AND exec_transaction_id = %(tx_id)s + AND upper(sys_period) = %(tx_time)s + UNION + SELECT user_id AS owner_id + FROM class_instance_class_instance__history + WHERE project_id = %(project_id)s + AND exec_transaction_id = %(tx_id)s + AND upper(sys_period) = %(tx_time)s + UNION + SELECT user_id AS owner_id + FROM treenode__history + WHERE project_id = %(project_id)s + AND exec_transaction_id = %(tx_id)s + AND upper(sys_period) = %(tx_time)s + UNION + SELECT user_id AS owner_id + FROM treenode_class_instance__history + WHERE project_id = %(project_id)s + AND exec_transaction_id = %(tx_id)s + AND upper(sys_period) = %(tx_time)s + UNION + SELECT user_id AS owner_id + FROM treenode_connector__history + WHERE project_id = %(project_id)s + AND exec_transaction_id = %(tx_id)s + AND upper(sys_period) = %(tx_time)s + UNION + SELECT reviewer_id AS owner_id + FROM review__history + WHERE project_id = %(project_id)s + AND exec_transaction_id = %(tx_id)s + AND upper(sys_period) = %(tx_time)s + ) + SELECT DISTINCT owner_id + FROM historic_owner + WHERE owner_id IS NOT NULL + """, { + 'project_id': project_id, + 'tx_id': tx.id, + 'tx_time': tx.time, + }) + historic_owner_ids = set(row[0] for row in cursor.fetchall()) + if not historic_owner_ids: + raise PermissionError("Could not determine owners of historic skeleton data") + + missing_owner_ids = historic_owner_ids.difference(user_domain(cursor, user.id)) + if missing_owner_ids: + raise PermissionError( + f"User {user.username} cannot restore skeleton data owned by " + f"user IDs {sorted(missing_owner_ids)}") + + return True + + +@api_view(['POST']) +@requires_user_role(UserRole.Annotate) +def restore_historic_skeleton(request:HttpRequest, project_id, skeleton_id): + """Restore the latest deleted historic version of a single skeleton.""" + project_id = int(project_id) + skeleton_id = int(skeleton_id) + + with transaction.atomic(): + cursor = connection.cursor() + cursor.execute(""" + SELECT pg_advisory_xact_lock(%(lock_id)s::bigint) + """, { + 'lock_id': locks.skeleton_restore_lock, + }) + cursor.execute("SET LOCAL catmaid.user_id=%(user_id)s", { + 'user_id': request.user.id, + }) + + if ClassInstance.objects.filter(pk=skeleton_id).exists(): + raise ValueError(f"An object with ID {skeleton_id} already exists") + + restore_info = find_latest_deleted_skeleton_transaction( + project_id, skeleton_id) + if not restore_info: + raise ValueError( + f"No deleted historic skeleton found for skeleton {skeleton_id}") + + source_label = restore_info['label'] + if source_label and source_label not in RESTORABLE_SKELETON_DELETE_LABELS: + raise ValueError( + f"Latest historic transaction for skeleton {skeleton_id} has " + f"non-restoreable label {source_label}") + + restored_skeleton_set = set(restore_info['skeleton_ids']) + if restored_skeleton_set != {skeleton_id}: + raise ValueError( + f"Historic transaction affects skeletons " + f"{sorted(restored_skeleton_set)}, refusing to restore only " + f"skeleton {skeleton_id}") + + tx = Transaction(restore_info['transaction_id'], + restore_info['execution_time']) + can_restore_historic_skeleton_or_fail(request.user, project_id, tx) + + restored_skeleton_ids = undelete_neuron(project_id, tx, + user_id=request.user.id, + expected_skeleton_ids={skeleton_id}) + + return JsonResponse({ + 'skeleton_id': skeleton_id, + 'restored_skeleton_ids': restored_skeleton_ids, + 'transaction_id': restore_info['transaction_id'], + 'execution_time': restore_info['execution_time'], + 'source_label': source_label, + 'success': f"Restored skeleton {skeleton_id} from history.", + }) + + @api_view(['POST']) @requires_user_role(UserRole.Annotate) def delete_skeleton(request:HttpRequest, project_id, skeleton_id): diff --git a/django/applications/catmaid/control/transaction.py b/django/applications/catmaid/control/transaction.py index f54bb6538e..2fd3eaae5e 100644 --- a/django/applications/catmaid/control/transaction.py +++ b/django/applications/catmaid/control/transaction.py @@ -404,6 +404,8 @@ def get(self): ORDER BY t.edition_time DESC LIMIT 1; """), + 'skeletons.remove': QueryRef(location_queries, "neurons.remove"), + 'skeletons.restore': QueryRef(location_queries, "nodes.update_location"), 'textlabels.create': HistoryQuery(""" SELECT t.location_x, t.location_y, t.location_z FROM textlabel{history} t @@ -537,6 +539,8 @@ def get(self): WHERE so.{txid} = %s AND t.skeleton_id = so.skeleton_id ORDER BY t.edition_time DESC """), + 'skeletons.remove': QueryRef(skeleton_queries, "neurons.remove"), + 'skeletons.restore': QueryRef(skeleton_queries, "nodes.update_location"), 'textlabels.create': HistoryQuery(""" SELECT t.skeleton_id FROM textlabel{history} t diff --git a/django/applications/catmaid/history.py b/django/applications/catmaid/history.py index 1feb83fa78..a1026320a0 100644 --- a/django/applications/catmaid/history.py +++ b/django/applications/catmaid/history.py @@ -153,6 +153,113 @@ def __str__(self): return "TX {} @ {}".format(self.id, self.time) +def find_latest_deleted_skeleton_transaction(project_id, skeleton_id): + """Find the newest transaction that removed the passed in skeleton. + + This intentionally only identifies a candidate. Callers still have to + decide whether the transaction label and affected skeleton set are safe for + their restore use case. + """ + cursor = connection.cursor() + cursor.execute(""" + WITH candidates AS ( + SELECT ci.exec_transaction_id AS transaction_id, + upper(ci.sys_period) AS execution_time + FROM class_instance__history ci + JOIN class c + ON c.id = ci.class_id + AND c.project_id = ci.project_id + AND c.class_name = 'skeleton' + WHERE ci.project_id = %(project_id)s + AND ci.id = %(skeleton_id)s + AND ci.sys_period IS NOT NULL + AND NOT upper_inf(ci.sys_period) + GROUP BY ci.exec_transaction_id, upper(ci.sys_period) + ), + latest AS ( + SELECT transaction_id, execution_time + FROM candidates + ORDER BY execution_time DESC + LIMIT 1 + ) + SELECT latest.transaction_id, + latest.execution_time::text, + cti.label + FROM latest + LEFT JOIN catmaid_transaction_info cti + ON cti.transaction_id = latest.transaction_id + AND cti.execution_time = latest.execution_time + """, { + 'project_id': project_id, + 'skeleton_id': skeleton_id, + }) + result = cursor.fetchone() + if not result: + return None + + return { + 'transaction_id': result[0], + 'execution_time': result[1], + 'label': result[2], + 'skeleton_ids': find_skeletons_affected_by_deletion_transaction( + project_id, Transaction(result[0], result[1])), + } + + +def find_skeletons_affected_by_deletion_transaction(project_id, tx): + """Find skeletons touched by a skeleton/neuron deletion transaction. + + This deliberately uses skeleton class instances and model_of links rather + than treenodes so empty skeletons and multi-skeleton neuron deletions are + represented correctly. + """ + cursor = connection.cursor() + cursor.execute(""" + WITH skeleton_class AS ( + SELECT id + FROM class + WHERE project_id = %(project_id)s + AND class_name = 'skeleton' + ), + model_of AS ( + SELECT id + FROM relation + WHERE project_id = %(project_id)s + AND relation_name = 'model_of' + ), + affected_skeleton AS ( + SELECT ci.id AS skeleton_id + FROM class_instance__history ci + JOIN skeleton_class sc + ON sc.id = ci.class_id + WHERE ci.project_id = %(project_id)s + AND ci.exec_transaction_id = %(tx_id)s + AND ci.sys_period IS NOT NULL + AND NOT upper_inf(ci.sys_period) + AND upper(ci.sys_period) >= %(tx_time)s + UNION + SELECT cici.class_instance_a AS skeleton_id + FROM class_instance_class_instance__history cici + JOIN model_of mo + ON mo.id = cici.relation_id + WHERE cici.project_id = %(project_id)s + AND cici.exec_transaction_id = %(tx_id)s + AND cici.sys_period IS NOT NULL + AND NOT upper_inf(cici.sys_period) + AND upper(cici.sys_period) >= %(tx_time)s + ) + SELECT DISTINCT skeleton_id + FROM affected_skeleton + WHERE skeleton_id IS NOT NULL + ORDER BY skeleton_id + """, { + 'project_id': project_id, + 'tx_id': tx.id, + 'tx_time': tx.time, + }) + return [row[0] for row in cursor.fetchall()] + + def get_historic_row_count_affected_by_tx(tx): """Counts how many historic rows reference the passed in transaction. Returned is a list of tuples (table_name, count). @@ -269,7 +376,8 @@ def get_dependent_historic_tx(tx, target_list=None): return target_list -def undelete_neuron(project_id, tx, user_id=None, interactive=False): +def undelete_neuron(project_id, tx, user_id=None, interactive=False, + expected_skeleton_ids=None): """Recreates a neuron and its connections. This simply restores everything from a delete.neuron transaction. Some materialized views as treenode_connector_edge or treenode_edge need to be recreated selectively @@ -281,9 +389,6 @@ def undelete_neuron(project_id, tx, user_id=None, interactive=False): user_id = get_system_user().id logger.info('No user ID provided, working as system user') - # Transaction log entry - add_log_entry(user_id, 'neurons.undelete', project_id) - tx_matches = get_historic_row_count_affected_by_tx(tx) if interactive: @@ -296,6 +401,32 @@ def undelete_neuron(project_id, tx, user_id=None, interactive=False): cursor = connection.cursor() nr_notices = len(cursor.connection.notices) + + skeleton_ids = find_skeletons_affected_by_deletion_transaction( + project_id, tx) + if not skeleton_ids: + cursor.execute(""" + SELECT DISTINCT skeleton_id + FROM treenode__history th + WHERE th.exec_transaction_id = %(tx_id)s + AND upper(th.sys_period) >= %(tx_time)s + ORDER BY skeleton_id + """, { + 'tx_id': tx.id, + 'tx_time': tx.time, + }) + skeleton_ids = [r[0] for r in cursor.fetchall()] + if not skeleton_ids: + raise ValueError(f"No skeletons found in historic transaction {tx}") + + if expected_skeleton_ids is not None and \ + set(skeleton_ids) != set(expected_skeleton_ids): + raise ValueError(f"Historic transaction {tx} affects skeletons " + f"{skeleton_ids}, expected {sorted(expected_skeleton_ids)}") + + # Transaction log entry + add_log_entry(user_id, 'neurons.undelete', project_id) + cursor.execute(""" DO $$ DECLARE @@ -304,16 +435,6 @@ def undelete_neuron(project_id, tx, user_id=None, interactive=False): BEGIN - CREATE TEMPORARY TABLE seen_skeleton ( - id bigint - ); - - INSERT INTO seen_skeleton - SELECT DISTINCT skeleton_id - FROM treenode__history th - WHERE th.exec_transaction_id = %(tx_id)s - AND upper(th.sys_period) >= %(tx_time)s; - FOR row IN SELECT format('INSERT INTO %%1$s (', cht.live_table) || array_to_string(array_agg(column_name::text order by pos), ',') || ') SELECT ' || @@ -338,17 +459,11 @@ def undelete_neuron(project_id, tx, user_id=None, interactive=False): END $$; - - SELECT id FROM seen_skeleton; """, { 'tx_id': tx.id, 'tx_time': tx.time, }) - skeleton_ids = [r[0] for r in cursor.fetchall()] - - cursor.execute('DROP TABLE seen_skeleton') - for notice in cursor.connection.notices: logger.debug(f'NOTICE: {notice}') diff --git a/django/applications/catmaid/locks.py b/django/applications/catmaid/locks.py index 4663f2fdff..05059cb7ee 100644 --- a/django/applications/catmaid/locks.py +++ b/django/applications/catmaid/locks.py @@ -6,3 +6,5 @@ spatial_update_event_lock = base_lock_id + 1 # Postgres advisory lock ID to update history update even handling history_update_event_lock = base_lock_id + 2 +# Postgres advisory lock ID to serialize historic skeleton restores +skeleton_restore_lock = base_lock_id + 3 diff --git a/django/applications/catmaid/tests/apis/test_skeletons.py b/django/applications/catmaid/tests/apis/test_skeletons.py index 3b4d4a3661..99a1fb55ee 100644 --- a/django/applications/catmaid/tests/apis/test_skeletons.py +++ b/django/applications/catmaid/tests/apis/test_skeletons.py @@ -15,9 +15,9 @@ from catmaid.control.annotation import _annotate_entities, annotations_for_skeleton from catmaid.control.skeleton import _get_neuronname_from_skeletonid from catmaid.models import ( - ClassInstance, ClassInstanceClassInstance, Log, Review, TreenodeConnector, - ReviewerWhitelist, Treenode, User, ClientDatastore, ClientData, - TreenodeClassInstance + Class, ClassInstance, ClassInstanceClassInstance, Log, Relation, Review, + SkeletonSummary, TreenodeConnector, ReviewerWhitelist, Treenode, User, + ClientDatastore, ClientData, TreenodeClassInstance ) from .common import CatmaidApiTestCase, CatmaidApiTransactionTestCase @@ -1411,6 +1411,32 @@ def test_skeleton_id_change(self): class SkeletonsApiTransactionTests(CatmaidApiTransactionTestCase): + def create_extra_skeleton_for_neuron(self, neuron_id): + skeleton_class = Class.objects.get(project_id=self.test_project_id, + class_name='skeleton') + model_of = Relation.objects.get(project_id=self.test_project_id, + relation_name='model_of') + skeleton = ClassInstance.objects.create(user=self.test_user, + project=self.test_project, class_column=skeleton_class, + name='extra test skeleton') + Treenode.objects.create(user=self.test_user, editor=self.test_user, + project=self.test_project, location_x=1, location_y=2, + location_z=3, parent=None, radius=-1, confidence=5, + skeleton=skeleton) + ClassInstanceClassInstance.objects.create(user=self.test_user, + project=self.test_project, relation=model_of, + class_instance_a=skeleton, class_instance_b_id=neuron_id) + return skeleton + + def transaction_label_count(self, label): + cursor = connection.cursor() + cursor.execute(""" + SELECT COUNT(*) + FROM catmaid_transaction_info + WHERE project_id = %s + AND label = %s + """, (self.test_project_id, label)) + return cursor.fetchone()[0] def test_import_skeleton(self): self.fake_authentication() @@ -2003,3 +2029,88 @@ def test_skeleton_deletion(self): self.assertEqual(0, TreenodeClassInstance.objects.filter(id=353).count()) self.assertEqual(log_count + 1, count_logs()) + + def test_restore_historic_skeleton(self): + self.fake_authentication() + skeleton_id = 1 + neuron_id = 2 + n_treenodes = Treenode.objects.filter(skeleton_id=skeleton_id).count() + skeleton_remove_count = self.transaction_label_count('skeletons.remove') + skeleton_restore_count = self.transaction_label_count('skeletons.restore') + + response = self.client.post( + '/%d/skeletons/%s/delete' % (self.test_project_id, skeleton_id)) + self.assertStatus(response) + self.assertEqual(skeleton_remove_count + 1, + self.transaction_label_count('skeletons.remove')) + + response = self.client.post( + '/%d/skeletons/%s/restore' % (self.test_project_id, skeleton_id)) + self.assertStatus(response) + parsed_response = json.loads(response.content.decode('utf-8')) + + self.assertEqual(skeleton_id, parsed_response['skeleton_id']) + self.assertEqual([skeleton_id], + parsed_response['restored_skeleton_ids']) + self.assertEqual('skeletons.remove', parsed_response['source_label']) + + self.assertEqual(n_treenodes, + Treenode.objects.filter(skeleton_id=skeleton_id).count()) + self.assertTrue(ClassInstance.objects.filter(id=skeleton_id).exists()) + self.assertTrue(ClassInstance.objects.filter(id=neuron_id).exists()) + self.assertTrue(ClassInstanceClassInstance.objects.filter( + class_instance_a=skeleton_id, class_instance_b=neuron_id, + relation__relation_name='model_of').exists()) + self.assertEqual(n_treenodes, + SkeletonSummary.objects.get(skeleton_id=skeleton_id).num_nodes) + + cursor = connection.cursor() + cursor.execute(""" + SELECT COUNT(*) + FROM treenode_edge te + JOIN treenode t + ON t.id = te.id + WHERE t.skeleton_id = %s + """, (skeleton_id,)) + self.assertEqual(n_treenodes, cursor.fetchone()[0]) + self.assertEqual(skeleton_restore_count + 1, + self.transaction_label_count('skeletons.restore')) + + def test_restore_historic_skeleton_requires_historic_edit_permission(self): + self.fake_authentication() + skeleton_id = 1 + + response = self.client.post( + '/%d/skeletons/%s/delete' % (self.test_project_id, skeleton_id)) + self.assertStatus(response) + + self.fake_authentication(username='test0', + add_default_permissions=True) + response = self.client.post( + '/%d/skeletons/%s/restore' % (self.test_project_id, skeleton_id)) + self.assertStatus(response, 403) + self.assertFalse(ClassInstance.objects.filter(id=skeleton_id).exists()) + + def test_restore_historic_skeleton_rejects_multi_skeleton_transaction(self): + self.fake_authentication() + skeleton_id = 1 + neuron_id = 2 + extra_skeleton = self.create_extra_skeleton_for_neuron(neuron_id) + + response = self.client.post( + '/%d/neuron/%s/delete' % (self.test_project_id, neuron_id)) + self.assertStatus(response) + + response = self.client.post( + '/%d/skeletons/%s/restore' % (self.test_project_id, skeleton_id)) + self.assertStatus(response, 400) + self.assertFalse(ClassInstance.objects.filter(id=skeleton_id).exists()) + self.assertFalse(ClassInstance.objects.filter(id=extra_skeleton.id).exists()) + + def test_restore_historic_skeleton_rejects_live_skeleton(self): + self.fake_authentication() + skeleton_id = 1 + + response = self.client.post( + '/%d/skeletons/%s/restore' % (self.test_project_id, skeleton_id)) + self.assertStatus(response, 400) diff --git a/django/applications/catmaid/urls.py b/django/applications/catmaid/urls.py index c725a29620..79e7f1b2bc 100644 --- a/django/applications/catmaid/urls.py +++ b/django/applications/catmaid/urls.py @@ -345,7 +345,8 @@ re_path(r'^(?P\d+)/skeletons/(?P\d+)/sampler-count$', skeleton.sampler_count), re_path(r'^(?P\d+)/skeletons/(?P\d+)/cable-length$', skeleton.cable_length), re_path(r'^(?P\d+)/skeletons/(?P\d+)/neuron-details$', skeleton.neurondetails), - re_path(r'^(?P\d+)/skeletons/(?P\d+)/delete$', skeleton.delete_skeleton), + re_path(r'^(?P\d+)/skeletons/(?P\d+)/delete$', record_view("skeletons.remove")(skeleton.delete_skeleton)), + re_path(r'^(?P\d+)/skeletons/(?P\d+)/restore$', record_view("skeletons.restore")(skeleton.restore_historic_skeleton)), re_path(r'^(?P\d+)/skeleton/split$', record_view("skeletons.split")(skeleton.split_skeleton)), re_path(r'^(?P\d+)/skeleton/ancestry$', skeleton.skeleton_ancestry), re_path(r'^(?P\d+)/skeleton/join$', record_view("skeletons.merge")(skeleton.join_skeleton)), From ed5ded50867143ee334885d1d19ee5c8cd228af0 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Thu, 28 May 2026 16:18:38 +0100 Subject: [PATCH 2/7] feat: Remove custom can_restore_historic_skeleton_or_fail --- .../applications/catmaid/control/skeleton.py | 68 +------------------ .../catmaid/tests/apis/test_skeletons.py | 15 ---- 2 files changed, 1 insertion(+), 82 deletions(-) diff --git a/django/applications/catmaid/control/skeleton.py b/django/applications/catmaid/control/skeleton.py index 77382e415e..a50624ae7a 100644 --- a/django/applications/catmaid/control/skeleton.py +++ b/django/applications/catmaid/control/skeleton.py @@ -39,7 +39,7 @@ compartmentalize_skeletongroup_by_confidence from catmaid.control.authentication import check_user_role, requires_user_role, \ can_edit_class_instance_or_fail, can_edit_or_fail, can_edit_all_or_fail, \ - PermissionError, user_domain + PermissionError from catmaid.control.common import (insert_into_log, get_class_to_id_map, get_relation_to_id_map, _create_relation, get_request_bool, get_request_list, Echo, get_last_concept_id) @@ -5160,71 +5160,6 @@ def post(self, request:Request, project_id, skeleton_id) -> JsonResponse: } -def can_restore_historic_skeleton_or_fail(user, project_id, tx): - """Check edit rights against owners of rows restored from a history tx.""" - if user.is_superuser: - return True - - cursor = connection.cursor() - cursor.execute(""" - WITH historic_owner AS ( - SELECT user_id AS owner_id - FROM class_instance__history - WHERE project_id = %(project_id)s - AND exec_transaction_id = %(tx_id)s - AND upper(sys_period) = %(tx_time)s - UNION - SELECT user_id AS owner_id - FROM class_instance_class_instance__history - WHERE project_id = %(project_id)s - AND exec_transaction_id = %(tx_id)s - AND upper(sys_period) = %(tx_time)s - UNION - SELECT user_id AS owner_id - FROM treenode__history - WHERE project_id = %(project_id)s - AND exec_transaction_id = %(tx_id)s - AND upper(sys_period) = %(tx_time)s - UNION - SELECT user_id AS owner_id - FROM treenode_class_instance__history - WHERE project_id = %(project_id)s - AND exec_transaction_id = %(tx_id)s - AND upper(sys_period) = %(tx_time)s - UNION - SELECT user_id AS owner_id - FROM treenode_connector__history - WHERE project_id = %(project_id)s - AND exec_transaction_id = %(tx_id)s - AND upper(sys_period) = %(tx_time)s - UNION - SELECT reviewer_id AS owner_id - FROM review__history - WHERE project_id = %(project_id)s - AND exec_transaction_id = %(tx_id)s - AND upper(sys_period) = %(tx_time)s - ) - SELECT DISTINCT owner_id - FROM historic_owner - WHERE owner_id IS NOT NULL - """, { - 'project_id': project_id, - 'tx_id': tx.id, - 'tx_time': tx.time, - }) - historic_owner_ids = set(row[0] for row in cursor.fetchall()) - if not historic_owner_ids: - raise PermissionError("Could not determine owners of historic skeleton data") - - missing_owner_ids = historic_owner_ids.difference(user_domain(cursor, user.id)) - if missing_owner_ids: - raise PermissionError( - f"User {user.username} cannot restore skeleton data owned by " - f"user IDs {sorted(missing_owner_ids)}") - - return True - - @api_view(['POST']) @requires_user_role(UserRole.Annotate) def restore_historic_skeleton(request:HttpRequest, project_id, skeleton_id): @@ -5267,7 +5202,6 @@ def restore_historic_skeleton(request:HttpRequest, project_id, skeleton_id): tx = Transaction(restore_info['transaction_id'], restore_info['execution_time']) - can_restore_historic_skeleton_or_fail(request.user, project_id, tx) restored_skeleton_ids = undelete_neuron(project_id, tx, user_id=request.user.id, diff --git a/django/applications/catmaid/tests/apis/test_skeletons.py b/django/applications/catmaid/tests/apis/test_skeletons.py index 99a1fb55ee..cea60b4b8f 100644 --- a/django/applications/catmaid/tests/apis/test_skeletons.py +++ b/django/applications/catmaid/tests/apis/test_skeletons.py @@ -2076,21 +2076,6 @@ def test_restore_historic_skeleton(self): self.assertEqual(skeleton_restore_count + 1, self.transaction_label_count('skeletons.restore')) - def test_restore_historic_skeleton_requires_historic_edit_permission(self): - self.fake_authentication() - skeleton_id = 1 - - response = self.client.post( - '/%d/skeletons/%s/delete' % (self.test_project_id, skeleton_id)) - self.assertStatus(response) - - self.fake_authentication(username='test0', - add_default_permissions=True) - response = self.client.post( - '/%d/skeletons/%s/restore' % (self.test_project_id, skeleton_id)) - self.assertStatus(response, 403) - self.assertFalse(ClassInstance.objects.filter(id=skeleton_id).exists()) - def test_restore_historic_skeleton_rejects_multi_skeleton_transaction(self): self.fake_authentication() skeleton_id = 1 From fd780ddfc22804dae2bb2dab35f3eab44e5def94 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Thu, 28 May 2026 17:25:23 +0100 Subject: [PATCH 3/7] feat: Reject unlabeled historic delete candidates --- .../applications/catmaid/control/skeleton.py | 4 +-- .../catmaid/tests/apis/test_skeletons.py | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/django/applications/catmaid/control/skeleton.py b/django/applications/catmaid/control/skeleton.py index a50624ae7a..621b30d376 100644 --- a/django/applications/catmaid/control/skeleton.py +++ b/django/applications/catmaid/control/skeleton.py @@ -5188,10 +5188,10 @@ def restore_historic_skeleton(request:HttpRequest, project_id, skeleton_id): f"No deleted historic skeleton found for skeleton {skeleton_id}") source_label = restore_info['label'] - if source_label and source_label not in RESTORABLE_SKELETON_DELETE_LABELS: + if source_label not in RESTORABLE_SKELETON_DELETE_LABELS: raise ValueError( f"Latest historic transaction for skeleton {skeleton_id} has " - f"non-restoreable label {source_label}") + f"missing or unsupported label {source_label}") restored_skeleton_set = set(restore_info['skeleton_ids']) if restored_skeleton_set != {skeleton_id}: diff --git a/django/applications/catmaid/tests/apis/test_skeletons.py b/django/applications/catmaid/tests/apis/test_skeletons.py index cea60b4b8f..ae816374a2 100644 --- a/django/applications/catmaid/tests/apis/test_skeletons.py +++ b/django/applications/catmaid/tests/apis/test_skeletons.py @@ -2092,6 +2092,42 @@ def test_restore_historic_skeleton_rejects_multi_skeleton_transaction(self): self.assertFalse(ClassInstance.objects.filter(id=skeleton_id).exists()) self.assertFalse(ClassInstance.objects.filter(id=extra_skeleton.id).exists()) + def test_restore_historic_skeleton_rejects_unlabeled_transaction(self): + self.fake_authentication() + skeleton_id = 1 + + response = self.client.post( + '/%d/skeletons/%s/delete' % (self.test_project_id, skeleton_id)) + self.assertStatus(response) + + cursor = connection.cursor() + cursor.execute(""" + WITH latest AS ( + SELECT ci.exec_transaction_id AS transaction_id, + upper(ci.sys_period) AS execution_time + FROM class_instance__history ci + JOIN class c + ON c.id = ci.class_id + AND c.project_id = ci.project_id + AND c.class_name = 'skeleton' + WHERE ci.project_id = %s + AND ci.id = %s + AND ci.sys_period IS NOT NULL + AND NOT upper_inf(ci.sys_period) + ORDER BY upper(ci.sys_period) DESC + LIMIT 1 + ) + DELETE FROM catmaid_transaction_info cti + USING latest + WHERE cti.transaction_id = latest.transaction_id + AND cti.execution_time = latest.execution_time + """, (self.test_project_id, skeleton_id)) + + response = self.client.post( + '/%d/skeletons/%s/restore' % (self.test_project_id, skeleton_id)) + self.assertStatus(response, 400) + self.assertFalse(ClassInstance.objects.filter(id=skeleton_id).exists()) + def test_restore_historic_skeleton_rejects_live_skeleton(self): self.fake_authentication() skeleton_id = 1 From deb6987252fb3f7ca7acde6b4396c805ad266254 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Thu, 28 May 2026 19:27:56 +0100 Subject: [PATCH 4/7] feat: Simplify find_latest_deleted_skeleton_transaction --- .../applications/catmaid/control/skeleton.py | 15 +- django/applications/catmaid/history.py | 143 +++++++----------- .../catmaid/tests/apis/test_skeletons.py | 3 +- 3 files changed, 55 insertions(+), 106 deletions(-) diff --git a/django/applications/catmaid/control/skeleton.py b/django/applications/catmaid/control/skeleton.py index 621b30d376..17d8e2c032 100644 --- a/django/applications/catmaid/control/skeleton.py +++ b/django/applications/catmaid/control/skeleton.py @@ -5185,7 +5185,8 @@ def restore_historic_skeleton(request:HttpRequest, project_id, skeleton_id): project_id, skeleton_id) if not restore_info: raise ValueError( - f"No deleted historic skeleton found for skeleton {skeleton_id}") + f"No single-skeleton deleted historic skeleton found for " + f"skeleton {skeleton_id}") source_label = restore_info['label'] if source_label not in RESTORABLE_SKELETON_DELETE_LABELS: @@ -5193,23 +5194,13 @@ def restore_historic_skeleton(request:HttpRequest, project_id, skeleton_id): f"Latest historic transaction for skeleton {skeleton_id} has " f"missing or unsupported label {source_label}") - restored_skeleton_set = set(restore_info['skeleton_ids']) - if restored_skeleton_set != {skeleton_id}: - raise ValueError( - f"Historic transaction affects skeletons " - f"{sorted(restored_skeleton_set)}, refusing to restore only " - f"skeleton {skeleton_id}") - tx = Transaction(restore_info['transaction_id'], restore_info['execution_time']) - restored_skeleton_ids = undelete_neuron(project_id, tx, - user_id=request.user.id, - expected_skeleton_ids={skeleton_id}) + undelete_neuron(project_id, tx, user_id=request.user.id) return JsonResponse({ 'skeleton_id': skeleton_id, - 'restored_skeleton_ids': restored_skeleton_ids, 'transaction_id': restore_info['transaction_id'], 'execution_time': restore_info['execution_time'], 'source_label': source_label, diff --git a/django/applications/catmaid/history.py b/django/applications/catmaid/history.py index a1026320a0..67f11f2672 100644 --- a/django/applications/catmaid/history.py +++ b/django/applications/catmaid/history.py @@ -156,20 +156,23 @@ def __str__(self): def find_latest_deleted_skeleton_transaction(project_id, skeleton_id): """Find the newest transaction that removed the passed in skeleton. - This intentionally only identifies a candidate. Callers still have to - decide whether the transaction label and affected skeleton set are safe for - their restore use case. + Only single-skeleton delete candidates are returned. Callers still have to + decide whether the transaction label is safe for their restore use case. """ cursor = connection.cursor() cursor.execute(""" - WITH candidates AS ( + WITH skeleton_class AS ( + SELECT id + FROM class + WHERE project_id = %(project_id)s + AND class_name = 'skeleton' + ), + candidates AS ( SELECT ci.exec_transaction_id AS transaction_id, upper(ci.sys_period) AS execution_time FROM class_instance__history ci - JOIN class c - ON c.id = ci.class_id - AND c.project_id = ci.project_id - AND c.class_name = 'skeleton' + JOIN skeleton_class sc + ON sc.id = ci.class_id WHERE ci.project_id = %(project_id)s AND ci.id = %(skeleton_id)s AND ci.sys_period IS NOT NULL @@ -181,11 +184,31 @@ def find_latest_deleted_skeleton_transaction(project_id, skeleton_id): FROM candidates ORDER BY execution_time DESC LIMIT 1 + ), + affected_skeleton AS ( + SELECT ci.id AS skeleton_id + FROM class_instance__history ci + JOIN skeleton_class sc + ON sc.id = ci.class_id + JOIN latest + ON latest.transaction_id = ci.exec_transaction_id + WHERE ci.project_id = %(project_id)s + AND ci.sys_period IS NOT NULL + AND NOT upper_inf(ci.sys_period) + AND upper(ci.sys_period) >= latest.execution_time + ), + affected_summary AS ( + SELECT COUNT(DISTINCT skeleton_id) AS skeleton_count, + BOOL_OR(skeleton_id = %(skeleton_id)s) AS includes_requested + FROM affected_skeleton ) SELECT latest.transaction_id, latest.execution_time::text, cti.label FROM latest + JOIN affected_summary affected + ON affected.skeleton_count = 1 + AND affected.includes_requested LEFT JOIN catmaid_transaction_info cti ON cti.transaction_id = latest.transaction_id AND cti.execution_time = latest.execution_time @@ -201,65 +224,9 @@ def find_latest_deleted_skeleton_transaction(project_id, skeleton_id): 'transaction_id': result[0], 'execution_time': result[1], 'label': result[2], - 'skeleton_ids': find_skeletons_affected_by_deletion_transaction( - project_id, Transaction(result[0], result[1])), } -def find_skeletons_affected_by_deletion_transaction(project_id, tx): - """Find skeletons touched by a skeleton/neuron deletion transaction. - - This deliberately uses skeleton class instances and model_of links rather - than treenodes so empty skeletons and multi-skeleton neuron deletions are - represented correctly. - """ - cursor = connection.cursor() - cursor.execute(""" - WITH skeleton_class AS ( - SELECT id - FROM class - WHERE project_id = %(project_id)s - AND class_name = 'skeleton' - ), - model_of AS ( - SELECT id - FROM relation - WHERE project_id = %(project_id)s - AND relation_name = 'model_of' - ), - affected_skeleton AS ( - SELECT ci.id AS skeleton_id - FROM class_instance__history ci - JOIN skeleton_class sc - ON sc.id = ci.class_id - WHERE ci.project_id = %(project_id)s - AND ci.exec_transaction_id = %(tx_id)s - AND ci.sys_period IS NOT NULL - AND NOT upper_inf(ci.sys_period) - AND upper(ci.sys_period) >= %(tx_time)s - UNION - SELECT cici.class_instance_a AS skeleton_id - FROM class_instance_class_instance__history cici - JOIN model_of mo - ON mo.id = cici.relation_id - WHERE cici.project_id = %(project_id)s - AND cici.exec_transaction_id = %(tx_id)s - AND cici.sys_period IS NOT NULL - AND NOT upper_inf(cici.sys_period) - AND upper(cici.sys_period) >= %(tx_time)s - ) - SELECT DISTINCT skeleton_id - FROM affected_skeleton - WHERE skeleton_id IS NOT NULL - ORDER BY skeleton_id - """, { - 'project_id': project_id, - 'tx_id': tx.id, - 'tx_time': tx.time, - }) - return [row[0] for row in cursor.fetchall()] - - def get_historic_row_count_affected_by_tx(tx): """Counts how many historic rows reference the passed in transaction. Returned is a list of tuples (table_name, count). @@ -376,8 +343,7 @@ def get_dependent_historic_tx(tx, target_list=None): return target_list -def undelete_neuron(project_id, tx, user_id=None, interactive=False, - expected_skeleton_ids=None): +def undelete_neuron(project_id, tx, user_id=None, interactive=False): """Recreates a neuron and its connections. This simply restores everything from a delete.neuron transaction. Some materialized views as treenode_connector_edge or treenode_edge need to be recreated selectively @@ -389,6 +355,9 @@ def undelete_neuron(project_id, tx, user_id=None, interactive=False, user_id = get_system_user().id logger.info('No user ID provided, working as system user') + # Transaction log entry + add_log_entry(user_id, 'neurons.undelete', project_id) + tx_matches = get_historic_row_count_affected_by_tx(tx) if interactive: @@ -401,32 +370,6 @@ def undelete_neuron(project_id, tx, user_id=None, interactive=False, cursor = connection.cursor() nr_notices = len(cursor.connection.notices) - - skeleton_ids = find_skeletons_affected_by_deletion_transaction( - project_id, tx) - if not skeleton_ids: - cursor.execute(""" - SELECT DISTINCT skeleton_id - FROM treenode__history th - WHERE th.exec_transaction_id = %(tx_id)s - AND upper(th.sys_period) >= %(tx_time)s - ORDER BY skeleton_id - """, { - 'tx_id': tx.id, - 'tx_time': tx.time, - }) - skeleton_ids = [r[0] for r in cursor.fetchall()] - if not skeleton_ids: - raise ValueError(f"No skeletons found in historic transaction {tx}") - - if expected_skeleton_ids is not None and \ - set(skeleton_ids) != set(expected_skeleton_ids): - raise ValueError(f"Historic transaction {tx} affects skeletons " - f"{skeleton_ids}, expected {sorted(expected_skeleton_ids)}") - - # Transaction log entry - add_log_entry(user_id, 'neurons.undelete', project_id) - cursor.execute(""" DO $$ DECLARE @@ -435,6 +378,16 @@ def undelete_neuron(project_id, tx, user_id=None, interactive=False, BEGIN + CREATE TEMPORARY TABLE seen_skeleton ( + id bigint + ); + + INSERT INTO seen_skeleton + SELECT DISTINCT skeleton_id + FROM treenode__history th + WHERE th.exec_transaction_id = %(tx_id)s + AND upper(th.sys_period) >= %(tx_time)s; + FOR row IN SELECT format('INSERT INTO %%1$s (', cht.live_table) || array_to_string(array_agg(column_name::text order by pos), ',') || ') SELECT ' || @@ -459,11 +412,17 @@ def undelete_neuron(project_id, tx, user_id=None, interactive=False, END $$; + + SELECT id FROM seen_skeleton; """, { 'tx_id': tx.id, 'tx_time': tx.time, }) + skeleton_ids = [r[0] for r in cursor.fetchall()] + + cursor.execute('DROP TABLE seen_skeleton') + for notice in cursor.connection.notices: logger.debug(f'NOTICE: {notice}') diff --git a/django/applications/catmaid/tests/apis/test_skeletons.py b/django/applications/catmaid/tests/apis/test_skeletons.py index ae816374a2..2307fc3e95 100644 --- a/django/applications/catmaid/tests/apis/test_skeletons.py +++ b/django/applications/catmaid/tests/apis/test_skeletons.py @@ -2050,8 +2050,7 @@ def test_restore_historic_skeleton(self): parsed_response = json.loads(response.content.decode('utf-8')) self.assertEqual(skeleton_id, parsed_response['skeleton_id']) - self.assertEqual([skeleton_id], - parsed_response['restored_skeleton_ids']) + self.assertNotIn('restored_skeleton_ids', parsed_response) self.assertEqual('skeletons.remove', parsed_response['source_label']) self.assertEqual(n_treenodes, From de565b71052a684afa374fd6baa05d041a004c0c Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Fri, 29 May 2026 12:03:07 +0100 Subject: [PATCH 5/7] feat: Allow only explicit skeleton delete transactions to be restorable --- django/applications/catmaid/control/skeleton.py | 10 ++++------ .../catmaid/tests/apis/test_skeletons.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/django/applications/catmaid/control/skeleton.py b/django/applications/catmaid/control/skeleton.py index 17d8e2c032..898133bfb9 100644 --- a/django/applications/catmaid/control/skeleton.py +++ b/django/applications/catmaid/control/skeleton.py @@ -5154,10 +5154,7 @@ def post(self, request:Request, project_id, skeleton_id) -> JsonResponse: }) -RESTORABLE_SKELETON_DELETE_LABELS = { - 'neurons.remove', - 'skeletons.remove', -} +RESTORABLE_SKELETON_DELETE_LABEL = 'skeletons.remove' @api_view(['POST']) @@ -5189,10 +5186,11 @@ def restore_historic_skeleton(request:HttpRequest, project_id, skeleton_id): f"skeleton {skeleton_id}") source_label = restore_info['label'] - if source_label not in RESTORABLE_SKELETON_DELETE_LABELS: + if source_label != RESTORABLE_SKELETON_DELETE_LABEL: raise ValueError( f"Latest historic transaction for skeleton {skeleton_id} has " - f"missing or unsupported label {source_label}") + f"missing or unsupported label {source_label}; expected " + f"{RESTORABLE_SKELETON_DELETE_LABEL}") tx = Transaction(restore_info['transaction_id'], restore_info['execution_time']) diff --git a/django/applications/catmaid/tests/apis/test_skeletons.py b/django/applications/catmaid/tests/apis/test_skeletons.py index 2307fc3e95..ccd3bddbdf 100644 --- a/django/applications/catmaid/tests/apis/test_skeletons.py +++ b/django/applications/catmaid/tests/apis/test_skeletons.py @@ -2091,6 +2091,20 @@ def test_restore_historic_skeleton_rejects_multi_skeleton_transaction(self): self.assertFalse(ClassInstance.objects.filter(id=skeleton_id).exists()) self.assertFalse(ClassInstance.objects.filter(id=extra_skeleton.id).exists()) + def test_restore_historic_skeleton_rejects_neuron_delete_transaction(self): + self.fake_authentication() + skeleton_id = 1 + neuron_id = 2 + + response = self.client.post( + '/%d/neuron/%s/delete' % (self.test_project_id, neuron_id)) + self.assertStatus(response) + + response = self.client.post( + '/%d/skeletons/%s/restore' % (self.test_project_id, skeleton_id)) + self.assertStatus(response, 400) + self.assertFalse(ClassInstance.objects.filter(id=skeleton_id).exists()) + def test_restore_historic_skeleton_rejects_unlabeled_transaction(self): self.fake_authentication() skeleton_id = 1 From df72995492a868c8b1f900d0a5680ef4dbb12cda Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Fri, 29 May 2026 13:35:44 +0100 Subject: [PATCH 6/7] feat: Add skeleton restore lock --- .../applications/catmaid/control/skeleton.py | 2 +- django/applications/catmaid/locks.py | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/django/applications/catmaid/control/skeleton.py b/django/applications/catmaid/control/skeleton.py index 898133bfb9..882de99ccf 100644 --- a/django/applications/catmaid/control/skeleton.py +++ b/django/applications/catmaid/control/skeleton.py @@ -5169,7 +5169,7 @@ def restore_historic_skeleton(request:HttpRequest, project_id, skeleton_id): cursor.execute(""" SELECT pg_advisory_xact_lock(%(lock_id)s::bigint) """, { - 'lock_id': locks.skeleton_restore_lock, + 'lock_id': locks.skeleton_restore_lock_id(skeleton_id), }) cursor.execute("SET LOCAL catmaid.user_id=%(user_id)s", { 'user_id': request.user.id, diff --git a/django/applications/catmaid/locks.py b/django/applications/catmaid/locks.py index 05059cb7ee..811652b45a 100644 --- a/django/applications/catmaid/locks.py +++ b/django/applications/catmaid/locks.py @@ -1,3 +1,6 @@ +import hashlib + + # The base lock is formed from the multiplication of all characters of "catmaid" # as ASCII: 99 * 97 * 116 * 109 * 97 * 105 * 100. base_lock_id = 123666608142000 @@ -6,5 +9,16 @@ spatial_update_event_lock = base_lock_id + 1 # Postgres advisory lock ID to update history update even handling history_update_event_lock = base_lock_id + 2 -# Postgres advisory lock ID to serialize historic skeleton restores -skeleton_restore_lock = base_lock_id + 3 +# Postgres advisory lock namespace for historic skeleton restores +skeleton_restore_lock_namespace = base_lock_id + 3 + + +def skeleton_restore_lock_id(skeleton_id): + """Return a stable signed 64-bit advisory lock ID for a skeleton restore.""" + lock_key = f'{skeleton_restore_lock_namespace}:{int(skeleton_id)}'.encode( + 'ascii') + unsigned_lock_id = int.from_bytes( + hashlib.blake2b(lock_key, digest_size=8).digest(), 'big') + if unsigned_lock_id >= 2 ** 63: + return unsigned_lock_id - 2 ** 64 + return unsigned_lock_id From 2dab4f0ee499bed0d7785b63af763e36542cb45f Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Fri, 29 May 2026 17:49:08 +0100 Subject: [PATCH 7/7] fix: Exclude empty ranges in both lookup CTEs --- django/applications/catmaid/history.py | 2 ++ .../catmaid/tests/apis/test_skeletons.py | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/django/applications/catmaid/history.py b/django/applications/catmaid/history.py index 67f11f2672..774957d6da 100644 --- a/django/applications/catmaid/history.py +++ b/django/applications/catmaid/history.py @@ -176,6 +176,7 @@ def find_latest_deleted_skeleton_transaction(project_id, skeleton_id): WHERE ci.project_id = %(project_id)s AND ci.id = %(skeleton_id)s AND ci.sys_period IS NOT NULL + AND NOT isempty(ci.sys_period) AND NOT upper_inf(ci.sys_period) GROUP BY ci.exec_transaction_id, upper(ci.sys_period) ), @@ -194,6 +195,7 @@ def find_latest_deleted_skeleton_transaction(project_id, skeleton_id): ON latest.transaction_id = ci.exec_transaction_id WHERE ci.project_id = %(project_id)s AND ci.sys_period IS NOT NULL + AND NOT isempty(ci.sys_period) AND NOT upper_inf(ci.sys_period) AND upper(ci.sys_period) >= latest.execution_time ), diff --git a/django/applications/catmaid/tests/apis/test_skeletons.py b/django/applications/catmaid/tests/apis/test_skeletons.py index ccd3bddbdf..22799f98a9 100644 --- a/django/applications/catmaid/tests/apis/test_skeletons.py +++ b/django/applications/catmaid/tests/apis/test_skeletons.py @@ -2075,6 +2075,38 @@ def test_restore_historic_skeleton(self): self.assertEqual(skeleton_restore_count + 1, self.transaction_label_count('skeletons.restore')) + def test_restore_historic_skeleton_after_split_delete(self): + self.fake_authentication() + + response = self.client.post( + '/%d/skeleton/split' % (self.test_project_id,), + { + 'treenode_id': 2394, + 'upstream_annotation_map': '{}', + 'downstream_annotation_map': '{}', + }) + self.assertStatus(response) + parsed_response = json.loads(response.content.decode('utf-8')) + skeleton_id = parsed_response['new_skeleton_id'] + n_treenodes = Treenode.objects.filter(skeleton_id=skeleton_id).count() + + response = self.client.post( + '/%d/skeletons/%s/delete' % (self.test_project_id, skeleton_id), + {'delete_multi_skeleton_neurons': 'false'}) + self.assertStatus(response) + self.assertFalse(ClassInstance.objects.filter(id=skeleton_id).exists()) + + response = self.client.post( + '/%d/skeletons/%s/restore' % (self.test_project_id, skeleton_id)) + self.assertStatus(response) + parsed_response = json.loads(response.content.decode('utf-8')) + + self.assertEqual(skeleton_id, parsed_response['skeleton_id']) + self.assertEqual('skeletons.remove', parsed_response['source_label']) + self.assertTrue(ClassInstance.objects.filter(id=skeleton_id).exists()) + self.assertEqual(n_treenodes, + Treenode.objects.filter(skeleton_id=skeleton_id).count()) + def test_restore_historic_skeleton_rejects_multi_skeleton_transaction(self): self.fake_authentication() skeleton_id = 1