Skip to content

Commit 0e54bc8

Browse files
authored
Add ability to cancel tasks, automatically fail running tasks on app opening (#36)
* Add ability to cancel tasks, automatically fail running tasks on app opening * add cancel button on the task progress page
1 parent 46285e5 commit 0e54bc8

File tree

8 files changed

+213
-2
lines changed

8 files changed

+213
-2
lines changed

deid/home/apps.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from django.apps import AppConfig
2+
3+
4+
class HomeConfig(AppConfig):
5+
default_auto_field = 'django.db.models.BigAutoField'
6+
name = 'home'
7+
8+
def ready(self):
9+
from django.db import OperationalError, ProgrammingError
10+
try:
11+
from .models import Project
12+
stale_count = Project.objects.filter(
13+
status__in=[Project.TaskStatus.PENDING, Project.TaskStatus.RUNNING]
14+
).update(status=Project.TaskStatus.FAILED, process_pid=None)
15+
if stale_count > 0:
16+
print(f"Marked {stale_count} stale task(s) as FAILED on startup")
17+
except (OperationalError, ProgrammingError):
18+
pass
19+

deid/home/management/commands/worker.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ def run_subprocess_and_capture_log_path(cmd, env, task):
3535
bufsize=1
3636
)
3737

38+
task.process_pid = process.pid
39+
task.save()
40+
3841
log_path_captured = False
3942
stdout_lines = []
4043
stderr_lines = []
@@ -673,6 +676,7 @@ def run_worker():
673676
print(f"Error processing task {task.id}: {str(e)}")
674677
task.status = Project.TaskStatus.FAILED
675678
finally:
679+
task.process_pid = None
676680
task.save()
677681
print(f"Task {task.id} finished with status: {task.status}")
678682

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 5.2.7 on 2025-12-03 20:26
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('home', '0001_initial'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='project',
15+
name='process_pid',
16+
field=models.IntegerField(blank=True, null=True),
17+
),
18+
migrations.AlterField(
19+
model_name='project',
20+
name='status',
21+
field=models.CharField(choices=[('PENDING', 'Pending'), ('RUNNING', 'Running'), ('COMPLETED', 'Completed'), ('FAILED', 'Failed'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20),
22+
),
23+
migrations.AlterField(
24+
model_name='project',
25+
name='task_type',
26+
field=models.CharField(choices=[('IMAGE_DEID', 'Image Deidentification'), ('IMAGE_QUERY', 'Image Query'), ('HEADER_QUERY', 'Header Query'), ('HEADER_EXTRACT', 'Header Extract'), ('TEXT_DEID', 'Text Deidentification'), ('IMAGE_EXPORT', 'Image Export'), ('IMAGE_DEID_EXPORT', 'Image Deidentification and Export'), ('GENERAL_MODULE', 'General Module')], max_length=25),
27+
),
28+
]

deid/home/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@ class TaskStatus(models.TextChoices):
2626
RUNNING = 'RUNNING', 'Running'
2727
COMPLETED = 'COMPLETED', 'Completed'
2828
FAILED = 'FAILED', 'Failed'
29+
CANCELLED = 'CANCELLED', 'Cancelled'
2930

3031
status = models.CharField(
3132
max_length=20,
3233
choices=TaskStatus.choices,
3334
default=TaskStatus.PENDING
3435
)
36+
process_pid = models.IntegerField(null=True, blank=True)
3537
created_at = models.DateTimeField(auto_now_add=True)
3638
updated_at = models.DateTimeField(auto_now=True)
3739
scheduled_time = models.DateTimeField(null=True, blank=True)

deid/home/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
path('verify_admin_password/', views.verify_admin_password, name='verify_admin_password'),
4141
path('task_list/', views.TaskListView.as_view(), name='task_list'),
4242
path('delete_task/<int:task_id>/', views.delete_task, name='delete_task'),
43+
path('cancel_task/<int:task_id>/', views.cancel_task, name='cancel_task'),
4344
path('upload_module/', views.upload_module, name='upload_module'),
4445
path('get_modules/', views.get_modules, name='get_modules'),
4546
path('reset_deid_settings/', views.reset_deid_settings, name='reset_deid_settings'),

deid/home/views.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import bcrypt
1414
import pandas as pd
15+
import psutil
1516
import pytz
1617
from django.db import OperationalError
1718
from django.http import (
@@ -1112,6 +1113,51 @@ def delete_task(request, task_id):
11121113
except Exception as e:
11131114
return JsonResponse({'status': 'error', 'message': str(e)}, status=400)
11141115

1116+
1117+
def kill_process_tree(pid):
1118+
try:
1119+
parent = psutil.Process(pid)
1120+
children = parent.children(recursive=True)
1121+
for child in children:
1122+
try:
1123+
child.terminate()
1124+
except psutil.NoSuchProcess:
1125+
pass
1126+
parent.terminate()
1127+
gone, alive = psutil.wait_procs(children + [parent], timeout=5)
1128+
for p in alive:
1129+
try:
1130+
p.kill()
1131+
except psutil.NoSuchProcess:
1132+
pass
1133+
except psutil.NoSuchProcess:
1134+
pass
1135+
1136+
1137+
@require_http_methods(["POST"])
1138+
@csrf_exempt
1139+
def cancel_task(request, task_id):
1140+
try:
1141+
task = get_object_or_404(Project, id=task_id)
1142+
1143+
if task.status not in [Project.TaskStatus.PENDING, Project.TaskStatus.RUNNING]:
1144+
return JsonResponse({
1145+
'status': 'error',
1146+
'message': 'Only pending or running tasks can be cancelled'
1147+
}, status=400)
1148+
1149+
if task.status == Project.TaskStatus.RUNNING and task.process_pid:
1150+
kill_process_tree(task.process_pid)
1151+
1152+
task.status = Project.TaskStatus.CANCELLED
1153+
task.process_pid = None
1154+
task.save()
1155+
1156+
return JsonResponse({'status': 'success'})
1157+
except Exception as e:
1158+
return JsonResponse({'status': 'error', 'message': str(e)}, status=400)
1159+
1160+
11151161
# Add this middleware function to process timezone from session
11161162
def timezone_middleware(get_response):
11171163
def middleware(request):

deid/templates/task_list.html

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ <h1 class="text-xl flex-1">Projects</h1>
2929
{% if task.status == 'PENDING' %}bg-yellow-100 text-yellow-800
3030
{% elif task.status == 'RUNNING' %}bg-blue-100 text-blue-800
3131
{% elif task.status == 'COMPLETED' %}bg-green-100 text-green-800
32+
{% elif task.status == 'CANCELLED' %}bg-gray-100 text-gray-800
3233
{% else %}bg-red-100 text-red-800{% endif %}">
3334
{{ task.get_status_display }}
3435
</span>
@@ -47,7 +48,10 @@ <h1 class="text-xl flex-1">Projects</h1>
4748
<div class="flex space-x-4 justify-end">
4849
<a href="{% url 'task_progress' %}?project_id={{ task.id }}"
4950
class="text-indigo-600 hover:text-indigo-900">Open</a>
50-
{% if task.status != 'RUNNING' %}
51+
{% if task.status == 'PENDING' or task.status == 'RUNNING' %}
52+
<button onclick="cancelTask('{{ task.id }}')"
53+
class="text-orange-600 hover:text-orange-900">Cancel</button>
54+
{% else %}
5155
<button onclick="deleteTask('{{ task.id }}')"
5256
class="text-red-600 hover:text-red-900">Delete</button>
5357
{% endif %}
@@ -71,6 +75,31 @@ <h1 class="text-xl flex-1">Projects</h1>
7175
return document.querySelector('meta[name="csrf_token"]').getAttribute('content');
7276
}
7377

78+
async function cancelTask(taskId) {
79+
if (!confirm('Are you sure you want to cancel this task?')) {
80+
return;
81+
}
82+
83+
try {
84+
const response = await fetch(`/cancel_task/${taskId}/`, {
85+
method: 'POST',
86+
headers: {
87+
'X-CSRFToken': getCsrfToken(),
88+
},
89+
});
90+
91+
if (response.ok) {
92+
window.location.reload();
93+
} else {
94+
const result = await response.json();
95+
alert('Failed to cancel task: ' + (result.message || 'Unknown error'));
96+
}
97+
} catch (error) {
98+
console.error('Error:', error);
99+
alert('Failed to cancel task: ' + error.message);
100+
}
101+
}
102+
74103
async function deleteTask(taskId) {
75104
if (!confirm('Are you sure you want to delete this project?')) {
76105
return;

deid/templates/task_progress.html

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ <h1 class="text-xl flex-1">{{ module_name }} ({{ project_name }})</h1>
99
<button id="openOutputFolder" class="px-2 py-1 bg-white shadow text-xs hover:bg-gray-100">Open output folder</button>
1010
<div>
1111
<button id="openAppdataFolder" class="px-2 py-1 bg-white shadow text-xs hover:bg-gray-100 mr-2">Open appdata folder</button>
12-
<button id="openLogsFolder" class="px-2 py-1 bg-white shadow text-xs hover:bg-gray-100">Open logs folder</button>
12+
<button id="openLogsFolder" class="px-2 py-1 bg-white shadow text-xs hover:bg-gray-100 mr-2">Open logs folder</button>
13+
<button id="cancelTask" class="px-2 py-1 bg-white shadow text-xs hover:bg-gray-100 text-orange-600 hover:text-orange-900 hidden">Cancel task</button>
1314
</div>
1415
</div>
1516
<div id="errorPopup" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full">
@@ -27,6 +28,21 @@ <h3 class="text-lg leading-6 font-medium text-gray-900">Error</h3>
2728
</div>
2829
</div>
2930
</div>
31+
<div id="cancelledPopup" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full">
32+
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
33+
<div class="mt-3 text-center">
34+
<h3 id="cancelledTitle" class="text-lg leading-6 font-medium text-gray-900">Task Cancelled</h3>
35+
<div class="mt-2 px-7 py-3">
36+
<p id="cancelledMessage" class="text-sm text-gray-500"></p>
37+
</div>
38+
<div class="items-center px-4 py-3">
39+
<button id="closeCancelled" class="px-4 py-2 bg-green-500 text-white text-base font-medium rounded-md shadow-sm hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-300">
40+
Close
41+
</button>
42+
</div>
43+
</div>
44+
</div>
45+
</div>
3046
<pre class="bg-black text-white p-4 font-mono text-xs h-[calc(100vh-13rem-45px)] overflow-y-auto whitespace-pre-wrap" id="logContent">
3147
</pre>
3248
</div>
@@ -42,6 +58,7 @@ <h3 class="text-lg leading-6 font-medium text-gray-900">Error</h3>
4258
let sameContentCount = 0;
4359
let pollInterval;
4460
let hasError = false;
61+
let taskStatus = null;
4562

4663
// List of error messages from validate_config
4764
const errorMessages = [
@@ -68,6 +85,8 @@ <h3 class="text-lg leading-6 font-medium text-gray-900">Error</h3>
6885
const response = await fetch(`/api/task_status/${projectId}`);
6986
const data = await response.json();
7087

88+
taskStatus = data.status;
89+
7190
if (data.log_path && data.log_path !== '' && data.log_path !== logPath) {
7291
logPath = data.log_path;
7392
console.log('Log path updated to:', logPath);
@@ -84,6 +103,7 @@ <h3 class="text-lg leading-6 font-medium text-gray-900">Error</h3>
84103
}
85104

86105
updateFolderLinks();
106+
updateCancelButton();
87107

88108
if (data.status === 'FAILED') {
89109
hasError = true;
@@ -96,6 +116,18 @@ <h3 class="text-lg leading-6 font-medium text-gray-900">Error</h3>
96116
}, 3000);
97117
return;
98118
}
119+
120+
if (data.status === 'CANCELLED') {
121+
hasError = true;
122+
document.getElementById('cancelledMessage').textContent = "Task has been cancelled.";
123+
document.getElementById('cancelledPopup').classList.remove('hidden');
124+
clearInterval(pollInterval);
125+
clearInterval(statusInterval);
126+
setTimeout(() => {
127+
window.location.href = '/task_list';
128+
}, 3000);
129+
return;
130+
}
99131
} catch (error) {
100132
console.error('Error checking task status:', error);
101133
}
@@ -151,6 +183,53 @@ <h3 class="text-lg leading-6 font-medium text-gray-900">Error</h3>
151183
}
152184
}
153185

