mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-29 22:19:08 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03d9395dfe | |||
| 226d004838 |
+1
-2
@@ -22,11 +22,10 @@ and this project adheres to
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
|
||||
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
|
||||
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
|
||||
- ♻️(backend) delete files synchronously
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) remove the temporary upload object when a file is deleted
|
||||
- 🐛(backend) report the app release to Sentry instead of "NA"
|
||||
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
|
||||
- 🔒️(backend) enforce display name setting on rename API
|
||||
- 🔒️(backend) reject inactive users in resource server backend
|
||||
|
||||
@@ -3,25 +3,48 @@
|
||||
from django import forms
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.db import transaction
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.recording.event import notification
|
||||
|
||||
from . import models
|
||||
from .tasks.file import process_file_deletion
|
||||
from .utils import generate_download_s3_url
|
||||
|
||||
|
||||
def hard_delete_file(file):
|
||||
"""Hard delete a file, soft deleting it first when needed."""
|
||||
if file.deleted_at is None:
|
||||
file.soft_delete()
|
||||
file.hard_delete()
|
||||
transaction.on_commit(lambda: process_file_deletion.delay(file.id))
|
||||
|
||||
|
||||
class FileInlineFormSet(forms.BaseInlineFormSet):
|
||||
"""Inline formset overriding delete behavior for files."""
|
||||
|
||||
def delete_existing(self, obj, commit=True):
|
||||
"""Hard delete files instead of calling model.delete()."""
|
||||
hard_delete_file(obj)
|
||||
|
||||
|
||||
class FileInline(admin.TabularInline):
|
||||
"""Inline class for the File model."""
|
||||
|
||||
model = models.File
|
||||
formset = FileInlineFormSet
|
||||
fk_name = "creator"
|
||||
extra = 0
|
||||
fields = ("id", "title", "type", "upload_state", "created_at")
|
||||
readonly_fields = ("id", "created_at", "upload_state", "type")
|
||||
show_change_link = True
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Hide hard deleted files in the inline."""
|
||||
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
|
||||
|
||||
|
||||
@admin.register(models.User)
|
||||
class UserAdmin(auth_admin.UserAdmin):
|
||||
@@ -123,6 +146,7 @@ class FileAdmin(admin.ModelAdmin):
|
||||
"creator",
|
||||
"upload_state",
|
||||
"deleted_at",
|
||||
"hard_deleted_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
@@ -132,6 +156,7 @@ class FileAdmin(admin.ModelAdmin):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
"hard_deleted_at",
|
||||
)
|
||||
search_fields = (
|
||||
"id",
|
||||
@@ -149,6 +174,7 @@ class FileAdmin(admin.ModelAdmin):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
"hard_deleted_at",
|
||||
"description",
|
||||
"malware_detection_info",
|
||||
"is_ready",
|
||||
@@ -187,7 +213,15 @@ class FileAdmin(admin.ModelAdmin):
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Deletion"), {"fields": ("deleted_at",)}),
|
||||
(
|
||||
_("Deletion"),
|
||||
{
|
||||
"fields": (
|
||||
"deleted_at",
|
||||
"hard_deleted_at",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Derived info"),
|
||||
{
|
||||
@@ -214,10 +248,18 @@ class FileAdmin(admin.ModelAdmin):
|
||||
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Hide hard deleted files in admin listing and lookups."""
|
||||
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
|
||||
|
||||
def delete_model(self, request, obj):
|
||||
"""Hard delete instead of calling model.delete()."""
|
||||
hard_delete_file(obj)
|
||||
|
||||
def delete_queryset(self, request, queryset):
|
||||
"""Delete one by one so storage is cleaned up too."""
|
||||
"""Hard delete all selected files."""
|
||||
for file in queryset:
|
||||
file.delete()
|
||||
hard_delete_file(file)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
@@ -134,7 +134,7 @@ class FilePermission(IsAuthenticated):
|
||||
Return a 404 on deleted files or if the user is not the owner
|
||||
"""
|
||||
|
||||
if obj.is_deleted:
|
||||
if obj.deleted_at is not None or obj.hard_deleted_at is not None:
|
||||
raise Http404
|
||||
|
||||
if obj.creator != request.user:
|
||||
|
||||
@@ -456,6 +456,7 @@ class ListFileSerializer(serializers.ModelSerializer):
|
||||
"type",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"hard_deleted_at",
|
||||
"filename",
|
||||
"upload_state",
|
||||
"mimetype",
|
||||
@@ -470,6 +471,7 @@ class ListFileSerializer(serializers.ModelSerializer):
|
||||
"updated_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"hard_deleted_at",
|
||||
"filename",
|
||||
"upload_state",
|
||||
"mimetype",
|
||||
|
||||
@@ -82,6 +82,7 @@ from core.services.room_roles import (
|
||||
)
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.connection_test import delete_connection_test_room
|
||||
from core.tasks.file import process_file_deletion
|
||||
from core.utils import generate_token
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
@@ -1198,7 +1199,7 @@ class FileViewSet(
|
||||
permission_classes = [
|
||||
permissions.FilePermission,
|
||||
]
|
||||
queryset = models.File.objects.all()
|
||||
queryset = models.File.objects.filter(hard_deleted_at__isnull=True)
|
||||
default_serializer_class = serializers.FileSerializer
|
||||
serializer_classes = {
|
||||
"list": serializers.ListFileSerializer,
|
||||
@@ -1352,7 +1353,7 @@ class FileViewSet(
|
||||
)
|
||||
|
||||
if validation_error is not None:
|
||||
file.delete()
|
||||
self._complete_file_deletion(file)
|
||||
else:
|
||||
file.upload_state = models.FileUploadStateChoices.READY
|
||||
file.mimetype = mimetype
|
||||
@@ -1394,6 +1395,12 @@ class FileViewSet(
|
||||
|
||||
return drf_response.Response(serializer.data, status=drf_status.HTTP_200_OK)
|
||||
|
||||
def _complete_file_deletion(self, file):
|
||||
"""Delete a file completely."""
|
||||
file.soft_delete()
|
||||
file.hard_delete()
|
||||
transaction.on_commit(lambda: process_file_deletion.delay(file.id))
|
||||
|
||||
def _authorize_subrequest(self, request, pattern):
|
||||
"""
|
||||
Authorize access based on the original URL of an Nginx subrequest
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
|
||||
from core.models import File, FileUploadStateChoices
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -31,19 +32,16 @@ class Command(BaseCommand):
|
||||
files = File.objects.filter(
|
||||
upload_state=FileUploadStateChoices.PENDING,
|
||||
created_at__lt=threshold,
|
||||
hard_deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
count = 0
|
||||
failed = []
|
||||
for file in files.iterator():
|
||||
try:
|
||||
file.delete()
|
||||
count += 1
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
failed.append(file.pk)
|
||||
self.stderr.write(f"[ERROR] Failed to clean file '{file.pk}': {exc}")
|
||||
# This check shouldn't happen, but just in case we do it to avoid an error
|
||||
if not file.deleted_at:
|
||||
file.soft_delete()
|
||||
file.hard_delete()
|
||||
process_file_deletion(file.id)
|
||||
count += 1
|
||||
|
||||
self.stdout.write(f"Cleaned {count} stale pending file(s).")
|
||||
|
||||
if failed:
|
||||
raise CommandError(f"Failed to clean {len(failed)} file(s).")
|
||||
|
||||
@@ -3,33 +3,38 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from core.models import File
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Purge files (object storage and database object) whose trash bin retention has expired."""
|
||||
"""
|
||||
Purge deleted files (object storage and database object):
|
||||
- files marked as hard deleted in database
|
||||
- files marked as soft deleted and for which the trashbin retention period has expired
|
||||
"""
|
||||
|
||||
help = "Purge deleted files"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Delete files soft deleted for longer than the grace period."""
|
||||
"""Browse purgeable files and queue them through the file deletion task."""
|
||||
|
||||
threshold = timezone.now() - timedelta(days=settings.FILE_PURGE_GRACE_DAYS)
|
||||
is_hard_deleted = Q(hard_deleted_at__isnull=False)
|
||||
is_purgeable = Q(
|
||||
deleted_at__lte=timezone.now()
|
||||
- timedelta(days=settings.FILE_PURGE_GRACE_DAYS)
|
||||
)
|
||||
|
||||
count = 0
|
||||
failed = []
|
||||
for file in File.objects.filter(deleted_at__lte=threshold).iterator():
|
||||
try:
|
||||
file.delete()
|
||||
count += 1
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
failed.append(file.pk)
|
||||
self.stderr.write(f"[ERROR] Failed to purge file '{file.pk}': {exc}")
|
||||
for file in File.objects.filter(is_hard_deleted | is_purgeable).iterator():
|
||||
if file.hard_deleted_at is None:
|
||||
file.hard_delete()
|
||||
|
||||
process_file_deletion.delay(file.id)
|
||||
count += 1
|
||||
|
||||
self.stdout.write(f"Purged {count} deleted file(s).")
|
||||
|
||||
if failed:
|
||||
raise CommandError(f"Failed to purge {len(failed)} file(s).")
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0022_user_default_room_access_level_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="file",
|
||||
name="hard_deleted_at",
|
||||
),
|
||||
]
|
||||
+50
-22
@@ -17,8 +17,7 @@ from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core import mail, validators
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -917,6 +916,7 @@ class File(BaseModel):
|
||||
null=True,
|
||||
)
|
||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
hard_deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
filename = models.CharField(max_length=255, null=False, blank=False)
|
||||
|
||||
@@ -954,10 +954,11 @@ class File(BaseModel):
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_deleted(self):
|
||||
"""Return whether the file is in the trash bin."""
|
||||
return self.deleted_at is not None
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
if self.deleted_at is None:
|
||||
raise RuntimeError("The file must be soft deleted before being deleted.")
|
||||
|
||||
return super().delete(using, keep_parents)
|
||||
|
||||
@property
|
||||
def is_ready(self):
|
||||
@@ -1017,33 +1018,60 @@ class File(BaseModel):
|
||||
"""
|
||||
Compute and return abilities for a given user on the file.
|
||||
"""
|
||||
# Characteristics that are based only on specific access
|
||||
is_creator = user == self.creator
|
||||
can_edit = is_creator and not self.is_deleted
|
||||
retrieve = is_creator
|
||||
is_deleted = self.deleted_at is not None
|
||||
can_update = is_creator and not is_deleted and user.is_authenticated
|
||||
can_hard_delete = is_creator and user.is_authenticated
|
||||
can_destroy = can_hard_delete and not is_deleted
|
||||
|
||||
return {
|
||||
"destroy": can_edit,
|
||||
"retrieve": is_creator,
|
||||
"media_auth": can_edit,
|
||||
"partial_update": can_edit,
|
||||
"update": can_edit,
|
||||
"upload_ended": can_edit,
|
||||
"destroy": can_destroy,
|
||||
"hard_delete": can_hard_delete,
|
||||
"retrieve": retrieve,
|
||||
"media_auth": retrieve and not is_deleted,
|
||||
"partial_update": can_update,
|
||||
"update": can_update,
|
||||
"upload_ended": can_update and user.is_authenticated,
|
||||
}
|
||||
|
||||
@transaction.atomic
|
||||
def soft_delete(self):
|
||||
"""Move the file to the trash bin."""
|
||||
"""
|
||||
Soft delete the file.
|
||||
We still keep the .delete() method untouched for programmatic purposes.
|
||||
"""
|
||||
if self.deleted_at:
|
||||
raise RuntimeError("This file is already deleted.")
|
||||
|
||||
self.deleted_at = timezone.now()
|
||||
self.save(update_fields=["deleted_at"])
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
def hard_delete(self):
|
||||
"""
|
||||
Remove the file's objects from storage, then its row from the database.
|
||||
Hard delete the file.
|
||||
We still keep the .delete() method untouched for programmatic purposes.
|
||||
"""
|
||||
if self.hard_deleted_at:
|
||||
raise ValidationError(
|
||||
{
|
||||
"hard_deleted_at": ValidationError(
|
||||
_("This file is already hard deleted."),
|
||||
code="file_hard_delete_already_effective",
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Storage is removed first so that a storage failure leaves the row in place
|
||||
and the periodic purge commands retry it on their next run.
|
||||
"""
|
||||
default_storage.delete(self.temporary_file_key) # Pending
|
||||
default_storage.delete(self.file_key) # Final
|
||||
return super().delete(using, keep_parents)
|
||||
if self.deleted_at is None:
|
||||
raise ValidationError(
|
||||
{
|
||||
"hard_deleted_at": ValidationError(
|
||||
_("To hard delete a file, it must first be soft deleted."),
|
||||
code="file_hard_delete_should_soft_delete_first",
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
self.hard_deleted_at = timezone.now()
|
||||
self.save(update_fields=["hard_deleted_at"])
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"""Celery tasks for the core app."""
|
||||
|
||||
from core.tasks.connection_test import delete_connection_test_room
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
__all__ = ("delete_connection_test_room",)
|
||||
__all__ = (
|
||||
"delete_connection_test_room",
|
||||
"process_file_deletion",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Tasks related to files.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
from core.models import File
|
||||
from core.tasks._task import task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@task
|
||||
def process_file_deletion(file_id):
|
||||
"""
|
||||
Process the deletion of a file.
|
||||
Definitely delete it in the database.
|
||||
Delete the files from the storage.
|
||||
"""
|
||||
logger.info("Processing item deletion for %s", file_id)
|
||||
try:
|
||||
file = File.objects.get(id=file_id)
|
||||
except File.DoesNotExist:
|
||||
logger.error("Item %s does not exist", file_id)
|
||||
return
|
||||
|
||||
if file.hard_deleted_at is None:
|
||||
logger.error("To process an item deletion, it must be hard deleted first.")
|
||||
return
|
||||
|
||||
logger.info("Deleting file %s", file.file_key)
|
||||
default_storage.delete(file.file_key)
|
||||
|
||||
file.delete()
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Tests for the clean_pending_files management command."""
|
||||
|
||||
from datetime import timedelta
|
||||
from io import BytesIO, StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.management import CommandError, call_command
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
@@ -25,32 +23,31 @@ def test_clean_pending_files_recent_pending_not_deleted():
|
||||
file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||
upload_bytes=b"hello",
|
||||
)
|
||||
# A pending upload only lives under the temporary key
|
||||
default_storage.save(file.temporary_file_key, BytesIO(b"hello"))
|
||||
|
||||
call_command("clean_pending_files")
|
||||
|
||||
file.refresh_from_db()
|
||||
assert file.deleted_at is None
|
||||
assert default_storage.exists(file.temporary_file_key)
|
||||
assert default_storage.exists(file.file_key)
|
||||
|
||||
|
||||
def test_clean_pending_files_old_pending_deleted():
|
||||
"""Pending files older than the threshold should be deleted, temporary object included."""
|
||||
"""Pending files older than the threshold should be deleted."""
|
||||
old_date = timezone.now() - timedelta(hours=49)
|
||||
file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||
upload_bytes=b"hello",
|
||||
)
|
||||
# A pending upload only lives under the temporary key
|
||||
default_storage.save(file.temporary_file_key, BytesIO(b"hello"))
|
||||
assert default_storage.exists(file.file_key)
|
||||
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
|
||||
|
||||
call_command("clean_pending_files")
|
||||
|
||||
assert not models.File.objects.filter(pk=file.pk).exists()
|
||||
assert not default_storage.exists(file.temporary_file_key)
|
||||
assert not default_storage.exists(file.file_key)
|
||||
|
||||
|
||||
def test_clean_pending_files_old_non_pending_not_deleted():
|
||||
@@ -66,6 +63,7 @@ def test_clean_pending_files_old_non_pending_not_deleted():
|
||||
|
||||
file.refresh_from_db()
|
||||
assert file.deleted_at is None
|
||||
assert file.hard_deleted_at is None
|
||||
|
||||
|
||||
def test_clean_pending_files_custom_hours():
|
||||
@@ -74,8 +72,8 @@ def test_clean_pending_files_custom_hours():
|
||||
file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||
upload_bytes=b"hello",
|
||||
)
|
||||
default_storage.save(file.temporary_file_key, BytesIO(b"hello"))
|
||||
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
|
||||
|
||||
# Default 24h threshold -> file not deleted
|
||||
@@ -83,58 +81,10 @@ def test_clean_pending_files_custom_hours():
|
||||
|
||||
file.refresh_from_db()
|
||||
assert file.deleted_at is None
|
||||
assert default_storage.exists(file.temporary_file_key)
|
||||
assert default_storage.exists(file.file_key)
|
||||
|
||||
# 8h threshold -> file deleted
|
||||
call_command("clean_pending_files", "--hours=8")
|
||||
|
||||
assert not models.File.objects.filter(pk=file.pk).exists()
|
||||
assert not default_storage.exists(file.temporary_file_key)
|
||||
|
||||
|
||||
def test_clean_pending_files_storage_failure_keeps_row_and_continues():
|
||||
"""
|
||||
A storage failure on one file leaves its row in place for the next run,
|
||||
does not stop the other files from being cleaned, and exits non-zero.
|
||||
"""
|
||||
out = StringIO()
|
||||
err = StringIO()
|
||||
old_date = timezone.now() - timedelta(hours=49)
|
||||
|
||||
failing_file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||
)
|
||||
default_storage.save(failing_file.temporary_file_key, BytesIO(b"hello"))
|
||||
stale_file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||
)
|
||||
default_storage.save(stale_file.temporary_file_key, BytesIO(b"hello"))
|
||||
models.File.objects.filter(pk__in=[failing_file.pk, stale_file.pk]).update(
|
||||
created_at=old_date
|
||||
)
|
||||
|
||||
original_delete = default_storage.delete
|
||||
|
||||
def flaky_delete(name):
|
||||
if name == failing_file.temporary_file_key:
|
||||
raise OSError("boom")
|
||||
return original_delete(name)
|
||||
|
||||
with (
|
||||
patch.object(default_storage, "delete", side_effect=flaky_delete),
|
||||
pytest.raises(CommandError, match="Failed to clean 1 file"),
|
||||
):
|
||||
call_command("clean_pending_files", stdout=out, stderr=err)
|
||||
|
||||
assert "Cleaned 1 stale pending file(s)." in out.getvalue()
|
||||
assert f"Failed to clean file '{failing_file.pk}': boom" in err.getvalue()
|
||||
|
||||
# The failing file is retried on the next run
|
||||
assert models.File.objects.filter(pk=failing_file.pk).exists()
|
||||
assert default_storage.exists(failing_file.temporary_file_key)
|
||||
|
||||
# The other file was cleaned despite the earlier failure
|
||||
assert not models.File.objects.filter(pk=stale_file.pk).exists()
|
||||
assert not default_storage.exists(stale_file.temporary_file_key)
|
||||
assert not default_storage.exists(file.file_key)
|
||||
|
||||
@@ -6,12 +6,13 @@ from random import randint
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.management import CommandError, call_command
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -24,7 +25,11 @@ def test_purge_deleted_files_no_deleted_files(django_assert_num_queries):
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_purge_deleted_files_success(settings):
|
||||
"""Only soft deleted files past the grace period are purged."""
|
||||
"""
|
||||
Queue deletion for:
|
||||
- hard-deleted files
|
||||
- soft-deleted files past retention period + grace period.
|
||||
"""
|
||||
out = StringIO()
|
||||
|
||||
settings.FILE_PURGE_GRACE_DAYS = grace = randint(1, 20)
|
||||
@@ -51,63 +56,30 @@ def test_purge_deleted_files_success(settings):
|
||||
)
|
||||
purgeable_file.soft_delete()
|
||||
|
||||
call_command("purge_deleted_files", stdout=out)
|
||||
hard_deleted_file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
upload_bytes=b"hello",
|
||||
)
|
||||
hard_deleted_file.soft_delete()
|
||||
hard_deleted_file.hard_delete()
|
||||
|
||||
assert "Purged 1 deleted file(s)." in out.getvalue()
|
||||
with patch(
|
||||
"core.management.commands.purge_deleted_files.process_file_deletion.delay",
|
||||
side_effect=process_file_deletion,
|
||||
) as mock_delay:
|
||||
call_command("purge_deleted_files", stdout=out)
|
||||
|
||||
assert "Purged 2 deleted file(s)." in out.getvalue()
|
||||
assert mock_delay.call_count == 2
|
||||
called_ids = {call.args[0] for call in mock_delay.call_args_list}
|
||||
assert called_ids == {purgeable_file.id, hard_deleted_file.id}
|
||||
|
||||
assert models.File.objects.filter(id=not_deleted_file.id).exists()
|
||||
assert models.File.objects.filter(id=not_purgeable_file.id).exists()
|
||||
assert not models.File.objects.filter(id=purgeable_file.id).exists()
|
||||
assert not models.File.objects.filter(id=hard_deleted_file.id).exists()
|
||||
|
||||
assert default_storage.exists(not_deleted_file.file_key)
|
||||
assert default_storage.exists(not_purgeable_file.file_key)
|
||||
assert not default_storage.exists(purgeable_file.file_key)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_purge_deleted_files_storage_failure_keeps_row_and_continues(settings):
|
||||
"""
|
||||
A storage failure on one file leaves its row in place for the next run,
|
||||
does not stop the other files from being purged, and exits non-zero.
|
||||
"""
|
||||
out = StringIO()
|
||||
err = StringIO()
|
||||
|
||||
settings.FILE_PURGE_GRACE_DAYS = 1
|
||||
purge_now = timezone.now() - timedelta(days=2)
|
||||
|
||||
with patch("django.utils.timezone.now", return_value=purge_now):
|
||||
failing_file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
upload_bytes=b"hello",
|
||||
)
|
||||
failing_file.soft_delete()
|
||||
purgeable_file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
upload_bytes=b"hello",
|
||||
)
|
||||
purgeable_file.soft_delete()
|
||||
|
||||
original_delete = default_storage.delete
|
||||
|
||||
def flaky_delete(name):
|
||||
if name == failing_file.file_key:
|
||||
raise OSError("boom")
|
||||
return original_delete(name)
|
||||
|
||||
with (
|
||||
patch.object(default_storage, "delete", side_effect=flaky_delete),
|
||||
pytest.raises(CommandError, match="Failed to purge 1 file"),
|
||||
):
|
||||
call_command("purge_deleted_files", stdout=out, stderr=err)
|
||||
|
||||
assert "Purged 1 deleted file(s)." in out.getvalue()
|
||||
assert f"Failed to purge file '{failing_file.pk}': boom" in err.getvalue()
|
||||
|
||||
# The failing file is retried on the next run
|
||||
assert models.File.objects.filter(id=failing_file.id).exists()
|
||||
assert default_storage.exists(failing_file.file_key)
|
||||
|
||||
# The other file was purged despite the earlier failure
|
||||
assert not models.File.objects.filter(id=purgeable_file.id).exists()
|
||||
assert not default_storage.exists(purgeable_file.file_key)
|
||||
assert not default_storage.exists(hard_deleted_file.file_key)
|
||||
|
||||
@@ -4,6 +4,8 @@ Tests for files API endpoint in meet's core app: list
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
@@ -54,6 +56,14 @@ def test_api_files_list_format():
|
||||
title="item 2",
|
||||
)
|
||||
|
||||
# hard deleted item should not appear
|
||||
factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
hard_deleted_at=timezone.now(),
|
||||
title="hard deleted item",
|
||||
creator=user,
|
||||
)
|
||||
|
||||
response = client.get("/api/v1.0/files/")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -84,8 +94,10 @@ def test_api_files_list_format():
|
||||
"size": None,
|
||||
"description": None,
|
||||
"deleted_at": None,
|
||||
"hard_deleted_at": None,
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"hard_delete": True,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from io import BytesIO
|
||||
from unittest import mock
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
@@ -137,40 +136,6 @@ def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog):
|
||||
|
||||
assert not models.File.objects.filter(id=file.id).exists()
|
||||
assert not default_storage.exists(file.file_key)
|
||||
assert not default_storage.exists(file.temporary_file_key)
|
||||
|
||||
|
||||
def test_api_file_upload_ended_rejected_storage_failure_keeps_row(settings):
|
||||
"""
|
||||
When a rejected upload cannot be removed from storage, the row must survive
|
||||
(reverted to pending) so the cleanup can be retried, and no phantom file
|
||||
must be re-inserted under a new id.
|
||||
"""
|
||||
settings.FILE_UPLOAD_RESTRICTIONS = {
|
||||
"background_image": {
|
||||
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
|
||||
"allowed_mimetypes": ["application/pdf"],
|
||||
}
|
||||
}
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = factories.FileFactory(
|
||||
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
|
||||
)
|
||||
default_storage.save(file.temporary_file_key, BytesIO(b"my prose"))
|
||||
|
||||
with (
|
||||
mock.patch.object(default_storage, "delete", side_effect=OSError("boom")),
|
||||
pytest.raises(OSError, match="boom"),
|
||||
):
|
||||
client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||
|
||||
assert models.File.objects.count() == 1
|
||||
file.refresh_from_db()
|
||||
assert file.upload_state == FileUploadStateChoices.PENDING
|
||||
|
||||
|
||||
def test_api_file_upload_ended_mimetype_not_allowed_not_checking_mimetype(settings):
|
||||
@@ -308,7 +273,6 @@ def test_api_upload_ended_file_size_exceeded(settings, caplog):
|
||||
|
||||
assert not models.File.objects.filter(id=file.id).exists()
|
||||
assert not default_storage.exists(file.file_key)
|
||||
assert not default_storage.exists(file.temporary_file_key)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
|
||||
@@ -117,9 +117,11 @@ def test_start_subtitle_invalid_token():
|
||||
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
|
||||
|
||||
|
||||
def test_start_subtitle_disabled_by_default(mock_livekit_token):
|
||||
def test_start_subtitle_disabled_by_default(mock_livekit_token, settings):
|
||||
"""Test that subtitle functionality is disabled when feature flag is off."""
|
||||
|
||||
settings.ROOM_SUBTITLE_ENABLED = False
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
"""
|
||||
Unit tests for the File model deletion flow
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
from unittest import mock
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import FileFactory
|
||||
from core.models import File
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_files_soft_delete():
|
||||
"""Soft deleting should only set the deletion timestamp."""
|
||||
file = FileFactory()
|
||||
assert file.is_deleted is False
|
||||
|
||||
file.soft_delete()
|
||||
|
||||
file.refresh_from_db()
|
||||
assert file.is_deleted is True
|
||||
assert FileFactory(deleted_at=timezone.now()).is_deleted is True
|
||||
|
||||
|
||||
def test_models_files_soft_delete_twice():
|
||||
"""Soft deleting an already soft deleted file should fail."""
|
||||
file = FileFactory()
|
||||
file.soft_delete()
|
||||
|
||||
with pytest.raises(RuntimeError, match="already deleted"):
|
||||
file.soft_delete()
|
||||
|
||||
|
||||
def test_models_files_delete():
|
||||
"""Deleting should remove the row and both the final and temporary objects."""
|
||||
file = FileFactory(upload_bytes=b"hello")
|
||||
default_storage.save(file.temporary_file_key, BytesIO(b"hello"))
|
||||
# Captured up front: Django nulls the pk after delete, and the keys depend on it
|
||||
pk, key, temporary_key = file.pk, file.file_key, file.temporary_file_key
|
||||
|
||||
file.delete()
|
||||
|
||||
assert not File.objects.filter(pk=pk).exists()
|
||||
assert not default_storage.exists(key)
|
||||
assert not default_storage.exists(temporary_key)
|
||||
|
||||
|
||||
def test_models_files_delete_without_storage_object():
|
||||
"""Deleting a file that has nothing in storage should still remove the row."""
|
||||
file = FileFactory()
|
||||
pk = file.pk
|
||||
|
||||
file.delete()
|
||||
|
||||
assert not File.objects.filter(pk=pk).exists()
|
||||
|
||||
|
||||
def test_models_files_delete_storage_failure_keeps_row():
|
||||
"""A storage failure must leave the row in place so the deletion can be retried."""
|
||||
file = FileFactory(upload_bytes=b"hello")
|
||||
|
||||
with (
|
||||
mock.patch.object(default_storage, "delete", side_effect=OSError("boom")),
|
||||
pytest.raises(OSError, match="boom"),
|
||||
):
|
||||
file.delete()
|
||||
|
||||
assert File.objects.filter(pk=file.pk).exists()
|
||||
assert default_storage.exists(file.file_key)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Unit tests for the get_release settings helper."""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from meet.settings import get_release
|
||||
|
||||
|
||||
@pytest.fixture(name="base_dir")
|
||||
def fixture_empty_base_dir(tmp_path, monkeypatch):
|
||||
"""Point get_release at an empty directory."""
|
||||
monkeypatch.setattr("meet.settings.BASE_DIR", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_get_release_reads_project_pyproject():
|
||||
"""Should return the semantic version of the backend's pyproject.toml."""
|
||||
assert re.fullmatch(r"\d+\.\d+\.\d+", get_release())
|
||||
|
||||
|
||||
def test_get_release_reads_pyproject_version(base_dir):
|
||||
"""Should return the version declared in the [project] table."""
|
||||
(base_dir / "pyproject.toml").write_text(
|
||||
'[project]\nname = "meet"\nversion = "1.2.3"\n', encoding="utf-8"
|
||||
)
|
||||
assert get_release() == "1.2.3"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("base_dir")
|
||||
def test_get_release_missing_pyproject():
|
||||
"""Should fall back to "NA" without a pyproject.toml."""
|
||||
assert get_release() == "NA"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
'[project]\nname = "meet"\n', # no version
|
||||
"[tool.uv]\npackage = true\n", # no [project] table
|
||||
"[project\nversion = ", # malformed TOML
|
||||
],
|
||||
)
|
||||
def test_get_release_unreadable_version(base_dir, content):
|
||||
"""Should fall back to "NA" without a readable version in pyproject.toml."""
|
||||
(base_dir / "pyproject.toml").write_text(content, encoding="utf-8")
|
||||
assert get_release() == "NA"
|
||||
@@ -12,7 +12,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
import tomllib
|
||||
import warnings
|
||||
from os import path
|
||||
from socket import gethostbyname, gethostname
|
||||
@@ -37,19 +37,11 @@ GB = 1024 * MB
|
||||
def get_release():
|
||||
"""
|
||||
Get the current release of the application
|
||||
|
||||
By release, we mean the release from the version.json file à la Mozilla [1]
|
||||
(if any). If this file has not been found, it defaults to "NA".
|
||||
|
||||
[1]
|
||||
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
|
||||
"""
|
||||
# Try to get the current release from the version.json file generated by the
|
||||
# CI during the Docker image build
|
||||
try:
|
||||
with open(path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
|
||||
return json.load(version)["version"]
|
||||
except FileNotFoundError:
|
||||
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
|
||||
return tomllib.load(pyproject)["project"]["version"]
|
||||
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
|
||||
return "NA" # Default: not available
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ApiFileItem = {
|
||||
type: ApiFileType
|
||||
creator: ApiFileCreator
|
||||
deleted_at: string | null
|
||||
hard_deleted_at: string | null
|
||||
filename: string
|
||||
upload_state: ApiFileUploadState
|
||||
mimetype: string // e.g. "image/png"
|
||||
|
||||
Reference in New Issue
Block a user