labdata 0.1.16__tar.gz → 0.1.18__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {labdata-0.1.16 → labdata-0.1.18}/PKG-INFO +1 -1
  2. {labdata-0.1.16 → labdata-0.1.18}/labdata/__init__.py +1 -1
  3. {labdata-0.1.16 → labdata-0.1.18}/labdata/cli.py +15 -10
  4. {labdata-0.1.16 → labdata-0.1.18}/labdata/compute/pose.py +13 -1
  5. {labdata-0.1.16 → labdata-0.1.18}/labdata/compute/utils.py +104 -17
  6. {labdata-0.1.16 → labdata-0.1.18}/labdata/dashboard/admin.py +5 -0
  7. {labdata-0.1.16 → labdata-0.1.18}/labdata/dashboard/compute.py +53 -20
  8. labdata-0.1.18/labdata/dashboard/drift.py +331 -0
  9. {labdata-0.1.16 → labdata-0.1.18}/labdata/dashboard/index.py +87 -36
  10. {labdata-0.1.16 → labdata-0.1.18}/labdata/dashboard/notes.py +21 -11
  11. labdata-0.1.18/labdata/dashboard/procedures.py +818 -0
  12. {labdata-0.1.16 → labdata-0.1.18}/labdata/dashboard/segmentation.py +46 -28
  13. {labdata-0.1.16 → labdata-0.1.18}/labdata/dashboard/sorting.py +378 -197
  14. {labdata-0.1.16 → labdata-0.1.18}/labdata/dashboard/video.py +191 -64
  15. labdata-0.1.18/labdata/dashboard/writes.py +116 -0
  16. {labdata-0.1.16 → labdata-0.1.18}/labdata/schema/ephys.py +54 -20
  17. {labdata-0.1.16 → labdata-0.1.18}/labdata/schema/procedures.py +5 -3
  18. {labdata-0.1.16 → labdata-0.1.18}/labdata/schema/video.py +143 -40
  19. {labdata-0.1.16 → labdata-0.1.18}/labdata/utils.py +5 -1
  20. {labdata-0.1.16 → labdata-0.1.18}/labdata.egg-info/PKG-INFO +1 -1
  21. {labdata-0.1.16 → labdata-0.1.18}/labdata.egg-info/SOURCES.txt +2 -0
  22. labdata-0.1.16/labdata/dashboard/procedures.py +0 -318
  23. {labdata-0.1.16 → labdata-0.1.18}/LICENSE +0 -0
  24. {labdata-0.1.16 → labdata-0.1.18}/README.md +0 -0
  25. {labdata-0.1.16 → labdata-0.1.18}/labdata/compute/__init__.py +0 -0
  26. {labdata-0.1.16 → labdata-0.1.18}/labdata/compute/caiman.py +0 -0
  27. {labdata-0.1.16 → labdata-0.1.18}/labdata/compute/ec2.py +0 -0
  28. {labdata-0.1.16 → labdata-0.1.18}/labdata/compute/ephys.py +0 -0
  29. {labdata-0.1.16 → labdata-0.1.18}/labdata/compute/schedulers.py +0 -0
  30. {labdata-0.1.16 → labdata-0.1.18}/labdata/compute/singularity.py +0 -0
  31. {labdata-0.1.16 → labdata-0.1.18}/labdata/compute/suite2p.py +0 -0
  32. {labdata-0.1.16 → labdata-0.1.18}/labdata/copy.py +0 -0
  33. {labdata-0.1.16 → labdata-0.1.18}/labdata/dashboard/__init__.py +0 -0
  34. {labdata-0.1.16 → labdata-0.1.18}/labdata/dashboard/ai_utils.py +0 -0
  35. {labdata-0.1.16 → labdata-0.1.18}/labdata/dashboard/console.py +0 -0
  36. {labdata-0.1.16 → labdata-0.1.18}/labdata/dashboard/decisiontask.py +0 -0
  37. {labdata-0.1.16 → labdata-0.1.18}/labdata/dashboard/docstrings.py +0 -0
  38. {labdata-0.1.16 → labdata-0.1.18}/labdata/dashboard/schema_graph.py +0 -0
  39. {labdata-0.1.16 → labdata-0.1.18}/labdata/export.py +0 -0
  40. {labdata-0.1.16 → labdata-0.1.18}/labdata/rules/__init__.py +0 -0
  41. {labdata-0.1.16 → labdata-0.1.18}/labdata/rules/ephys.py +0 -0
  42. {labdata-0.1.16 → labdata-0.1.18}/labdata/rules/imaging.py +0 -0
  43. {labdata-0.1.16 → labdata-0.1.18}/labdata/rules/utils.py +0 -0
  44. {labdata-0.1.16 → labdata-0.1.18}/labdata/s3.py +0 -0
  45. {labdata-0.1.16 → labdata-0.1.18}/labdata/schema/__init__.py +0 -0
  46. {labdata-0.1.16 → labdata-0.1.18}/labdata/schema/general.py +0 -0
  47. {labdata-0.1.16 → labdata-0.1.18}/labdata/schema/histology.py +0 -0
  48. {labdata-0.1.16 → labdata-0.1.18}/labdata/schema/onephoton.py +0 -0
  49. {labdata-0.1.16 → labdata-0.1.18}/labdata/schema/tasks.py +0 -0
  50. {labdata-0.1.16 → labdata-0.1.18}/labdata/schema/twophoton.py +0 -0
  51. {labdata-0.1.16 → labdata-0.1.18}/labdata/schema/utils.py +0 -0
  52. {labdata-0.1.16 → labdata-0.1.18}/labdata/stacks.py +0 -0
  53. {labdata-0.1.16 → labdata-0.1.18}/labdata/widgets.py +0 -0
  54. {labdata-0.1.16 → labdata-0.1.18}/labdata.egg-info/dependency_links.txt +0 -0
  55. {labdata-0.1.16 → labdata-0.1.18}/labdata.egg-info/entry_points.txt +0 -0
  56. {labdata-0.1.16 → labdata-0.1.18}/labdata.egg-info/requires.txt +0 -0
  57. {labdata-0.1.16 → labdata-0.1.18}/labdata.egg-info/top_level.txt +0 -0
  58. {labdata-0.1.16 → labdata-0.1.18}/pyproject.toml +0 -0
  59. {labdata-0.1.16 → labdata-0.1.18}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labdata
