Skip to content

Commit f4056a8

Browse files
authored
Async exporting of applications (#4471)
This PR makes the generation of submission exports async as (especially in production) the generation of these files can take a long time and cause a request timeout. This allows the user to do other tasks while the file generates, and will notify them when the file is ready for download via a new task on their dashboard or a green indicator on the submission export button in the "All Submissions" view. **Changes TLDR**: - Added tasks to `hypha.apply.funds` module to handle the generation of the submissions CSV - Added a `SubmissionExportManager` model that keeps track of the status of the generation & the finalized data - Each user can only have one active model/download - Added a check in the nprogress event to allow for the disabling of the progress bar while polling - Preserved all sync functionality for users that don't need the background worker, which is the default behavior This is a WIP that should be completed early next week, but wanted to get eyes on it early for any critiques. Steps left: - [x] Add unit tests - [x] Make a management command to remove `SubmissionExportManager`s older than a day - [x] Error handling for a failed generation - [x] Triggering updating of `My Tasks` when downloading from dashboard **Running locally** 1. Get a Redis instance setup locally (I used docker, ie. `docker run -d -p 6379:6379 redis`) 2. Install requirements 3. In a separate terminal from the running `make serve` (but with same environment vars), run `celery -A hypha.celery worker -E --loglevel=info` to start celery ## Demo *Generating and downloading the exported CSV* https://github.com/user-attachments/assets/71758bf1-2a0c-461f-9a4a-6942bf77b694 *Failed generation of a CSV* https://github.com/user-attachments/assets/bb3029b2-0eb2-471a-930e-d8c85ff4ed6f
1 parent 270bf23 commit f4056a8

16 files changed

Lines changed: 643 additions & 20 deletions

File tree

Procfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
release: python manage.py migrate --noinput && python manage.py clear_cache --cache=default && python manage.py sync_roles
22
web: gunicorn hypha.wsgi:application --log-file -
3-
worker: celery --app=hypha.celery worker --autoscale=6,2 --events
3+
worker: celery --app=hypha.celery worker --autoscale=6,2 --events

hypha/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .celery import app as celery_app
2+
3+
__all__ = ("celery_app",)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import argparse
2+
from datetime import timedelta
3+
4+
from django.core.management.base import BaseCommand
5+
from django.db import transaction
6+
from django.utils import timezone
7+
8+
from hypha.apply.funds.models.utils import SubmissionExportManager
9+
10+
11+
def check_not_negative(value) -> int:
12+
"""Used to validate `older_than_days` argument
13+
14+
Args:
15+
value: Argument to be validated
16+
17+
Returns:
18+
int: Valid non-negative value
19+
20+
Raises:
21+
argparse.ArgumentTypeError: if not non-negative integer
22+
"""
23+
try:
24+
ivalue = int(value)
25+
except ValueError:
26+
ivalue = -1
27+
28+
if ivalue < 0:
29+
raise argparse.ArgumentTypeError(
30+
f'"{value}" is an invalid non-negative integer value'
31+
)
32+
return ivalue
33+
34+
35+
class Command(BaseCommand):
36+
help = "Delete all generated exports older than the specified time (in days)"
37+
38+
def add_arguments(self, parser):
39+
parser.add_argument(
40+
"older_than_days",
41+
action="store",
42+
type=check_not_negative,
43+
help="Time in days to delete exports older than",
44+
)
45+
parser.add_argument(
46+
"--noinput",
47+
"--no-input",
48+
action="store_false",
49+
dest="interactive",
50+
help="Do not prompt the user for confirmation",
51+
required=False,
52+
)
53+
54+
@transaction.atomic
55+
def handle(self, *args, **options):
56+
interactive = options["interactive"]
57+
older_than = options["older_than_days"]
58+
59+
older_than_date = timezone.now() - timedelta(days=older_than)
60+
61+
old_exports = SubmissionExportManager.objects.filter(
62+
created_time__lte=older_than_date
63+
)
64+
65+
export_count = old_exports.count()
66+
67+
if not export_count:
68+
self.stdout.write(
69+
f"No exports older than {older_than} day{'s' if older_than > 1 else ''} exist."
70+
)
71+
return
72+
73+
if interactive:
74+
confirm = input(
75+
f"This action will permanently delete {export_count} generated export{'s' if export_count != 1 else ''}.\nAre you sure you want to do this?\n\nType 'yes' to continue, or 'no' to cancel: "
76+
)
77+
else:
78+
confirm = "yes"
79+
80+
if confirm == "yes":
81+
old_exports.delete()
82+
self.stdout.write(
83+
f"{export_count} generated export{'s' if export_count != 1 else ''} deleted."
84+
)
85+
else:
86+
self.stdout.write("Deletion cancelled.")
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Generated by Django 4.2.20 on 2025-03-24 21:29
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
11+
("funds", "0123_help_text_rich_text"),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name="SubmissionExportManager",
17+
fields=[
18+
(
19+
"id",
20+
models.AutoField(
21+
auto_created=True,
22+
primary_key=True,
23+
serialize=False,
24+
verbose_name="ID",
25+
),
26+
),
27+
("export_data", models.TextField()),
28+
("created_time", models.DateTimeField(auto_now_add=True)),
29+
("completed_time", models.DateTimeField(null=True)),
30+
(
31+
"status",
32+
models.CharField(
33+
choices=[
34+
("error", "Failed"),
35+
("success", "Success"),
36+
("generating", "In Progress"),
37+
],
38+
default="generating",
39+
),
40+
),
41+
("total_export", models.IntegerField(null=True)),
42+
(
43+
"user",
44+
models.ForeignKey(
45+
limit_choices_to={"groups__name": "Staff", "is_active": True},
46+
on_delete=django.db.models.deletion.CASCADE,
47+
to=settings.AUTH_USER_MODEL,
48+
),
49+
),
50+
],
51+
),
52+
]

