Skip to content
65 changes: 63 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,65 @@ def post(self, request:Request, project_id, skeleton_id) -> JsonResponse:
})


SKELETON_REMOVE_TRANSACTION_LABEL = 'skeletons.remove'
SKELETON_RESTORE_TRANSACTION_LABEL = 'skeletons.restore'


@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()
lock_namespace, skeleton_lock_key = locks.skeleton_restore_lock_keys(
skeleton_id)
cursor.execute("""
SELECT pg_advisory_xact_lock(
%(lock_namespace)s::integer,
%(skeleton_lock_key)s::integer
)
""", {
'lock_namespace': lock_namespace,
'skeleton_lock_key': skeleton_lock_key,
})
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 != SKELETON_REMOVE_TRANSACTION_LABEL:
raise ValueError(
f"Latest historic transaction for skeleton {skeleton_id} has "
f"missing or unsupported label {source_label}; expected "
f"{SKELETON_REMOVE_TRANSACTION_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
18 changes: 18 additions & 0 deletions django/applications/catmaid/locks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,21 @@
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


def _signed_int32(value):
value = int(value) & 0xffffffff
if value >= 2 ** 31:
return value - 2 ** 32
return value


# Postgres advisory lock namespace for historic skeleton restores. This is
# used with pg_advisory_xact_lock(integer, integer), which is separate from the
# one-key bigint advisory lock space used by the global locks above.
skeleton_restore_lock_namespace = _signed_int32(base_lock_id + 3)


def skeleton_restore_lock_keys(skeleton_id):
"""Return stable signed 32-bit advisory lock keys for a skeleton restore."""
return skeleton_restore_lock_namespace, _signed_int32(skeleton_id)
Loading