Skip to content

Commit e8fe85e

Browse files
authored
Merge pull request #354 from OpenGeoscience/email-notifs
Email notifications upon task completion
2 parents c1c133c + 41d15ba commit e8fe85e

8 files changed

Lines changed: 123 additions & 2 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 5.2.9 on 2026-03-16 14:42
2+
from __future__ import annotations
3+
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("core", "0022_view_states"),
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name="taskresult",
17+
name="subscribers",
18+
field=models.ManyToManyField(
19+
blank=True, related_name="task_subscriptions", to=settings.AUTH_USER_MODEL
20+
),
21+
),
22+
]

uvdat/core/models/task_result.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44

55
from asgiref.sync import async_to_sync
66
from channels.layers import get_channel_layer
7+
from django.conf import settings
8+
from django.contrib.auth.models import User
9+
from django.core.mail import send_mail
710
from django.db import models
811
from django.db.models.signals import post_save
912
from django.dispatch import receiver
13+
from django.template.loader import render_to_string
1014
from django.utils import timezone
1115

1216
from .project import Project
@@ -25,6 +29,7 @@ class TaskResult(models.Model):
2529
error = models.TextField(blank=True, default="")
2630
created = models.DateTimeField(auto_now_add=True, editable=False)
2731
completed = models.DateTimeField(null=True)
32+
subscribers = models.ManyToManyField(User, related_name="task_subscriptions", blank=True)
2833

2934
project_filter_path = "project"
3035
project_filter_allow_null = True
@@ -53,6 +58,19 @@ def complete(self):
5358
self.status = f"Completed in {seconds:.2f} seconds."
5459
self.save()
5560

61+
for subscriber in self.subscribers.all():
62+
subject = "GeoDatalytics Task Completed"
63+
message = render_to_string(
64+
"uvdat/email_task_complete.txt",
65+
{"task_name": self.name, "link": settings.UVDAT_WEB_URL},
66+
)
67+
send_mail(
68+
subject=subject,
69+
message=message,
70+
from_email=None,
71+
recipient_list=[subscriber],
72+
)
73+
5674

5775
@receiver(post_save, sender=TaskResult)
5876
def result_post_save(sender, instance, **kwargs):

uvdat/core/rest/analytics.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import inspect
44

55
from django.db.models import QuerySet
6+
from django.shortcuts import get_object_or_404
67
from rest_framework.decorators import action
78
from rest_framework.response import Response
89
from rest_framework.serializers import ModelSerializer
@@ -110,3 +111,18 @@ def run(self, request, project_id: int, task_type: str, **kwargs):
110111
uvdat_serializers.TaskResultSerializer(result).data,
111112
status=200,
112113
)
114+
115+
@action(
116+
detail=False,
117+
methods=["post"],
118+
url_path=r"(?P<result_id>[\d*]+)/subscribe",
119+
)
120+
def subscribe(self, request, result_id, **kwargs):
121+
task_result = get_object_or_404(TaskResult, id=result_id)
122+
if task_result.completed:
123+
return Response("Task already completed. Subscription not applied.", status=410)
124+
task_result.subscribers.add(request.user)
125+
return Response(
126+
uvdat_serializers.TaskResultSerializer(task_result).data,
127+
status=200,
128+
)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
You are receiving this email because you have requested a notification upon the completion of a task in GeoDatalytics.
2+
3+
The following Analytics Task has been completed:
4+
5+
{{ task_name }}
6+
7+
View the results at {{ link }}.

uvdat/core/tests/test_analytics.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.core.management import call_command
55
import pytest
66

