Skip to content

Commit 7c2a3e4

Browse files
committed
Analysis is actually deleted when pressing delete, results are archived if needed
1 parent 9bbef9a commit 7c2a3e4

File tree

8 files changed

+158
-115
lines changed

8 files changed

+158
-115
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Generated by Django 5.2 on 2026-03-04 14:42
2+
3+
from django.db import migrations, models
4+
from jobs import jobs as jj
5+
6+
7+
def fake(x, y):
8+
pass
9+
10+
def fixfield(apps, s):
11+
A = apps.get_model('analysis', 'Analysis')
12+
A.objects.filter(nextflowsearch__job__state=jj.Jobstates.DONE).update(success_completed=True)
13+
14+
15+
class Migration(migrations.Migration):
16+
17+
dependencies = [
18+
('analysis', '0062_alter_analysisdeleted_date'),
19+
]
20+
21+
operations = [
22+
migrations.AddField(
23+
model_name='analysis',
24+
name='success_completed',
25+
field=models.BooleanField(default=False),
26+
),
27+
28+
migrations.RunPython(fixfield, fake)
29+
]

src/backend/analysis/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ class Analysis(models.Model):
287287
base_rundir = models.TextField()
288288
editable = models.BooleanField(default=True)
289289
securityclass = models.IntegerField(choices=filemodels.DataSecurityClass.choices)
290+
success_completed = models.BooleanField(default=False)
290291

291292
def get_fullname(self, wftype=False):
292293
if wftype:

src/backend/analysis/tests.py

Lines changed: 39 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -845,22 +845,22 @@ def test_new_analysis_and_run_and_purge(self):
845845
j = jm.Job.objects.last()
846846
self.assertEqual(j.kwargs, {'sfloc_id': anasfl.pk, 'isdir': False})
847847

848-
# Now purge
848+
# Now delete analysis
849849
j.state = jj.Jobstates.DONE
850850
j.save()
851+
rm.PDCBackedupFile.objects.filter(storedfile_id=anasfl.sfile_id).update(success=True)
851852
self.user.is_staff = True
852853
self.user.save()
853854
resp = self.cl.post('/analysis/delete/', content_type='application/json', data={'item_id': ana.pk})
854-
resp = self.cl.post('/analysis/purge/', content_type='application/json', data={'item_id': ana.pk})
855855
self.assertEqual(resp.status_code, 200)
856856
self.assertFalse(os.path.exists(webfn))
857857
self.assertTrue(os.path.exists(anafn))
858858
self.assertTrue(os.path.exists(nfrunfn))
859859
# Two purge jobs and two delete dir jobs, this test is getting slow
860-
self.run_job()
861-
self.run_job()
862-
self.run_job()
863-
self.run_job()
860+
self.run_job() # purge files
861+
self.run_job() # rm dir
862+
self.run_job() # purge
863+
self.run_job() # rm dir
864864
self.assertFalse(os.path.exists(anafn))
865865
self.assertFalse(os.path.exists(nfrunfn))
866866

@@ -1240,11 +1240,6 @@ def test_failing(self):
12401240
class TestDeleteAnalysis(BaseTest):
12411241
url = '/analysis/delete/'
12421242

1243-
def setUp(self):
1244-
super().setUp()
1245-
self.ana = am.Analysis.objects.create(user=self.user, name='test', base_rundir='testdir',
1246-
securityclass=rm.DataSecurityClass.NOSECURITY)
1247-
12481243
def test_fail_request(self):
12491244
resp = self.cl.get(self.url)
12501245
self.assertEqual(resp.status_code, 405)
@@ -1275,50 +1270,45 @@ def test_fail_request(self):
12751270
self.assertEqual(resp.status_code, 403)
12761271
self.assertIn('Analysis is already deleted', resp.json()['error'])
12771272

1278-
def test_delete(self):
1273+
def test_delete_with_backup(self):
12791274
resp = self.cl.post(self.url, content_type='application/json', data={'item_id': self.ana.pk})
12801275
self.assertEqual(resp.status_code, 200)
12811276
self.assertFalse(self.ana.deleted)
12821277
self.ana.refresh_from_db()
12831278
self.assertTrue(self.ana.deleted)
1284-
# FIXME test job revoked
1285-
1286-
1287-
class TestPurgeAnalysis(BaseIntegrationTest):
1288-
url = '/analysis/purge/'
1289-
1290-
def setUp(self):
1291-
super().setUp()
1292-
self.ana = am.Analysis.objects.create(user=self.user, name='test', base_rundir='testdir',
1293-
deleted=True, securityclass=rm.DataSecurityClass.NOSECURITY)
1294-
self.user.is_staff = True
1295-
self.user.save()
1296-
1297-
def test_fail_request(self):
1298-
resp = self.cl.get(self.url)
1299-
self.assertEqual(resp.status_code, 405)
1300-
# wrong dict keys
1301-
resp = self.cl.post(self.url, content_type='application/json', data={'hello': 'test'})
1302-
self.assertEqual(resp.status_code, 400)
1279+
self.assertTrue(jm.Job.objects.filter(funcname='create_pdc_archive',
1280+
kwargs={'sfloc_id': self.anasfile_sfl.pk, 'isdir': False}).exists())
13031281

