Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions django/applications/catmaid/control/skeleton.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -5152,6 +5154,58 @@ def post(self, request:Request, project_id, skeleton_id) -> JsonResponse:
})


RESTORABLE_SKELETON_DELETE_LABEL = 'skeletons.remove'


@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_id(skeleton_id),
})
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 single-skeleton deleted historic skeleton found for "
f"skeleton {skeleton_id}")

source_label = restore_info['label']
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}; expected "
f"{RESTORABLE_SKELETON_DELETE_LABEL}")

tx = Transaction(restore_info['transaction_id'],
restore_info['execution_time'])

undelete_neuron(project_id, tx, user_id=request.user.id)

return JsonResponse({
'skeleton_id': skeleton_id,
'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):
Expand Down
4 changes: 4 additions & 0 deletions django/applications/catmaid/control/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions django/applications/catmaid/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,82 @@ 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.

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 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 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
AND NOT isempty(ci.sys_period)
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
),
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 isempty(ci.sys_period)
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
""", {
'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],
}


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).
Expand Down
16 changes: 16 additions & 0 deletions django/applications/catmaid/locks.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -6,3 +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 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
Loading