Skip to content

Commit 11e1108

Browse files
authored
Merge pull request #1427 from opengisch/QF-6843-make-attachments-not-versionable
feat: make attachments of a project optionally versionable
2 parents ad21619 + 90d9591 commit 11e1108

File tree

6 files changed

+140
-0
lines changed

6 files changed

+140
-0
lines changed

docker-app/qfieldcloud/core/admin.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from django.contrib.auth.models import Group
2828
from django.contrib.auth.views import redirect_to_login
2929
from django.core.exceptions import PermissionDenied, ValidationError
30+
from django.core.files.storage import storages
3031
from django.db.models import Q, QuerySet
3132
from django.db.models.fields.json import JSONField
3233
from django.db.models.functions import Lower
@@ -68,6 +69,7 @@
6869
from qfieldcloud.core.templatetags.filters import filesizeformat10
6970
from qfieldcloud.core.utils import get_file_storage_choices
7071
from qfieldcloud.core.utils2 import delta_utils, jobs, pg_service_file
72+
from qfieldcloud.filestorage.backend import QfcS3Boto3Storage
7173
from qfieldcloud.filestorage.models import File
7274

7375

@@ -855,6 +857,26 @@ def __init__(self, *args, **kwargs):
855857
)
856858
if self.instance.has_attachments_files:
857859
self.fields["attachments_file_storage"].disabled = True
860+
self.fields["are_attachments_versioned"].disabled = True
861+
862+
def clean_are_attachments_versioned(self):
863+
value = self.cleaned_data["are_attachments_versioned"]
864+
865+
if value:
866+
return value
867+
868+
# attachments can not be unversioned if attachments are stored on S3.
869+
attachment_storage_value = self.cleaned_data["attachments_file_storage"]
870+
attachment_storage = storages[attachment_storage_value]
871+
872+
if isinstance(attachment_storage, QfcS3Boto3Storage):
873+
raise ValidationError(
874+
_(
875+
"The '{}' attachments file storage is not compatible with unversioned attachment files."
876+
).format(attachment_storage_value)
877+
)
878+
879+
return value
858880

859881

860882
class ProjectAdmin(QFieldCloudModelAdmin):
@@ -899,6 +921,7 @@ class ProjectAdmin(QFieldCloudModelAdmin):
899921
"file_storage",
900922
"file_storage_migrated_at",
901923
"attachments_file_storage",
924+
"are_attachments_versioned",
902925
"is_attachment_download_on_demand",
903926
"project_files",
904927
)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 4.2.25 on 2025-11-13 16:09
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("core", "0092_project_restricted_data_last_updated_at"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="project",
14+
name="are_attachments_versioned",
15+
field=models.BooleanField(
16+
default=True,
17+
help_text="If enabled, attachment files will make use of the file versioning system. If disabled, only the latest version of each attachment file will be kept, and stored with the extension in the filename.",
18+
verbose_name="Versioned attachment files",
19+
),
20+
),
21+
]

docker-app/qfieldcloud/core/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,6 +1262,14 @@ class Meta:
12621262
),
12631263
)
12641264