7-
from uvdat.core.models import Chart, Dataset, Network
7+
from uvdat.core.models import Chart, Dataset, Network, TaskResult
88
from uvdat.core.tasks.analytics import (
99
FloodNetworkFailure,
1010
FloodSimulation,
@@ -56,6 +56,28 @@ def test_rest_run_analysis_task_no_inputs(authenticated_api_client, user, projec
5656
assert "not provided" in resp.json()
5757

5858

59+
@pytest.mark.django_db
60+
def test_rest_subscribe_to_running_task(authenticated_api_client, user, project, mailoutbox):
61+
project.set_followers([user])
62+
task_result = TaskResult.objects.create(name="Test Task", project=project)
63+
resp = authenticated_api_client.post(f"/api/v1/analytics/{task_result.id}/subscribe/")
64+
assert resp.status_code == 200
65+
task_result.complete()
66+
message = next(filter(lambda m: m.subject == "GeoDatalytics Task Completed", mailoutbox), None)
67+
assert message is not None
68+
assert task_result.name in message.body
69+
70+
71+
@pytest.mark.django_db
72+
def test_rest_subscribe_to_completed_task(authenticated_api_client, user, project):
73+
project.set_followers([user])
74+
task_result = TaskResult.objects.create(name="Test Task", project=project)
75+
task_result.complete()
76+
resp = authenticated_api_client.post(f"/api/v1/analytics/{task_result.id}/subscribe/")
77+
assert resp.status_code == 410
78+
assert resp.json() == "Task already completed. Subscription not applied."
79+
80+
5981
@pytest.mark.slow
6082
@pytest.mark.django_db
6183
def test_flood_analysis_chain(project):

web/src/api/rest.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,14 @@ export async function getTaskResult(resultId: number): Promise<TaskResult> {
226226
return (await apiClient.get(`analytics/${resultId}`)).data;
227227
}
228228

229+
export async function subscribeToTaskResult(resultId: number): Promise<TaskResult> {
230+
return (await apiClient.post(
231+
`analytics/${resultId}/subscribe/`,
232+
undefined,
233+
{errorMsg: 'Failed to subscribe to task result. Task may already be completed.'}
234+
)).data;
235+
}
236+
229237
export async function getVectorDataBounds(vectorId: number): Promise<number[]> {
230238
return (await apiClient.get(`vectors/${vectorId}/bounds/`)).data;
231239
}

web/src/components/sidebars/AnalyticsPanel.vue

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
<script setup lang="ts">
22
import { ref, watch, computed } from "vue";
33
import {
4-
getTaskResults,
54
runAnalysis,
65
getDataset,
76
getChart,
87
getTaskResult,
98
getNetwork,
9+
subscribeToTaskResult,
1010
} from "@/api/rest";
1111
import NodeAnimation from "./NodeAnimation.vue";
1212
import SliderNumericInput from "../SliderNumericInput.vue";
@@ -17,13 +17,15 @@ import {
1717
usePanelStore,
1818
useAnalysisStore,
1919
useProjectStore,
20+
useAppStore,
2021
} from "@/store";
2122
2223
const panelStore = usePanelStore();
2324
const analysisStore = useAnalysisStore();
2425
const projectStore = useProjectStore();
2526
const networkStore = useNetworkStore();
2627
const layerStore = useLayerStore();
28+
const appStore = useAppStore();
2729
2830
const searchText = ref<string | undefined>();
2931
const filteredAnalysisTypes = computed(() => {
@@ -187,6 +189,11 @@ async function fillInputsAndOutputs() {
187189
}
188190
}
189191
192+
async function subscribe() {
193+
if (analysisStore.currentResult){
194+
analysisStore.currentResult.subscribers = (await subscribeToTaskResult(analysisStore.currentResult.id)).subscribers;
195+
}
196+
}
190197
191198
watch(() => analysisStore.currentAnalysisType, () => {
192199
fetchResults()
@@ -344,6 +351,26 @@ watch(
344351
</tbody>
345352
</v-table>
346353
</div>
354+
<div v-else>
355+
<div
356+
v-if="appStore.currentUser && analysisStore.currentResult?.subscribers.includes(appStore.currentUser.id)"
357+
style="text-align: center;"
358+
>
359+
<v-icon icon="mdi-check" color="success" />
360+
Subscribed
361+
<v-icon
362+
icon="mdi-information-outline"
363+
v-tooltip="'An email will be sent to you when the task is completed.'"
364+
/>
365+
</div>
366+
<v-btn
367+
v-else
368+
@click="subscribe"
369+
v-tooltip="'If subscribed, an email will be sent to you when the task is completed.'"
370+
>
371+
Notify Me Once Completed
372+
</v-btn>
373+
</div>
347374
</v-expansion-panel-text>
348375
</v-expansion-panel>
349376
</v-expansion-panels>

web/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@ export interface TaskResult {
421421
error: string;
422422
created: string;
423423
completed: string;
424+
subscribers: number[];
424425
}
425426

426427
export interface FloatingPanelConfig {

0 commit comments

Comments
 (0)