hypha/apply/funds/models/utils.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.db import models
33
from django.shortcuts import redirect
44
from django.urls import reverse
5+
from django.utils import timezone
56
from django.utils.translation import gettext_lazy as _
67
from wagtail.admin.panels import (
78
FieldPanel,
@@ -200,3 +201,52 @@ def send_mail(self, submission):
200201
]
201202

202203
email_tab = ObjectList(email_confirmation_panels, heading=_("Confirmation email"))
204+
205+
206+
# Managing async submission exports
207+
208+
STATUS_ERROR = "error"
209+
STATUS_SUCCESS = "success"
210+
STATUS_GENERATING = "generating"
211+
212+
STATUS_CHOICES = [
213+
(STATUS_ERROR, _("Failed")),
214+
(STATUS_SUCCESS, _("Success")),
215+
(STATUS_GENERATING, _("In Progress")),
216+
]
217+
218+
219+
class SubmissionExportManager(models.Model):
220+
user = models.ForeignKey(
221+
settings.AUTH_USER_MODEL,
222+
limit_choices_to=LIMIT_TO_STAFF,
223+
on_delete=models.CASCADE,
224+
)
225+
226+
export_data = models.TextField()
227+
228+
created_time = models.DateTimeField(auto_now_add=True)
229+
230+
completed_time = models.DateTimeField(null=True)
231+
232+
status = models.CharField(choices=STATUS_CHOICES, default=STATUS_GENERATING)
233+
234+
total_export = models.IntegerField(null=True)
235+
236+
def set_completed_and_save(self) -> None:
237+
"""Sets the status to completed and saves the object"""
238+
self.status = "success"
239+
self.completed_time = timezone.now()
240+
self.save()
241+
242+
def set_failed_and_save(self) -> None:
243+
"""Sets the status to error and saves the object"""
244+
self.status = "error"
245+
self.save()
246+
247+
def get_absolute_url(self) -> str:
248+
"""Returns the submissions all page where the user can download the file
249+
250+
Primarily used for tasks
251+
"""
252+
return reverse("apply:submissions:list")