1265+
are_attachments_versioned = models.BooleanField(
1266+
default=True,
1267+
verbose_name=_("Versioned attachment files"),
1268+
help_text=_(
1269+
"If enabled, attachment files will make use of the file versioning system. If disabled, only the latest version of each attachment file will be kept, and stored with the extension in the filename."
1270+
),
1271+
)
1272+
12651273
restricted_data_last_updated_at = models.DateTimeField(
12661274
_("Restricted data last updated at"),
12671275
blank=True,

docker-app/qfieldcloud/filestorage/backend.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,21 @@ def patch_nginx_download_redirect(self, response: HttpResponse) -> None:
259259
b64_auth = base64.b64encode(self.basic_auth.encode()).decode()
260260
basic_auth = f"Basic {b64_auth}"
261261
response["webdav_auth"] = basic_auth
262+
263+
def is_name_available(self, name, max_length=None):
264+
# TODO: Delete with QF-7176 and Django >= 5.1 upgrade.
265+
# see https://github.com/django/django/blob/6f35c2e1fd71ff8f349598a59689514e213490e7/django/core/files/storage/base.py#L54
266+
exceeds_max_length = max_length and len(name) > max_length
267+
return not self.exists(name) and not exceeds_max_length
268+
269+
def get_available_name(self, name: str, max_length: int | None = None) -> str:
270+
"""Returns a filename that is available on the configured webdav storage.
271+
272+
Arguments:
273+
name: desired relative path of the file on the webdav server.
274+
max_length: maximum length of the filename (not used)."""
275+
276+
if self.is_name_available(name, max_length):
277+
return super().get_available_name(name, max_length)
278+
279+
return name

docker-app/qfieldcloud/filestorage/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,12 +261,21 @@ def add_version(
261261

262262
def get_file_version_upload_to(instance: "FileVersion", _filename: str) -> str:
263263
if instance.file.file_type == File.FileType.PROJECT_FILE:
264+
# if the project is configured to not version attachments, store them without version id.
265+
if (
266+
instance.file.is_attachment()
267+
and not instance.file.project.are_attachments_versioned
268+
):
269+
return f"projects/{instance.file.project.id}/files/{instance.file.name}"
270+
264271
return f"projects/{instance.file.project.id}/files/{instance.file.name}/{instance.display}-{str(instance.id)[0:8]}"
272+
265273
elif instance.file.file_type == File.FileType.PACKAGE_FILE:
266274
# TODO decide whether we need to add the version id in there?
267275
# Currently we don't add it, since there is no situation to have multiple versions of the same file in a packaged file.
268276
# On the other hand, having this differing from regular files will make it harder to manage.
269277
return f"projects/{instance.file.project.id}/packages/{instance.file.package_job_id}/{instance.file.name}"
278+
270279
else:
271280
raise NotImplementedError()
272281

docker-app/qfieldcloud/filestorage/tests/test_attachments.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,64 @@ def test_attachments_packages_on_default_storage_succeeds(self):
191191

192192
for package_file in package_files_qs:
193193
self.assertFileOnStorage(package_file, "default")
194+
195+
def test_not_versioned_attachment_upload_succeeds(self):
196+
p = Project.objects.create(
197+
owner=self.u1,
198+
name="p2",
199+
file_storage="default",
200+
attachments_file_storage="webdav",
201+
are_attachments_versioned=False,
202+
)
203+
204+
# Upload a first version of the file.
205+
response = self._upload_file(
206+
self.u1,
207+
p,
208+
"DCIM/file.name",
209+
io.FileIO(testdata_path("DCIM/1.jpg"), "rb"),
210+
)
211+
212+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
213+
self.assertEqual(p.project_files.count(), 1)
214+
215+
file = p.get_file("DCIM/file.name")
216+
217+
self.assertFileOnStorage(file, "webdav")
218+
219+
version = file.latest_version
220+
221+
self.assertIsNotNone(version)
222+
223+
first_content_name = version.content.name
224+
225+
self.assertTrue(first_content_name.endswith("DCIM/file.name"))
226+
227+
# Upload a second version of the same file (with different content).
228+
response = self._upload_file(
229+
self.u1,
230+
p,
231+
"DCIM/file.name",
232+
io.FileIO(testdata_path("DCIM/2.jpg"), "rb"),
233+
)
234+
235+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
236+
self.assertEqual(p.project_files.count(), 1)
237+
238+
file = p.get_file("DCIM/file.name")
239+
240+
self.assertFileOnStorage(file, "webdav")
241+
self.assertEqual(file.versions.count(), 2)
242+
243+
second_content_name = file.latest_version.content.name
244+
245+
self.assertTrue(second_content_name.endswith("DCIM/file.name"))
246+
self.assertTrue(second_content_name.endswith(".name"))
247+
248+
response = self._delete_file(self.u1, p, "DCIM/file.name")
249+
250+
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
251+
self.assertEqual(p.project_files.count(), 0)
252+
253+
self.assertFalse(version.content.storage.exists(first_content_name))
254+
self.assertFalse(version.content.storage.exists(second_content_name))

0 commit comments

Comments
 (0)