Skip to content

Commit 56deb27

Browse files
authored
Implement an Obj DELETE handler (skyportal#2047)
1 parent ab93174 commit 56deb27

File tree

8 files changed

+237
-5
lines changed

8 files changed

+237
-5
lines changed

Diff for: config.yaml.defaults

+2
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,8 @@ misc:
285285
# consider a photometry point as a detection
286286
photometry_detection_threshold_nsigma: 3.0
287287

288+
allow_nonadmins_delete_objs: False
289+
288290
weather:
289291
# time in seconds to wait before fetching weather for a given telescope
290292
refresh_time: 3600.0

Diff for: skyportal/app_server.py

+2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
ObservingRunHandler,
3333
PhotometryHandler,
3434
BulkDeletePhotometryHandler,
35+
ObjHandler,
3536
ObjPhotometryHandler,
3637
ObjClassificationHandler,
3738
ObjAnnotationHandler,
@@ -131,6 +132,7 @@ def log_request(self, handler):
131132
(r'/api/invitations(/.*)?', InvitationHandler),
132133
(r'/api/newsfeed', NewsFeedHandler),
133134
(r'/api/observing_run(/[0-9]+)?', ObservingRunHandler),
135+
(r'/api/objs(/[0-9A-Za-z-_\.\+]+)', ObjHandler),
134136
(r'/api/photometry(/[0-9]+)?', PhotometryHandler),
135137
(r'/api/sharing', SharingHandler),
136138
(r'/api/photometry/bulk_delete/(.*)', BulkDeletePhotometryHandler),

Diff for: skyportal/handlers/api/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from .color_mag import ObjColorMagHandler
3333
from .public_group import PublicGroupHandler
3434
from .roles import RoleHandler, UserRoleHandler
35+
from .obj import ObjHandler
3536
from .sharing import SharingHandler
3637
from .source import (
3738
SourceHandler,

Diff for: skyportal/handlers/api/obj.py

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from baselayer.app.access import auth_or_token, AccessError
2+
from baselayer.app.env import load_env
3+
from ..base import BaseHandler
4+
from ...models import DBSession, Obj
5+
6+
_, cfg = load_env()
7+
8+
9+
class ObjHandler(BaseHandler):
10+
@auth_or_token # ACLs will be checked below based on configs
11+
def delete(self, obj_id):
12+
"""
13+
---
14+
description: Delete an Obj
15+
tags:
16+
- objs
17+
parameters:
18+
- in: path
19+
name: obj_id
20+
required: true
21+
schema:
22+
type: string
23+
responses:
24+
200:
25+
content:
26+
application/json:
27+
schema: Success
28+
400:
29+
content:
30+
application/json:
31+
schema: Error
32+
"""
33+
try:
34+
obj = Obj.get_if_accessible_by(
35+
obj_id,
36+
self.current_user,
37+
mode='delete',
38+
raise_if_none=True,
39+
)
40+
DBSession().delete(obj)
41+
self.verify_and_commit()
42+
except AccessError as e:
43+
error_msg = (
44+
"Insufficient permissions: Objs may only be deleted by system admins"
45+
)
46+
if cfg["misc.allow_nonadmins_delete_objs"]:
47+
error_msg += " or by users who own all data associated with the Obj"
48+
49+
return self.error(f"{error_msg}. (Original exception: {e})")
50+
51+
return self.success()

Diff for: skyportal/models.py

+107-3
Original file line numberDiff line numberDiff line change
@@ -637,11 +637,110 @@ def token_groups(self):
637637
Token.groups = token_groups
638638

639639

640+
def delete_obj_if_all_data_owned(cls, user_or_token):
641+
allow_nonadmins = cfg["misc.allow_nonadmins_delete_objs"] or False
642+
643+
deletable_photometry = Photometry.query_records_accessible_by(
644+
user_or_token, mode="delete"
645+
).subquery()
646+
nondeletable_photometry = (
647+
DBSession()
648+
.query(Photometry.obj_id)
649+
.join(
650+
deletable_photometry,
651+
deletable_photometry.c.id == Photometry.id,
652+
isouter=True,
653+
)
654+
.filter(deletable_photometry.c.id.is_(None))
655+
.distinct(Photometry.obj_id)
656+
.subquery()
657+
)
658+
659+
deletable_spectra = Spectrum.query_records_accessible_by(
660+
user_or_token, mode="delete"
661+
).subquery()
662+
nondeletable_spectra = (
663+
DBSession()
664+
.query(Spectrum.obj_id)
665+
.join(
666+
deletable_spectra,
667+
deletable_spectra.c.id == Spectrum.id,
668+
isouter=True,
669+
)
670+
.filter(deletable_spectra.c.id.is_(None))
671+
.distinct(Spectrum.obj_id)
672+
.subquery()
673+
)
674+
675+
deletable_candidates = Candidate.query_records_accessible_by(
676+
user_or_token, mode="delete"
677+
).subquery()
678+
nondeletable_candidates = (
679+
DBSession()
680+
.query(Candidate.obj_id)
681+
.join(
682+
deletable_candidates,
683+
deletable_candidates.c.id == Candidate.id,
684+
isouter=True,
685+
)
686+
.filter(deletable_candidates.c.id.is_(None))
687+
.distinct(Candidate.obj_id)
688+
.subquery()
689+
)
690+
691+
deletable_sources = Source.query_records_accessible_by(
692+
user_or_token, mode="delete"
693+
).subquery()
694+
nondeletable_sources = (
695+
DBSession()
696+
.query(Source.obj_id)
697+
.join(
698+
deletable_sources,
699+
deletable_sources.c.id == Source.id,
700+
isouter=True,
701+
)
702+
.filter(deletable_sources.c.id.is_(None))
703+
.distinct(Source.obj_id)
704+
.subquery()
705+
)
706+
707+
return (
708+
DBSession()
709+
.query(cls)
710+
.join(
711+
nondeletable_photometry,
712+
nondeletable_photometry.c.obj_id == cls.id,
713+
isouter=True,
714+
)
715+
.filter(nondeletable_photometry.c.obj_id.is_(None))
716+
.join(
717+
nondeletable_spectra,
718+
nondeletable_spectra.c.obj_id == cls.id,
719+
isouter=True,
720+
)
721+
.filter(nondeletable_spectra.c.obj_id.is_(None))
722+
.join(
723+
nondeletable_candidates,
724+
nondeletable_candidates.c.obj_id == cls.id,
725+
isouter=True,
726+
)
727+
.filter(nondeletable_candidates.c.obj_id.is_(None))
728+
.join(
729+
nondeletable_sources,
730+
nondeletable_sources.c.obj_id == cls.id,
731+
isouter=True,
732+
)
733+
.filter(nondeletable_sources.c.obj_id.is_(None))
734+
.filter(sa.literal(allow_nonadmins))
735+
)
736+
737+
640738
class Obj(Base, ha.Point):
641739
"""A record of an astronomical Object and its metadata, such as position,
642740
positional uncertainties, name, and redshift."""
643741

644742
update = public
743+
delete = restricted | CustomUserAccessControl(delete_obj_if_all_data_owned)
645744

646745
id = sa.Column(sa.String, primary_key=True, doc="Name of the object.")
647746
# TODO should this column type be decimal? fixed-precison numeric
@@ -721,7 +820,7 @@ class Obj(Base, ha.Point):
721820
candidates = relationship(
722821
'Candidate',
723822
back_populates='obj',
724-
cascade='save-update, merge, refresh-expire, expunge',
823+
cascade='save-update, merge, refresh-expire, expunge, delete',
725824
passive_deletes=True,
726825
order_by="Candidate.passed_at",
727826
doc="Candidates associated with the object.",
@@ -739,7 +838,7 @@ class Obj(Base, ha.Point):
739838
comments_on_spectra = relationship(
740839
'CommentOnSpectrum',
741840
back_populates='obj',
742-
cascade='save-update, merge, refresh-expire, expunge',
841+
cascade='save-update, merge, refresh-expire, expunge, delete',
743842
passive_deletes=True,
744843
order_by="CommentOnSpectrum.created_at",
745844
doc="Comments posted about spectra belonging to the object.",
@@ -766,7 +865,7 @@ class Obj(Base, ha.Point):
766865
photometry = relationship(
767866
'Photometry',
768867
back_populates='obj',
769-
cascade='save-update, merge, refresh-expire, expunge',
868+
cascade='save-update, merge, refresh-expire, expunge, delete',
770869
single_parent=True,
771870
passive_deletes=True,
772871
order_by="Photometry.mjd",
@@ -799,19 +898,22 @@ class Obj(Base, ha.Point):
799898
followup_requests = relationship(
800899
'FollowupRequest',
801900
back_populates='obj',
901+
cascade='delete',
802902
passive_deletes=True,
803903
doc="Robotic follow-up requests of the object.",
804904
)
805905
assignments = relationship(
806906
'ClassicalAssignment',
807907
back_populates='obj',
908+
cascade='delete',
808909
passive_deletes=True,
809910
doc="Assignments of the object to classical observing runs.",
810911
)
811912

812913
obj_notifications = relationship(
813914
"SourceNotification",
814915
back_populates="source",
916+
cascade='delete',
815917
passive_deletes=True,
816918
doc="Notifications regarding the object sent out by users",
817919
)
@@ -1309,12 +1411,14 @@ def source_create_access_logic(cls, user_or_token):
13091411
Obj.sources = relationship(
13101412
Source,
13111413
back_populates='obj',
1414+
cascade='delete',
13121415
passive_deletes=True,
13131416
doc="Instances in which a group saved this Obj.",
13141417
)
13151418
Obj.candidates = relationship(
13161419
Candidate,
13171420
back_populates='obj',
1421+
cascade='delete',
13181422
passive_deletes=True,
13191423
doc="Instances in which this Obj passed a group's filter.",
13201424
)

Diff for: skyportal/tests/api/test_objs.py

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from skyportal.tests import api
2+
3+
4+
def test_delete_obj_non_admin(
5+
manage_sources_token,
6+
public_obj,
7+
public_source_no_data,
8+
upload_data_token,
9+
ztf_camera,
10+
public_group,
11+
):
12+
# A manage_sources_token user cannot delete an obj from ObjFactory since things
13+
# like Photometry and Comments are created with other users as authors/owners
14+
status, _ = api("DELETE", f"objs/{public_obj.id}", token=manage_sources_token)
15+
assert status == 400
16+
17+
# Now start with a fresh Obj with no associated data, and post photometry to it
18+
status, data = api(
19+
'POST',
20+
'photometry',
21+
data={
22+
'obj_id': str(public_source_no_data.id),
23+
'mjd': 58000.0,
24+
'instrument_id': ztf_camera.id,
25+
'flux': 12.24,
26+
'fluxerr': 0.031,
27+
'zp': 25.0,
28+
'magsys': 'ab',
29+
'filter': 'ztfi',
30+
'group_ids': [public_group.id],
31+
},
32+
token=upload_data_token,
33+
)
34+
assert status == 200
35+
assert data['status'] == 'success'
36+
photometry_id = data['data']['ids'][0]
37+
38+
# Since the owner is the upload_data_token user, the manage_sources_token user
39+
# won't be able to delete this Obj either
40+
status, _ = api(
41+
"DELETE", f"objs/{public_source_no_data.id}", token=manage_sources_token
42+
)
43+
assert status == 400
44+
45+
# Now delete the photometry blocking the delete
46+
status, data = api('DELETE', f'photometry/{photometry_id}', token=upload_data_token)
47+
assert status == 200
48+
49+
# Now the manage_source_token user should be able to delete the Obj,
50+
# since they are a member of the group the associated Source is saved to,
51+
# that is the only data referencing the `public_source_no_data` Obj, and
52+
# cfg['misc.allow_nonadmins_delete_objs'] is True.
53+
status, _ = api(
54+
"DELETE", f"objs/{public_source_no_data.id}", token=manage_sources_token
55+
)
56+
assert status == 200
57+
58+
59+
def test_delete_obj_system_admin(public_obj, super_admin_token):
60+
status, _ = api("DELETE", f"objs/{public_obj.id}", token=super_admin_token)
61+
assert status == 200

Diff for: skyportal/tests/fixtures.py

+12-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import factory
1010
import numpy as np
1111
from sqlalchemy import inspect
12+
from sqlalchemy.orm.exc import ObjectDeletedError
1213

1314
from baselayer.app.config import load_config
1415
from baselayer.app.env import load_env
@@ -62,8 +63,17 @@ def is_already_deleted(instance, table):
6263
if instance in DBSession() and inspect(instance).detached:
6364
return True
6465

65-
if instance not in DBSession():
66-
return DBSession().query(table).filter(table.id == instance.id).first() is None
66+
if instance not in DBSession() or (
67+
instance in DBSession() and inspect(instance).expired
68+
):
69+
try:
70+
return (
71+
DBSession().query(table).filter(table.id == instance.id).first() is None
72+
)
73+
except ObjectDeletedError:
74+
# If instance was deleted by the test, it would have taken place in
75+
# another transaction and thus undiscovered until the exception here
76+
return True
6777

6878
# If the instance is in the session and has not been detached (deleted + committed)
6979
# then it still requires some teardown actions.

Diff for: test_config.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ server:
1414
misc:
1515
photometry_detection_threshold_nsigma: 3.0
1616
minutes_to_keep_candidate_query_cache: 0.033333 # 2 seconds
17+
allow_nonadmins_delete_objs: True
1718

1819
twilio:
1920
# Twilio Sendgrid API configs

0 commit comments

Comments
 (0)