1304-
# No analysis
1305-
resp = self.cl.post(self.url, content_type='application/json',
1306-
data={'item_id': self.ana.pk + 1000})
1307-
self.assertEqual(resp.status_code, 403)
1308-
self.assertIn('Analysis does not exist', resp.json()['error'])
1282+
def test_delete_without_backup(self):
1283+
rm.PDCBackedupFile.objects.create(storedfile=self.anasfile, success=True)
1284+
resp = self.cl.post(self.url, content_type='application/json', data={'item_id': self.ana.pk})
1285+
self.assertEqual(resp.status_code, 200)
1286+
self.assertFalse(self.ana.deleted)
1287+
self.ana.refresh_from_db()
1288+
self.assertTrue(self.ana.deleted)
1289+
self.assertFalse(jm.Job.objects.filter(funcname='create_pdc_archive',
1290+
kwargs={'sfloc_id': self.anasfile_sfl.pk, 'isdir': False}).exists())
13091291

1310-
# Not allowed wrong user
1311-
self.user.is_staff = False
1312-
self.user.save()
1292+
def test_delete_without_backup_pending(self):
1293+
rm.PDCBackedupFile.objects.create(storedfile=self.anasfile, success=False)
1294+
job = jm.Job.objects.create(funcname='create_pdc_archive', timestamp=timezone.now(),
1295+
kwargs={'sfloc_id': self.anasfile_sfl.pk, 'isdir': False}, state=jj.Jobstates.PENDING)
13131296
resp = self.cl.post(self.url, content_type='application/json', data={'item_id': self.ana.pk})
1314-
self.assertEqual(resp.status_code, 403)
1315-
self.assertIn('Only admin is authorized to purge analysis', resp.json()['error'])
1316-
1317-
# analysis not deleted
1318-
self.user.is_staff = True
1319-
self.user.save()
1320-
self.ana.deleted = False
1321-
self.ana.save()
1297+
self.assertEqual(resp.status_code, 200)
1298+
self.assertFalse(self.ana.deleted)
1299+
self.ana.refresh_from_db()
1300+
self.assertTrue(self.ana.deleted)
1301+
self.assertFalse(jm.Job.objects.exclude(pk=job.pk).filter(funcname='create_pdc_archive',
1302+
kwargs={'sfloc_id': self.anasfile_sfl.pk, 'isdir': False}).exists())
1303+
1304+
def test_delete_fail_backup(self):
1305+
rm.PDCBackedupFile.objects.create(storedfile=self.anasfile, success=False)
1306+
job = jm.Job.objects.create(funcname='create_pdc_archive', timestamp=timezone.now(),
1307+
kwargs={'sfloc_id': self.anasfile_sfl.pk, 'isdir': False}, state=jj.Jobstates.WAITING)
13221308
resp = self.cl.post(self.url, content_type='application/json', data={'item_id': self.ana.pk})
1323-
self.assertEqual(resp.status_code, 403)
1324-
self.assertIn('Analysis is not deleted', resp.json()['error'])
1309+
self.assertEqual(resp.status_code, 409)
1310+
self.assertFalse(self.ana.deleted)
1311+
self.ana.refresh_from_db()
1312+
self.assertFalse(self.ana.deleted)
1313+
self.assertFalse(jm.Job.objects.exclude(pk=job.pk).filter(funcname='create_pdc_archive',
1314+
kwargs={'sfloc_id': self.anasfile_sfl.pk, 'isdir': False}).exists())

src/backend/analysis/urls.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
path('freeze/', views.freeze_analysis),
1414
path('unfreeze/', views.unfreeze_analysis),
1515
path('undelete/', views.undelete_analysis),
16-
path('purge/', views.purge_analysis),
1716
path('dsets/<int:wfversion_id>/', views.get_datasets),
1817
path('baseanalysis/show/', views.get_base_analyses),
1918
path('baseanalysis/load/<int:wfversion_id>/<int:baseanid>/', views.load_base_analysis),