186+
function updateCancelButton() {
187+
const cancelBtn = document.getElementById('cancelTask');
188+
189+
if (taskStatus === 'PENDING' || taskStatus === 'RUNNING') {
190+
cancelBtn.classList.remove('hidden');
191+
} else {
192+
cancelBtn.classList.add('hidden');
193+
}
194+
}
195+
196+
function getCsrfToken() {
197+
return document.querySelector('meta[name="csrf_token"]').getAttribute('content');
198+
}
199+
200+
async function cancelTask() {
201+
if (!confirm('Are you sure you want to cancel this task?')) {
202+
return;
203+
}
204+
205+
try {
206+
const response = await fetch(`/cancel_task/${projectId}/`, {
207+
method: 'POST',
208+
headers: {
209+
'X-CSRFToken': getCsrfToken(),
210+
},
211+
});
212+
213+
if (response.ok) {
214+
hasError = true;
215+
document.getElementById('cancelledTitle').textContent = "Cancelling Task";
216+
document.getElementById('cancelledMessage').textContent = "Task is being cancelled...";
217+
document.getElementById('cancelledPopup').classList.remove('hidden');
218+
clearInterval(pollInterval);
219+
clearInterval(statusInterval);
220+
setTimeout(() => {
221+
window.location.href = '/task_list';
222+
}, 2000);
223+
} else {
224+
const result = await response.json();
225+
alert('Failed to cancel task: ' + (result.message || 'Unknown error'));
226+
}
227+
} catch (error) {
228+
console.error('Error:', error);
229+
alert('Failed to cancel task: ' + error.message);
230+
}
231+
}
232+
154233
async function fetchLogContent() {
155234
if (hasError) return;
156235

@@ -187,6 +266,9 @@ <h3 class="text-lg leading-6 font-medium text-gray-900">Error</h3>
187266
document.getElementById('errorPopup').classList.add('hidden');
188267
window.history.back(); // Return to previous page
189268
});
269+
270+
// Handle cancel button click
271+
document.getElementById('cancelTask').addEventListener('click', cancelTask);
190272

191273
// Initial fetch
192274
fetchLogContent();

0 commit comments

Comments
 (0)