Skip to content

Commit 53f8c41

Browse files
committed
Initial implementation for deleting application attachements
1 parent 4c3bdf5 commit 53f8c41

8 files changed

Lines changed: 165 additions & 5 deletions

File tree

hypha/apply/activity/adapters/emails.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,6 @@ def handle_transition(self, new_phase, source, old_phase=None, **kwargs):
175175
old_index = list(dict(PHASES).keys()).index(old_phase.name)
176176
target_index = list(dict(PHASES).keys()).index(submission.status)
177177
is_forward = old_index < target_index
178-
print("NEW PHASE")
179-
print(new_phase.public_name)
180178

181179
kwargs["old_phase"] = old_phase.public_name
182180
kwargs["new_phase"] = new_phase.public_name

hypha/apply/funds/apps.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
from django.apps import AppConfig
2+
from django.core.signals import request_finished
23

34

45
class ApplyConfig(AppConfig):
56
name = "hypha.apply.funds"
7+
8+
def ready(self):
9+
# Connect the attachment deletion handler
10+
from . import signals
11+
12+
request_finished.connect(signals.delete_attachments)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Generated by Django 4.2.24 on 2025-10-01 15:39
2+
3+
from django.db import migrations
4+
from django.core.files.storage import default_storage
5+
import os
6+
7+
8+
def delete_directory(directory_path):
9+
"""Delete a full directory (empty or not)"""
10+
11+
directories, files = default_storage.listdir(directory_path)
12+
13+
for item in directories:
14+
item_path = os.path.join(directory_path, item)
15+
if default_storage.exists(item_path):
16+
# Recursively delete subdirectories
17+
delete_directory(item_path)
18+
19+
for item in files:
20+
item_path = os.path.join(directory_path, item)
21+
if default_storage.exists(item_path):
22+
# Delete files
23+
default_storage.delete(item_path)
24+
25+
if default_storage.exists(directory_path):
26+
# Delete the empty directory
27+
default_storage.delete(directory_path)
28+
29+
30+
def delete_orphaned_attachments(apps, schema_editor):
31+
"""Remove all attachments not associated with an application"""
32+
33+
ApplicationSubmission = apps.get_model("funds", "ApplicationSubmission")
34+
35+
submission_attachment_path = f"{default_storage.base_location}/submission"
36+
37+
folders_to_delete = []
38+
folders_to_check = []
39+
40+
for folder in default_storage.listdir(submission_attachment_path)[
41+
0
42+
]: # `listdir` returns ([folders], [files])
43+
try:
44+
folders_to_check.append(int(folder))
45+
except ValueError:
46+
# Folder name is not an int, therefore not a submission ID and can be deleted (an edge case)
47+
folders_to_delete.append(folder)
48+
49+
# Get a list of all undeleted submissions that have a folder
50+
valid_ids = set(
51+
ApplicationSubmission.objects.filter(id__in=folders_to_check).values_list(
52+
"id", flat=True
53+
)
54+
)
55+
56+
# Find the set difference and delete those folders
57+
folders_to_delete += list(set(folders_to_check) - valid_ids)
58+
59+
for folder in folders_to_delete:
60+
# try:
61+
delete_directory(f"{submission_attachment_path}/{folder}")
62+
# except FileNotFoundError:
63+
# # Will get thrown when unit tests attempt to run migrations
64+
# pass
65+
66+
67+
class Migration(migrations.Migration):
68+
dependencies = [
69+
("funds", "0130_alter_applicationsubmission_status"),
70+
]
71+
72+
operations = [migrations.RunPython(delete_orphaned_attachments)]

hypha/apply/funds/models/mixins.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,6 @@ def extract_files(self):
161161
for field in self.form_fields:
162162
if isinstance(field.block, UploadableMediaBlock):
163163
files[field.id] = self.data(field.id) or []
164-
self.form_data.pop(field.id, None)
165164
return files
166165

167166
@classmethod

hypha/apply/funds/signals.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from django.db.models.signals import pre_delete
2+
from django.dispatch import receiver
3+
4+
from hypha.apply.funds.models.application_revisions import ApplicationRevision
5+
from hypha.apply.funds.models.mixins import AccessFormData
6+
from hypha.apply.funds.models.submissions import ApplicationSubmission
7+
8+
9+
@receiver(signal=pre_delete, sender=ApplicationSubmission)
10+
@receiver(signal=pre_delete, sender=ApplicationRevision)
11+
def delete_attachments(sender, instance=None, **kwargs):
12+
"""
13+
Before the deletion of any sub class of AccessFormData, ensure the files associated with it are deleted too.
14+
15+
This can include things like ApplicationSubmission & ApplicationRevision objects
16+
"""
17+
# This will be called anytime a deletion is ran, so ensure the object being deleted
18+
# can have attachments
19+
if (
20+
sender
21+
and issubclass(sender, AccessFormData)
22+
and isinstance(instance, AccessFormData)
23+
):
24+
files = instance.extract_files()
25+
for value in files.values():
26+
if not isinstance(value, list): # Single file field
27+
value.delete()
28+
else: # Multiple file fields
29+
[sub_file.delete() for sub_file in value]