3
- Version: 0.1.16
3
+ Version: 0.1.18
4
4
  Summary: Package to manage data in experimental neuroscience labs
5
5
  Author-email: Joao Couto <jpcouto@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/jcouto/labdata
@@ -1,4 +1,4 @@
1
- VERSION = "0.1.16"
1
+ VERSION = "0.1.18"
2
2
 
3
3
  from .utils import *
4
4
  from .copy import copy_to_upload_server
@@ -625,16 +625,21 @@ Available analysis are
625
625
  return
626
626
  from .compute import parse_analysis, run_analysis
627
627
  # parse analysis will check if the analysis is defined
628
- jobids,obj = parse_analysis(analysis = args.analysis,
629
- job_id = args.job,
630
- subject = args.subject,
631
- session = args.session,
632
- datatype = args.datatype,
633
- secondary_args = secondary_args,
634
- multisession = args.multisession,
635
- force_submit = args.force_submit,
636
- full_command = ' '.join(sys.argv[1:]),
637
- project = args.project)
628
+ try:
629
+ jobids,obj = parse_analysis(analysis = args.analysis,
630
+ job_id = args.job,
631
+ subject = args.subject,
632
+ session = args.session,
633
+ datatype = args.datatype,
634
+ secondary_args = secondary_args,
635
+ multisession = args.multisession,
636
+ force_submit = args.force_submit,
637
+ full_command = ' '.join(sys.argv[1:]),
638
+ project = args.project)
639
+ except (ValueError, NotImplementedError) as err:
640
+ # what the request asked for is not there: say what, and stop
641
+ print(f'{tcolor["r"]("Nothing to run:")} {err}')
642
+ sys.exit(1)
638
643
  if not len(jobids):
639
644
  print('Nothing to run.')