hypha/apply/funds/tasks.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from typing import List
2+
3+
from celery import shared_task
4+
from django.conf import settings
5+
6+
from hypha.apply.funds.models.submissions import ApplicationSubmission
7+
from hypha.apply.funds.models.utils import SubmissionExportManager
8+
from hypha.apply.funds.utils import export_submissions_to_csv
9+
from hypha.apply.todo.options import (
10+
DOWNLOAD_SUBMISSIONS_EXPORT,
11+
FAILED_SUBMISSIONS_EXPORT,
12+
)
13+
from hypha.apply.todo.views import add_task_to_user
14+
from hypha.apply.users.models import User
15+
16+
17+
@shared_task
18+
def generate_submission_csv(
19+
qs_ids: List[int], request_user_id: int, base_uri: str
20+
) -> None:
21+
"""Celery task to generate a CSV file containing the given submission IDs
22+
23+
Integer IDs have to be used as QuerySets are not simple data types & can't be
24+
passed to workers.
25+
26+
Updates the user's SubmissionExportManager object with status/final data, then
27+
adds a download task to the user's `My Tasks` when completed.
28+
29+
Args:
30+
qs_ids: A list of application IDs to generate the CSV export for
31+
request_user_id: The ID of the user issuing the export request
32+
"""
33+
try:
34+
qs = ApplicationSubmission.objects.filter(id__in=qs_ids)
35+
request_user = User.objects.get(pk=request_user_id)
36+
37+
# If the user already has an existing export, delete it to begin the new one
38+
if current := SubmissionExportManager.objects.filter(user=request_user):
39+
current.delete()
40+
41+
export_manager = SubmissionExportManager.objects.create(
42+
user=request_user, total_export=len(qs_ids)
43+
)
44+
csv_string = export_submissions_to_csv(qs, base_uri)
45+
export_manager.export_data = "".join(csv_string.readlines())
46+
export_manager.set_completed_and_save()
47+
48+
user_task = DOWNLOAD_SUBMISSIONS_EXPORT
49+
50+
except Exception as exc:
51+
# Update the status to failed
52+
export_manager.set_failed_and_save()
53+
user_task = FAILED_SUBMISSIONS_EXPORT
54+
55+
if settings.SENTRY_DSN:
56+
# If sentry is enabled, pass the exception to sentry
57+
from sentry_sdk import capture_exception
58+
59+
capture_exception(exc)
60+
else:
61+
# Otherwise re-raise it
62+
raise exc
63+
finally:
64+
# When the generation is complete or failed, add a task to the user's dashboard (only if async)
65+
if not settings.CELERY_TASK_ALWAYS_EAGER:
66+
add_task_to_user(
67+
code=user_task,
68+
user=request_user,
69+
related_obj=export_manager,
70+
)

hypha/apply/funds/templates/submissions/all.html

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,15 +174,16 @@
174174
{% endif %}
175175