src/backend/analysis/views.py

Lines changed: 85 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from rawstatus import models as rm
2121
from jobs import jobs as jj
2222
from jobs import views as jv
23-
from jobs.jobutil import create_job, jobmap
23+
from jobs.jobutil import create_job, check_job_error, jobmap, create_job_without_check
2424
from jobs import models as jm
2525

2626

@@ -1320,69 +1320,104 @@ def undelete_analysis(request):
13201320

13211321
@login_required
13221322
def delete_analysis(request):
1323+
# FIXME analysis can be properly deleted if its backed up
1324+
# right now this is only mark for deletion and it was for a previous
1325+
# setup where delete meant gone for ever
1326+
# There is a lot of old analyses that should be backed up or purged properly
1327+
# Maybe delete all those where no project is active anymore in delete_expired?
13231328
if request.method != 'POST':
13241329
return JsonResponse({'error': 'Must use POST'}, status=405)
13251330
req = json.loads(request.body.decode('utf-8'))
13261331
try:
1327-
analysis = am.Analysis.objects.select_related('nextflowsearch__job').get(pk=req['item_id'])
1332+
analysis = am.Analysis.objects.get(pk=req['item_id'])
13281333
except am.Analysis.DoesNotExist:
13291334
return JsonResponse({'error': 'Analysis does not exist'}, status=403)
13301335
except KeyError:
13311336
return JsonResponse({'error': 'Bad request'}, status=400)
13321337
if analysis.deleted:
13331338
return JsonResponse({'error': 'Analysis is already deleted'}, status=403)
1334-
if analysis.user == request.user or request.user.is_staff:
1335-
if not analysis.deleted:
1336-
analysis.deleted = True
1337-
analysis.save()
1338-
am.AnalysisDeleted.objects.update_or_create(analysis=analysis)
1339-
if hasattr(analysis, 'nextflowsearch'):
1340-
jobq = jm.Job.objects.filter(nextflowsearch__analysis=analysis)
1341-
jv.cancel_or_revoke_job(jobq)
1342-
return JsonResponse({})
1343-
else:
1339+
if analysis.user != request.user and not request.user.is_staff:
13441340
return JsonResponse({'error': 'User is not authorized to delete this analysis'}, status=403)
1341+
if errmsg := do_analysis_deletion(analysis):
1342+
return JsonResponse({'error': f'Could not delete analysis, {errmsg}'}, status=409)
1343+
return JsonResponse({})
13451344

13461345