640
645
  return
@@ -159,7 +159,19 @@ class DeeplabcutCompute(BaseCompute):
159
159
  raise(NotImplementedError('Specifying no session is not yet implemented'))
160
160
  datasets = []
161
161
  for k in keys:
162
- datasets += (self.schema.DatasetVideo()& k).fetch(as_dict = True)
162
+ videos = (self.schema.DatasetVideo() & k).fetch(as_dict = True)
163
+ if not len(videos):
164
+ raise ValueError(f'Session {k["session_name"]} of {k["subject_name"]} has no video '
165
+ '(nothing in DatasetVideo); videos are registered when the '
166
+ 'session is uploaded.')
167
+ video_name = self.parameters.get('video_name')
168
+ if video_name is not None:
169
+ names = sorted({v['video_name'] for v in videos})
170
+ videos = [v for v in videos if v['video_name'] == video_name]
171
+ if not len(videos):
172
+ raise ValueError(f'Session {k["session_name"]} of {k["subject_name"]} has no video '
173
+ f'named {video_name}; it has {", ".join(names)}.')
174
+ datasets += videos
163
175
  datasets = [dict(subject_name = d['subject_name'],
164
176
  session_name = d['session_name'],
165
177
  dataset_name = d['dataset_name']) for d in datasets]
@@ -1,13 +1,36 @@
1
1
  from ..utils import *
2
2
  import traceback
3
3
 
4
- def load_analysis_object(analysis):
4
+ def available_analyses(project=None):
5
+ '''The compute classes of the preference file that are loaded, by analysis name.
6
+
7
+ An entry whose class is not there, because it comes from a plugin that is
8
+ not loaded, or is not a compute class at all, is left out. With
9
+ ``project`` the project's plugins are loaded first.
10
+ '''
11
+ import labdata
12
+ if project is not None:
13
+ load_project_schema(project)
14
+ analyses = {}
15
+ for name, path in prefs['compute']['analysis'].items():
16
+ try:
17
+ obj = eval(path)
18
+ except (AttributeError, NameError):
19
+ continue
20
+ if isinstance(obj, type) and issubclass(obj, BaseCompute):
21
+ analyses[name] = obj
22
+ return analyses
23
+
24
+
25
+ def load_analysis_object(analysis, project=None):
5
26
  '''Load a compute class by its analysis name from user preferences.
6
27
 
7
28
  Parameters
8
29
  ----------
9
30
  analysis : str
10
31
  Key in ``prefs['compute']['analysis']`` (e.g. ``'spks'``, ``'suite2p'``).
32
+ project : str or None, optional
33
+ Project whose plugins to load before looking for the class.
11
34
 
12
35
  Returns
13
36
  -------
@@ -17,23 +40,22 @@ def load_analysis_object(analysis):
17
40
  Raises
18
41
  ------
19
42
  SystemExit
20
- If the analysis name is not found in preferences, prints a help message
21
- listing available analyses and exits.
43
+ If the analysis is not among the loaded ones, prints a help message
44
+ listing the analyses that are and exits.
22
45
  '''
23
- if not analysis in prefs['compute']['analysis'].keys():
46
+ analyses = available_analyses(project)
47
+ if analysis not in analyses:
48
+ path = prefs['compute']['analysis'].get(analysis)
49
+ where = f' for project {project}' if project else ''
50
+ because = (f' ({path} is not loaded{where}, or is not a compute class)' if path else '')
24
51
  print(f'''
25
- Could not find {tcolor['r'](analysis)} analysis.
26
-
27
- The analysis are {tcolor['g'](','.join(prefs["compute"]["analysis"].keys()))}
28
-
52
+ Could not find {tcolor['r'](analysis)} analysis{because}.
53
+ The analyses are {tcolor['g'](','.join(analyses.keys()))}
29
54
  Add the analysis to the "{tcolor['y']("compute")}" section of the preference file {tcolor['y']("{analysis_name:analysis_object}")}.
30
-
31
55
  ''')