176176
{% if can_export_submissions %}
177-
<a
177+
<button
178178
class="py-1.5 px-2 rounded-sm border transition-colors hover:bg-gray-100 shadow-xs"
179-
aria-label="{% trans 'Submissions: Download as CSV' %}"
180-
href="{% modify_query "page" format="csv" %}"
181-
onclick="return confirm('{% blocktrans %}Are you sure you want to download the submissions as a csv file? This file may contain sensitive information, so please handle it carefully.{% endblocktrans %}');"
182-
data-tippy-content="{% trans 'Export as CSV' %}"
179+
hx-get="{% url 'apply:submissions:submission-export-status' %}"
180+
hx-swap="outerHTML"
181+
hx-target="this"
182+
hx-push-url="false"
183+
hx-trigger="load"
183184
>
184185
{% heroicon_mini "arrow-down-tray" %}
185-
</a>
186+
</button>
186187
{% endif %}
187188
</form>
188189

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
{% load i18n querystrings heroicons %}
2+
3+
4+
{% if not_async %}
5+
{% comment %} For sync uses: no polling, just a download after pressing the button {% endcomment %}
6+
<a
7+
class="relative py-1.5 px-2 rounded-sm border transition-colors hover:bg-gray-100 shadow-xs"
8+
aria-label="{% trans 'Submissions: Export as CSV' %}"
9+
href="{{ start_export_url }}"
10+
data-tippy-content="{% trans 'Export as CSV' %}"
11+
onclick="return confirm('{% blocktrans %}Are you sure you want to export the submissions as a CSV file? This file may contain sensitive information, so please handle it carefully.{% endblocktrans %}')"
12+
>
13+
{% heroicon_mini "arrow-down-tray" %}
14+
</a>
15+
{% else %}
16+
{% if generating %}
17+
{% comment %} Disabled button used to indicate generation of the CSV is in progress {% endcomment %}
18+
<button
19+
class="py-1.5 px-2 rounded-sm border transition-colors animate-pulse shadow-xs"
20+
aria-label="{% trans 'Submissions: Generating downloadable CSV' %}"
21+
title="{% trans 'Generating downloadable CSV...' %}"
22+
disabled
23+
hx-get="{% url 'apply:submissions:submission-export-status' %}"
24+
hx-swap="outerHTML"
25+
hx-target="this"
26+
hx-trigger="every {{ poll_time }}s"
27+
hx-push-url="false"
28+
hx-noprog
29+
>
30+
{% heroicon_mini "clock" %}
31+
</button>
32+
{% elif success %}
33+
{% comment %} The final download link for the generated CSV {% endcomment %}
34+
<a
35+
class="relative py-1.5 px-2 rounded-sm border transition-colors hover:bg-gray-100 shadow-xs"
36+
aria-label="{% trans 'Submissions: Download generated CSV' %}"
37+
href="{% url 'apply:submissions:submission-export-download' %}"
38+
data-tippy-content="{% trans 'Download generated CSV' %}"
39+
hx-get="{% url 'apply:submissions:submission-export-status' %}"
40+
hx-swap="outerHTML"
41+
hx-target="this"
42+
hx-trigger="every 2s"
43+
hx-push-url="false"
44+
hx-noprog
45+
>
46+
<span class="flex absolute top-0 right-0 -mt-1 -mr-1 size-3">
47+
<span class="inline-flex absolute w-full h-full bg-green-400 rounded-full opacity-75 animate-ping"></span>
48+
<span class="inline-flex relative bg-green-500 rounded-full size-3"></span>
49+
</span>
50+
{% heroicon_mini "arrow-down-tray" %}
51+
</a>
52+
53+
{% else %}
54+
{% comment %} Button that will begin the generation of the CSV, used to start a generation or retry a failed one {% endcomment %}
55+
<button
56+
class="relative py-1.5 px-2 rounded-sm border transition-colors cursor-pointer hover:bg-gray-100 shadow-xs"
57+
aria-label="{% trans 'Submissions: Generate downloadable CSV' %}"
58+
{% if failed %}
59+
data-tippy-content="{% trans 'Generation failed, click to retry generating downloadable CSV' %}"
60+
{% else %}
61+
data-tippy-content="{% trans 'Generate downloadable CSV' %}"
62+
{% endif %}
63+
hx-get="{{ start_export_url }}"
64+
hx-swap="outerHTML"
65+
hx-target="this"
66+
hx-push-url="false"
67+
hx-confirm="{% trans 'Are you sure you want to export the submissions as a CSV file? This file may contain sensitive information, so please handle it carefully.' %}"
68+
>
69+
{% if not failed %}
70+
{% heroicon_mini "arrow-down-tray" %}
71+
{% else %}
72+
<span class="flex absolute top-0 right-0 -mt-1 -mr-1 size-3">
73+
<span class="inline-flex absolute w-full h-full bg-red-400 rounded-full opacity-75 animate-ping"></span>
74+
<span class="inline-flex relative bg-red-500 rounded-full size-3"></span>
75+
</span>
76+
{% heroicon_mini "exclamation-circle" %}
77+
{% endif %}
78+
</button>
79+
{% endif %}
80+
{% endif %}

0 commit comments

Comments
 (0)