diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 3924569f..c3dd1b2b 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1197,22 +1197,25 @@ class FileViewSet( Check the actual uploaded file and mark it as ready. """ # Ensures we go through authorization checks - self.get_object() + file = self.get_object() + + # Try to update the file with the new state. If the file is already in this state + # we are in a concurrent request, and we should reject that request + updated_rows = models.File.objects.filter( + upload_state=models.FileUploadStateChoices.PENDING, + pk=kwargs["pk"], + ).update(upload_state=models.FileUploadStateChoices.ANALYZING) + if updated_rows != 1: + raise drf_exceptions.ValidationError( + {"file": "This action is only available for files in PENDING state."}, + code="file_upload_state_not_pending", + ) + file.refresh_from_db() s3_client = default_storage.connection.meta.client validation_error = None - with transaction.atomic(): - file = self.get_queryset().select_for_update().get(pk=kwargs["pk"]) - - if not file.is_pending_upload: - raise drf_exceptions.ValidationError( - { - "file": "This action is only available for files in PENDING state." - }, - code="file_upload_state_not_pending", - ) - + try: # We copy the file to its final destination, we will run the checks on that # final file and ignore any updates to the temporary file. (We cannot revoke the policy, # so the temporary file might still be updated after that.) @@ -1302,6 +1305,11 @@ class FileViewSet( Metadata=head_response["Metadata"], MetadataDirective="REPLACE", ) + except Exception as e: + logger.exception("Failed to analyze file, reverting to pending state") + file.upload_state = models.FileUploadStateChoices.PENDING + file.save() + raise e if validation_error: raise validation_error @@ -1318,7 +1326,7 @@ class FileViewSet( """Delete a file completely.""" file.soft_delete() file.hard_delete() - transaction.on_commit(lambda: process_file_deletion.delay(file.id)) + process_file_deletion.delay(file.id) def _authorize_subrequest(self, request, pattern): """ diff --git a/src/backend/core/migrations/0019_alter_file_upload_state.py b/src/backend/core/migrations/0019_alter_file_upload_state.py new file mode 100644 index 00000000..d32d5cdc --- /dev/null +++ b/src/backend/core/migrations/0019_alter_file_upload_state.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-06-02 12:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0018_rename_active_application_is_active'), + ] + + operations = [ + migrations.AlterField( + model_name='file', + name='upload_state', + field=models.CharField(choices=[('pending', 'Pending'), ('analyzing', 'Analyzing'), ('ready', 'Ready')], max_length=25), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index bfdb4643..60879937 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -846,8 +846,8 @@ class FileUploadStateChoices(models.TextChoices): """Possible states of a file.""" PENDING = "pending", _("Pending") + ANALYZING = "analyzing", _("Analyzing") # Commented out for now, as we may need this when we implement the malware detection logic. - # ANALYZING = "analyzing", _("Analyzing") # SUSPICIOUS = "suspicious", _("Suspicious") # FILE_TOO_LARGE_TO_ANALYZE = ( # "file_too_large_to_analyze",