32
- sys.exit()
33
- raise ValueError('Add the analysis to the "compute" section of the preference file {analysis_name:analysis_object}.\n\n')
34
-
35
- import labdata
36
- return eval(prefs['compute']['analysis'][analysis])
56
+ sys.exit(1)
57
+ return analyses[analysis]
58
+
37
59
 
38
60
  def handle_compute(job_id, project=None):
39
61
  '''Instantiate and return the correct compute object for a queued job.
@@ -61,7 +83,7 @@ def handle_compute(job_id, project=None):
61
83
  jobinfo = jobinfo.iloc[0]
62
84
  if jobinfo.task_waiting == 0:
63
85
  print(f'Task {job_id} is running on {jobinfo.task_host}')
64
- obj = load_analysis_object(jobinfo.task_name)(jobinfo.job_id,project)
86
+ obj = load_analysis_object(jobinfo.task_name, project=project)(jobinfo.job_id,project)
65
87
  return obj
66
88
 
67
89
  def parse_analysis(analysis, job_id=None,
@@ -115,7 +137,7 @@ def parse_analysis(analysis, job_id=None,
115
137
  obj : BaseCompute
116
138
  Instantiated compute object.
117
139
  '''
118
- obj = load_analysis_object(analysis)(job_id, project=project)
140
+ obj = load_analysis_object(analysis, project=project)(job_id, project=project)
119
141
  obj.secondary_parse(secondary_args, parameter_number)
120
142
  if obj.job_id is None:
121
143
  # then we have to create jobs and assign