1347-
@login_required
1348-
def purge_analysis(request):
1349-
if request.method != 'POST':
1350-
return JsonResponse({'error': 'Must use POST'}, status=405)
1351-
elif not request.user.is_staff:
1352-
return JsonResponse({'error': 'Only admin is authorized to purge analysis'}, status=403)
1353-
req = json.loads(request.body.decode('utf-8'))
1354-
try:
1355-
analysis = am.Analysis.objects.get(pk=req['item_id'])
1356-
except am.Analysis.DoesNotExist:
1357-
return JsonResponse({'error': 'Analysis does not exist'}, status=403)
1358-
except KeyError:
1359-
return JsonResponse({'error': 'Bad request'}, status=400)
1360-
if not analysis.deleted:
1361-
return JsonResponse({'error': 'Analysis is not deleted, cannot purge'}, status=403)
1362-
analysis.purged = True
1363-
analysis.save()
1364-
webshares = rm.ServerShare.objects.filter(function=rm.ShareFunction.REPORTS)
1365-
# Delete files on web share here since the job tasks run on storage cannot do that
1366-
webfiles = rm.StoredFileLoc.objects.filter(sfile__analysisresultfile__analysis__id=analysis.pk,
1367-
servershare__in=webshares, active=True)
1368-
for webfile in webfiles.values('path', 'sfile__filename'):
1369-
fpath = os.path.join(settings.WEBSHARE, webfile['path'], webfile['sfile__filename'])
1370-
os.unlink(fpath)
1371-
webfiles.update(active=False, purged=True)
1372-
sfiles = rm.StoredFile.objects.filter(analysisresultfile__analysis__id=analysis.pk)
1373-
sfiles.update(deleted=True)
1374-
sfls = rm.StoredFileLoc.objects.filter(sfile__in=sfiles, active=True)
1375-
# One job per servershare as empty dir deleter needs that
1376-
share_pks = defaultdict(list)
1377-
for sfl in sfls.values('pk', 'servershare_id', 'path'):
1378-
share_pks[f'{sfl["servershare_id"]}__{sfl["path"]}'].append(sfl['pk'])
1379-
for share_path, sfloc_ids in share_pks.items():
1380-
shareid, path = share_path.split('__')
1381-
purgejob = create_job('purge_files', sfloc_ids=sfloc_ids)
1382-
if not purgejob['error']:
1346+
def do_analysis_deletion(analysis):
1347+
'''Function to call when deleting analysis. Also used when
1348+
projects are deleted. Checks if analysis is backed up'''
1349+
# FIXME analysisdeleted tracking no longer needed when we backup etc
1350+
# implement a log instead
1351+
am.AnalysisDeleted.objects.update_or_create(analysis=analysis)
1352+
if jobq := jm.Job.objects.filter(nextflowsearch__analysis=analysis).exclude(state__in=[
1353+
jj.Jobstates.ERROR, jj.Jobstates.DONE, jj.Jobstates.REVOKING, jj.Jobstates.CANCELED]):
1354+
jv.cancel_or_revoke_job(jobq)
1355+
if analysis.success_completed:
1356+
# Back up files if that for some reason (old analysis) hasnt happened earlier
1357+
backup_jobs = []
1358+
for sf in rm.StoredFile.objects.filter(analysisresultfile__analysis=analysis).exclude(
1359+
pdcbackedupfile__success=True).values('pk', 'filetype__is_folder'):
1360+
sfl_pks = [x['pk'] for x in rm.StoredFileLoc.objects.filter(sfile_id=sf['pk']).values('pk')]
1361+
do_backup = True
1362+
for exist_job in jm.Job.objects.filter(funcname='create_pdc_archive',
1363+
kwargs__sfloc_id__in=sfl_pks).values('state'):
1364+
match exist_job['state']:
1365+
case jj.Jobstates.WAITING | jj.Jobstates.HOLD:
1366+
do_backup = False
1367+
return 'Result file queued for backing up but job not running, please check'
1368+
case x if x in jj.JOBSTATES_JOB_ACTIVE:
1369+
do_backup = False
1370+
continue
1371+
case jj.Jobstates.ERROR:
1372+
return 'Result file queued for backing up but job errored, please check'
1373+
# If no backup exists and job is somehow in done/revoking/canceled,
1374+
# keep looking for more jobs, if none exist -> do a backup
1375+
if do_backup and (sfl := rm.StoredFileLoc.objects.filter(pk__in=sfl_pks, active=True,
1376+
servershare__fileservershare__server__can_backup=True).values('pk')):
1377+
jobkw = {'sfloc_id': sfl.first()['pk'], 'isdir': sf['filetype__is_folder']}
1378+
backup_jobs.append(jobkw)
1379+
if joberror := check_job_error('create_pdc_archive', **jobkw):
1380+
return f'errors backing up result file(s): {joberror}'
1381+
elif do_backup:
1382+
return 'could not find copy of result file to back up'
1383+
for bupjob in backup_jobs:
1384+
create_job_without_check('create_pdc_archive', **bupjob)
1385+
1386+
# Delete files on web share here since the job tasks run on storage cannot do that
1387+
webshares = rm.ServerShare.objects.filter(function=rm.ShareFunction.REPORTS)
1388+
webfiles = rm.StoredFileLoc.objects.filter(sfile__analysisresultfile__analysis__id=analysis.pk,
1389+
servershare__in=webshares, active=True)
1390+
for webfile in webfiles.values('path', 'sfile__filename'):
1391+
fpath = os.path.join(settings.WEBSHARE, webfile['path'], webfile['sfile__filename'])
1392+
os.unlink(fpath)
1393+
webfiles.update(active=False, purged=True)
1394+
sfiles = rm.StoredFile.objects.filter(analysisresultfile__analysis__id=analysis.pk)
1395+
sfls = rm.StoredFileLoc.objects.filter(sfile__in=sfiles, active=True)
1396+
# One job per servershare as empty dir deleter needs that
1397+
share_pks = defaultdict(list)
1398+
for sfl in sfls.values('pk', 'servershare_id', 'path'):
1399+
share_pks[f'{sfl["servershare_id"]}__{sfl["path"]}'].append(sfl['pk'])
1400+
purgejobs, rmdirjobs = [], []
1401+
for share_path, sfloc_ids in share_pks.items():
1402+
shareid, path = share_path.split('__')
1403+
purgejobs.append(sfloc_ids)
1404+
if joberror := check_job_error('purge_files', sfloc_ids=sfloc_ids):
1405+
return f'error trying to queue delete files job: {joberror}'
1406+
rmdirkw = {'sfloc_ids': sfloc_ids, 'path': path, 'share_id': shareid}
1407+
rmdirjobs.append(rmdirkw)
1408+
if joberror := check_job_error('delete_empty_directory', sfloc_ids=sfloc_ids):
1409+
return f'error trying to queue delete files job: {joberror}'
1410+
for sfloc_ids in purgejobs:
1411+
create_job_without_check('purge_files', sfloc_ids=sfloc_ids)
13831412
rm.StoredFileLoc.objects.filter(pk__in=sfloc_ids).update(active=False)
1384-
rmdirjob = create_job('delete_empty_directory', sfloc_ids=sfloc_ids, path=path, share_id=shareid)
1385-
return JsonResponse({})
1413+
for rmdirkw in rmdirjobs:
1414+
create_job_without_check('delete_empty_directory', **rmdirkw)
1415+
sfiles.update(deleted=True)
1416+
1417+
# No more errors, mark for deletion
1418+
analysis.deleted, analysis.purged = True, True
1419+
analysis.save()
1420+
return 0
13861421