hypha/apply/funds/views/submission_edit.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ def get_placeholder_file(self, initial_file):
253253
return [
254254
PlaceholderUploadedFile(f.filename, size=f.size, file_id=f.name)
255255
for f in initial_file
256+
if f
256257
]
257258

258259
def save_draft_and_refresh_page(self, form) -> HttpResponseRedirect:

hypha/apply/stream_forms/files.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
class StreamFieldDataEncoder(DjangoJSONEncoder):
99
def default(self, o):
10-
if isinstance(o, StreamFieldFile):
10+
if isinstance(o, File):
1111
return {
1212
"name": o.name,
1313
"filename": o.filename,

hypha/apply/stream_forms/testing/factories.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import json
2+
import random
23
import uuid
34
from collections import defaultdict
45

56
import factory
67
import wagtail_factories
8+
from django.core.files.base import File
79
from django.core.files.uploadedfile import SimpleUploadedFile
810
from django.core.serializers.json import DjangoJSONEncoder
911
from wagtail.blocks import RichTextBlock, StructValue
@@ -73,6 +75,18 @@ def _create(cls, *args, form_fields=None, for_factory=None, clean=False, **kwarg
7375
or for_factory.Meta.model.form_fields.field.to_python(form_fields)
7476
}
7577

78+
# Get UUIDs of the file fields to add "-uploads" fields later
79+
file_fields = []
80+
file_types = ("image", "file", "multi_file")
81+
for field in form_fields:
82+
try:
83+
if field["type"] in file_types:
84+
file_fields.append(field["id"])
85+
except TypeError:
86+
if field.block_type in file_types:
87+
file_fields.append(field.id)
88+
# field["id"] for field in form_fields if field["type"] in ("file", "multi_file")]
89+
7690
form_data = {}
7791
for name, answer in kwargs.items():
7892
try:
@@ -92,6 +106,25 @@ def _create(cls, *args, form_fields=None, for_factory=None, clean=False, **kwarg
92106
clean_object.delete()
93107
return form_data
94108

109+
for id in file_fields:
110+
uploads = []
111+
if entry := form_data.get(id):
112+
if not isinstance(entry, list):
113+
entry = [entry]
114+
115+
for file in entry:
116+
uploads.append(
117+
{
118+
"id": str(uuid.uuid4()),
119+
"name": file._name,
120+
"size": random.randint(20, 100000),
121+
"type": "tus",
122+
"url": "",
123+
}
124+
)
125+
126+
form_data[f"{id}-uploads"] = json.dumps(uploads)
127+
95128
return form_data
96129

97130
@classmethod
@@ -242,6 +275,17 @@ def make_answer(cls, params=None):
242275
return cls.choices[0]
243276

244277

278+
class UploadedFile(SimpleUploadedFile):
279+
"""Utilized to make functionality closer to that of `StreamFieldFile`
280+
281+
Requires a `filename` attribute which is pulled from the existing `_name`
282+
"""
283+
284+
def __init__(self, name, content, content_type=...):
285+
super().__init__(name, content, content_type)
286+
self.filename = self._name
287+
288+
245289
class UploadableMediaFactory(FormFieldBlockFactory):
246290
default_value = factory.django.FileField()
247291

@@ -252,7 +296,7 @@ def make_answer(cls, params=None):
252296
if params.get("filename") is None:
253297
params["filename"] = "test_example.pdf"
254298
file_name, file = cls.default_value._make_content(params)
255-
return SimpleUploadedFile(file_name, file.read())
299+
return UploadedFile(file_name, file.read())
256300

257301

258302
class ImageFieldBlockFactory(UploadableMediaFactory):
@@ -276,6 +320,16 @@ def make_answer(cls, params=None):
276320
return [UploadableMediaFactory.make_answer() for _ in range(2)]
277321

278322

323+
class StreamFieldDataEncoder(DjangoJSONEncoder):
324+
def default(self, o):
325+
if isinstance(o, File):
326+
return {
327+
"name": o.name,
328+
"filename": o.filename,
329+
}
330+
return super().default(o)
331+
332+
279333
class StreamFieldUUIDFactory(wagtail_factories.StreamFieldFactory):
280334
def evaluate(self, instance, step, extra):
281335
params = self.build_form(extra)

0 commit comments

Comments
 (0)