@@ -123,6 +145,9 @@ def parse_analysis(analysis, job_id=None,
123
145
  schema = load_project_schema(project)
124
146
  else:
125
147
  schema = obj.schema
148
+ # a subject or session that is not in the project is the usual reason
149
+ # for finding nothing; say so, with the closest names, before looking
150
+ check_subject_and_session(schema, subject, session, project = project)
126
151
  # first check if there is a task that has already been submitted with the exact same command.
127
152
  # this has a caveat: if the order of the arguments is switched, it wont work..
128
153
  if not full_command is None:
@@ -135,6 +160,12 @@ def parse_analysis(analysis, job_id=None,
135
160
  return [], None
136
161
  if not subject is None or not session is None:
137
162
  datasets = obj.find_datasets(subject_name = subject, session_name = session)
163
+ if datasets is not None and not len(datasets):
164
+ asked = ' '.join(f'{name} {", ".join(_as_names(v))}' for name, v in
165
+ (('subject', subject), ('session', session)) if _as_names(v))
166
+ print(tcolor['r'](f'No dataset for {analysis} matches {asked}.')
167
+ + _datasets_on_record(schema, subject, session))
168
+ return [], obj
138
169
  else:
139
170
  datasets = None
140
171
  job_ids = obj.place_tasks_in_queue(datasets,
@@ -144,6 +175,60 @@ def parse_analysis(analysis, job_id=None,
144
175
  # now we have the job ids, need to figure out how to launch the jobs
145
176
  return job_ids, obj # returns the job ids and the task
146
177
 
178
+ def _as_names(value):
179
+ '''The names in a command line argument: a string, a list of strings, or None.'''
180
+ if value is None:
181
+ return []
182
+ if isinstance(value, str):
183
+ value = [value]
184
+ return [str(v) for v in value if str(v).strip()]
185
+
186
+
187
+ def _datasets_on_record(schema, subject, session):
188
+ '''One line saying what datasets the requested sessions do have, or nothing.'''
189
+ keys = [dict(subject_name = a, session_name = s) for a in _as_names(subject) for s in _as_names(session)]
190
+ if not keys:
191
+ return ''
192
+ rows = (schema.Dataset() & keys).fetch('session_name', 'dataset_name', 'dataset_type', as_dict = True)
193
+ if not rows:
194
+ return ' The session has no dataset on record.'
195
+ have = sorted({f'{r["dataset_name"]} ({r["dataset_type"] or "no type"})' for r in rows})
196
+ return f' On record: {", ".join(have)}.'
197
+
198
+
199
+ def check_subject_and_session(schema, subject=None, session=None, project=None):
200
+ '''Raise a ValueError naming what is not in the project, before a compute looks for datasets.
201
+
202
+ A subject that is not in ``Subject`` is reported with the closest names
203
+ on record; a session that the subject does not have is reported with the
204
+ subject's latest sessions. Nothing happens when neither is given.
205
+ '''
206
+ import difflib
207
+ where = f'project {project}' if project else 'the project'
208
+ subjects = _as_names(subject)
209
+ sessions = _as_names(session)
210
+ if not subjects and not sessions:
211
+ return
212
+ known = list(schema.Subject().fetch('subject_name'))
213
+ for name in subjects:
214
+ if name not in known:
215
+ close = difflib.get_close_matches(name, known, n = 5, cutoff = 0.6)
216
+ hint = f' Close names: {", ".join(close)}.' if close else ''
217
+ raise ValueError(f'Subject {name} is not in {where}.{hint}')
218
+ if sessions and not subjects:
219
+ raise ValueError('A session needs its subject: pass the subject name as well.')
220
+ for name in subjects:
221
+ have = list((schema.Session() & dict(subject_name = name)).fetch(
222
+ 'session_name', order_by = 'session_datetime DESC'))
223
+ for ses in sessions:
224
+ if ses not in have:
225
+ close = difflib.get_close_matches(ses, have, n = 3, cutoff = 0.6)
226
+ hint = (f' Close names: {", ".join(close)}.' if close else
227
+ f' Its latest sessions: {", ".join(have[:5])}.' if have else
228
+ ' It has no sessions.')
229
+ raise ValueError(f'Subject {name} has no session {ses} in {where}.{hint}')
230
+
231
+
147
232
  def check_archived(jobid, check_local=False, schema=None):
148
233
  '''Check whether files assigned to a compute job are in S3 Glacier.
149
234
 
@@ -591,7 +676,9 @@ class BaseCompute():
591
676
  idx += list(filter(lambda x: not x is None,[i if f in s else None for i,s in enumerate(
592
677
  files.file_path.values)]))
593
678
  if len(idx) == 0:
594
- raise ValueError(f'Could not find valid Dataset.DataFiles for {dataset}')
679
+ found = ', '.join(Path(f).name for f in files.file_path.values[:8]) if len(files) else 'no files at all'
680
+ raise ValueError(f'{self.name} needs a file of type {", ".join(self.file_filters)} '
681
+ f'and dataset {dataset} has {found}.')
595
682
  files = files.iloc[idx]
596
683
  if type(dataset) is dict:
597
684
  key = dict(dataset,task_name = self.name)
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
3
3
 
4
4
  import pandas as pd
5
5
  import streamlit as st
6
+ from writes import log
6
7
  import plotly.express as px
7
8
 
8
9
 
@@ -285,8 +286,10 @@ def _add_user_dialog(project, user):
285
286
  add_user_to_project(project, user)
286
287
  get_project_users.clear()
287
288
  st.session_state['admin_msg'] = ('success', f'Added {user} to {project}.')
289
+ log(f'added user {user} to project {project}')
288
290
  except Exception as e:
289
291
  st.session_state['admin_msg'] = ('error', str(e))
292
+ log(f'could not add user {user} to project {project}: {e}')
290
293
  st.rerun()
291
294
  if col2.button("Cancel", key="add_cancel"):
292
295
  st.rerun()
@@ -304,8 +307,10 @@ def _remove_user_dialog(project, user):
304
307
  remove_user_from_project(project, user)
305
308
  get_project_users.clear()
306
309
  st.session_state['admin_msg'] = ('success', f'Removed {user} from {project}.')
310
+ log(f'removed user {user} from project {project}')
307
311
  except Exception as e:
308
312
  st.session_state['admin_msg'] = ('error', str(e))
313
+ log(f'could not remove user {user} from project {project}: {e}')
309
314
  st.rerun()
310
315
  if col2.button("Cancel", key="remove_cancel"):
311
316
  st.rerun()
@@ -1,6 +1,7 @@
1
1
  import pandas as pd
2
2
  import numpy as np
3
3
  import streamlit as st
4
+ from writes import write, show_flash, log
4
5
 
5
6
  STATES = ['COMPLETED', 'WORKING', 'WAITING', 'FAILED', 'CANCELED','ON SERVER']
6
7
  STATE_COLORS = ['#01D186', '#48CAE4', '#F3A21C', '#FF5733', '#F3A21C','#F3A21C']
@@ -50,6 +51,21 @@ def _resubmit_jobs(schema, job_ids, project, target_override=None):
50
51
  return errors
51
52
 
52
53
 
54
+ def _fetch_rows(table):
55
+ '''Fetch all rows of a job table, reconnecting once if the first attempt fails.
56
+
57
+ The dashboard shares one database connection between browser sessions, and
58
+ the client can be left half closed (a stale socket, a packet sequence
59
+ error, or ``'NoneType' object has no attribute 'read'`` from pymysql).
60
+ Such failures clear on a fresh connection, so retry once before giving up.
61
+ '''
62
+ try:
63
+ return table.fetch(as_dict=True)
64
+ except Exception:
65
+ table.connection.connect()
66
+ return table.fetch(as_dict=True)
67
+
68
+
53
69
  def _jobs_panel(table, status_field, target_field, cols, delete_key,
54
70
  resubmit_fn=None, allow_delete_completed=False, on_server_fn=None):
55
71
 
@@ -59,20 +75,13 @@ def _jobs_panel(table, status_field, target_field, cols, delete_key,
59
75
  if st.session_state.get(f'{delete_key}_resubmit_errors'):
60
76
  for e in st.session_state.pop(f'{delete_key}_resubmit_errors'):
61
77
  st.error(e)
78
+ show_flash(f'{delete_key}_msg')
62
79
 
63
80
  try:
64
- rows = table.fetch(as_dict=True)
81
+ rows = _fetch_rows(table)
65
82
  except Exception as e:
66
- if 'Packet sequence' in str(e) or 'sequence number wrong' in str(e):
67
- try:
68
- table.connection.connect()
69
- rows = table.fetch(as_dict=True)
70
- except Exception as e2:
71
- st.error(f'Database connection error: {e2}')
72
- return
73
- else:
74
- st.error(f'Error fetching jobs: {e}')
75
- return
83
+ st.error(f'Could not fetch the jobs: {e}')
84
+ return
76
85
  if not rows:
77
86
  st.info('There are no jobs in the **ComputeTask** table.')
78
87
  return
@@ -95,9 +104,14 @@ def _jobs_panel(table, status_field, target_field, cols, delete_key,
95
104
  st.warning(f'Delete all {n} COMPLETED job(s)? This cannot be undone.')
96
105
  col_yes, col_no, _ = st.columns([1, 1, 4])
97
106
  if col_yes.button('Yes, delete', type='primary', key=f'{delete_key}_del_completed_yes'):
98
- (table & f'{status_field} = "COMPLETED"').delete(safemode=False)
107
+ ok, _ = write(table, 'delete the completed jobs',
108
+ lambda: (table & f'{status_field} = "COMPLETED"').delete(
109
+ safemode=False, transaction=False),
110
+ success=f'Deleted {n} completed job(s).',
111
+ flash_key=f'{delete_key}_msg')
99
112
  st.session_state.pop(f'{delete_key}_del_completed_pending', None)
100
- st.rerun()
113
+ if ok:
114
+ st.rerun()
101
115
  if col_no.button('Cancel', key=f'{delete_key}_del_completed_no'):
102
116
  st.session_state.pop(f'{delete_key}_del_completed_pending', None)
103
117
  st.rerun()
@@ -175,9 +189,14 @@ def _jobs_panel(table, status_field, target_field, cols, delete_key,
175
189
  st.warning(f'Delete all {len(jobs_to_delete)} filtered job(s)? This cannot be undone.')
176
190
  col_yes, col_no, _ = st.columns([1, 1, 4])
177
191
  if col_yes.button('Yes, delete', type='primary', key=f'{delete_key}_del_filtered_yes'):
178
- (table & [dict(job_id=j) for j in jobs_to_delete]).delete(safemode=False)
192
+ ok, _ = write(table, 'delete the filtered jobs',
193
+ lambda: (table & [dict(job_id=j) for j in jobs_to_delete]).delete(
194
+ safemode=False, transaction=False),
195
+ success=f'Deleted {len(jobs_to_delete)} job(s).',
196
+ flash_key=f'{delete_key}_msg')
179
197
  st.session_state.pop(f'{delete_key}_del_filtered_pending', None)
180
- st.rerun()
198
+ if ok:
199
+ st.rerun()
181
200
  if col_no.button('Cancel', key=f'{delete_key}_del_filtered_no'):
182
201
  st.session_state.pop(f'{delete_key}_del_filtered_pending', None)
183
202
  st.rerun()
@@ -221,10 +240,14 @@ def _jobs_panel(table, status_field, target_field, cols, delete_key,
221
240
  st.warning(f'Are you sure you want to delete {len(jobs_to_delete)} job(s)? This cannot be undone and scheduled jobs might fail.')
222
241
  col_yes, col_no, _ = st.columns([1, 1, 4])
223
242
  if col_yes.button('Yes, delete', type='primary', key=f'{delete_key}_yes'):
224
- (table & [dict(job_id=j) for j in jobs_to_delete]).delete(safemode=False)
243
+ ok, _ = write(table, 'delete the selected jobs',
244
+ lambda: (table & [dict(job_id=j) for j in jobs_to_delete]).delete(
245
+ safemode=False, transaction=False),
246
+ success=f'Deleted {len(jobs_to_delete)} job(s).',
247
+ flash_key=f'{delete_key}_msg')
225
248
  st.session_state.pop(f'{delete_key}_pending', None)
226
- st.success(f'Deleted {len(jobs_to_delete)} job(s).')
227
- st.rerun()
249
+ if ok:
250
+ st.rerun()
228
251
  if col_no.button('Cancel', key=f'{delete_key}_no'):
229
252
  st.session_state.pop(f'{delete_key}_pending', None)
230
253
  st.rerun()
@@ -245,6 +268,13 @@ def _jobs_panel(table, status_field, target_field, cols, delete_key,
245
268
  st.session_state.pop(f'{delete_key}_resubmit_pending', None)
246
269
  if errors:
247
270
  st.session_state[f'{delete_key}_resubmit_errors'] = errors
271
+ log(f'could not re-submit {len(errors)} of {len(jobs_to_resubmit)} job(s): '
272
+ + '; '.join(str(e) for e in errors))
273
+ else:
274
+ st.session_state[f'{delete_key}_msg'] = (
275
+ f'Re-submitted {len(jobs_to_resubmit)} job(s).')
276
+ log(f're-submitted job(s) {", ".join(str(j) for j in jobs_to_resubmit)}'
277
+ + (f' to {override}' if override else ''))
248
278
  st.rerun()
249
279
  if col_no.button('Cancel', key=f'{delete_key}_resubmit_no'):
250
280
  st.session_state.pop(f'{delete_key}_resubmit_pending', None)
@@ -255,9 +285,12 @@ def _jobs_panel(table, status_field, target_field, cols, delete_key,
255
285
  st.warning(f'Reset {len(jobs_to_reset)} job(s) to **ON SERVER** / waiting?')
256
286
  col_yes, col_no, _ = st.columns([1, 1, 4])
257
287
  if col_yes.button('Yes, reset', type='primary', key=f'{delete_key}_on_server_yes'):
258
- on_server_fn(jobs_to_reset)
288
+ ok, _ = write(table, 'reset the jobs', lambda: on_server_fn(jobs_to_reset),
289
+ success=f'Reset {len(jobs_to_reset)} job(s) to ON SERVER.',
290
+ flash_key=f'{delete_key}_msg')
259
291
  st.session_state.pop(f'{delete_key}_on_server_pending', None)
260
- st.rerun()
292
+ if ok:
293
+ st.rerun()
261
294
  if col_no.button('Cancel', key=f'{delete_key}_on_server_no'):
262
295
  st.session_state.pop(f'{delete_key}_on_server_pending', None)
263
296
  st.rerun()