13871422

13881423
@login_required

src/backend/jobs/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ def analysis_run_done(request):
316316
if 'task' in data:
317317
set_task_done(data['task'])
318318
# Defensively, since analysis should already be not editable upon launch:
319-
am.Analysis.objects.filter(pk=data['analysis_id']).update(editable=False)
319+
am.Analysis.objects.filter(pk=data['analysis_id']).update(editable=False, success_completed=True)
320320
ana = am.Analysis.objects.values('user', 'name').get(pk=data['analysis_id'])
321321
hm.UserMessage.create_message(ana['user'], msgtype=hm.AnalysisMsgTypes.COMPLETED,
322322
analysis_id=data['analysis_id'])

src/backend/kantele/tests.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,8 @@ def setUp(self):
258258
self.uft = rm.StoredFileType.objects.create(name='ufileft', filetype='tst', is_rawdata=False)
259259

260260
# Analysis files
261-
ana = am.Analysis.objects.create(name='anatesttest', user=self.user, base_rundir='fakerundir',
262-
securityclass=rm.DataSecurityClass.NOSECURITY)
261+
self.ana = am.Analysis.objects.create(name='anatesttest', user=self.user, base_rundir='fakerundir',
262+
securityclass=rm.DataSecurityClass.NOSECURITY, success_completed=True)
263263
anaft = rm.StoredFileType.objects.create(name=settings.ANALYSIS_FT_NAME, filetype='ana',
264264
is_rawdata=False)
265265
self.anaprod = rm.Producer.objects.create(name='analysisprod', client_id=settings.ANALYSISCLIENT_APIKEY, shortname=settings.PRODUCER_ANALYSIS_NAME)
@@ -269,7 +269,7 @@ def setUp(self):
269269
self.anasfile = rm.StoredFile.objects.create(rawfile=self.ana_raw, filetype=anaft,
270270

271271
filename=self.ana_raw.name, md5=self.ana_raw.source_md5)
272-
am.AnalysisResultFile.objects.create(analysis=ana, sfile=self.anasfile)
272+
am.AnalysisResultFile.objects.create(analysis=self.ana, sfile=self.anasfile)
273273

274274
self.anasfile_sfl = rm.StoredFileLoc.objects.create(sfile=self.anasfile,
275275
servershare=self.ssana, path='', active=True, purged=False)

src/frontend/home/src/Analysis.svelte

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,15 +113,6 @@ async function unDeleteAnalyses() {
113113
}
114114
updateNotif()
115115
}
116-
117-
118-
async function purgeAnalyses() {
119-
for (let anid of selectedAnalyses) {
120-
await treatItems('/analysis/purge/', 'analysis', 'purging', anid, notif);
121-
refreshAnalysis(anid);
122-
}
123-
updateNotif()
124-
}
125116
</script>
126117

127118
<Tabs tabshow="Analyses" notif={notif} />
@@ -130,11 +121,9 @@ async function purgeAnalyses() {
130121
{#if selectedAnalyses.length}
131122
<a class="button is-small" on:click={deleteAnalyses}>Delete analyses</a>
132123
<a class="button is-small" on:click={unDeleteAnalyses}>Undelete analyses</a>
133-
<a class="button is-small" on:click={purgeAnalyses}>Purge analyses</a>
134124
{:else}
135125
<a class="button is-small" disabled>Delete analyses</a>
136126
<a class="button is-small" disabled>Undelete analyses</a>
137-
<a class="button is-small" disabled>Purge analyses</a>
138127
{/if}
139128

140129
<Table tab="Analyses"

0 commit comments

Comments
 (0)