From 6cb2086cdcfc56c7c1e89798ea040c552f667d53 Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Wed, 19 Aug 2026 17:58:29 +0300 Subject: [PATCH 1/4] recast to OsfStorageFileNode after restore does not clear delete_* fields --- addons/osfstorage/tests/test_models.py | 15 +++++++++++++++ osf/models/files.py | 4 ++++ 2 files changed, 19 insertions(+) diff --git a/addons/osfstorage/tests/test_models.py b/addons/osfstorage/tests/test_models.py index 5e45c1d35f1..9b55634a6a9 100644 --- a/addons/osfstorage/tests/test_models.py +++ b/addons/osfstorage/tests/test_models.py @@ -266,6 +266,21 @@ def test_delete_root_node(self): assert BaseFileNode.objects.get(_id=folder._id).type == 'osf.trashedfolder' assert BaseFileNode.objects.get(_id=file._id).type == 'osf.trashedfile' + def test_restore_deleted_file_without_deleted_fields(self): + assert models.TrashedFileNode.objects.exists() is False + + child = self.node_settings.get_root().append_file('Test') + child.delete() + + trashed_file = models.TrashedFileNode.objects.first() + restored_file = trashed_file.restore() + + assert restored_file.deleted is None + assert restored_file.deleted_by is None + assert restored_file.deleted_on is None + + assert models.TrashedFileNode.objects.exists() is False + def test_delete_file(self): child = self.node_settings.get_root().append_file('Test') field_names = [f.name for f in child._meta.get_fields() if not f.is_relation and f.name not in ['id', 'content_type_pk']] diff --git a/osf/models/files.py b/osf/models/files.py index 0609f801034..f9a4337a703 100644 --- a/osf/models/files.py +++ b/osf/models/files.py @@ -671,6 +671,10 @@ def restore(self, recursive=True, parent=None, save=True, deleted_on=None, clien type_cls = File if self.is_file else Folder + self.deleted = None + self.deleted_by = None + self.deleted_on = None + self.recast(self._resolve_class(type_cls)._typedmodels_type) if save: From 6464900aa5bfa451a0030e18fc846fcb605bae5c Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Wed, 19 Aug 2026 19:04:47 +0300 Subject: [PATCH 2/4] added management command to fix files with deleted fields --- admin/management/urls.py | 2 ++ admin/management/views.py | 8 ++++++ admin/templates/management/commands.html | 13 +++++++++ .../commands/fix_restored_trashed_files.py | 28 +++++++++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 osf/management/commands/fix_restored_trashed_files.py diff --git a/admin/management/urls.py b/admin/management/urls.py index 2f77bfc5c8a..cce6e013fcb 100644 --- a/admin/management/urls.py +++ b/admin/management/urls.py @@ -24,4 +24,6 @@ name='remove_orcid_from_user_social'), re_path(r'^migrate_funder_names_to_ror', views.MigrateFunderNamesToRor.as_view(), name='migrate_funder_names_to_ror'), + re_path(r'^fix_restored_trashed_files', views.FixRestoredTrashedFiles.as_view(), + name='fix_restored_trashed_files'), ] diff --git a/admin/management/views.py b/admin/management/views.py index 3c552822b8f..363ca1c154d 100644 --- a/admin/management/views.py +++ b/admin/management/views.py @@ -228,3 +228,11 @@ def post(self, request): for _line in _out_io.getvalue().split('\n'): messages.info(request, _line) return redirect(reverse('management:commands')) + + +class FixRestoredTrashedFiles(ManagementCommandPermissionView): + + def post(self, request): + call_command('fix_restored_trashed_files') + messages.success(request, 'Restored trashed files have been successfully fixed.') + return redirect(reverse('management:commands')) diff --git a/admin/templates/management/commands.html b/admin/templates/management/commands.html index aa7ec517b8f..0b3bf9669e5 100644 --- a/admin/templates/management/commands.html +++ b/admin/templates/management/commands.html @@ -226,6 +226,19 @@

Update ROR funder names to be consistent and proper.

+
+

Fix restored files

+

+ Use this management command to fix restored files that were previously trashed. +

+
+ {% csrf_token %} + +
+
{% endblock %} diff --git a/osf/management/commands/fix_restored_trashed_files.py b/osf/management/commands/fix_restored_trashed_files.py new file mode 100644 index 00000000000..a7fa341b80b --- /dev/null +++ b/osf/management/commands/fix_restored_trashed_files.py @@ -0,0 +1,28 @@ +""" +Clears deleted, deleted_on, deleted_by values for all restored OsfStorageFileNode objects +as restore() method did not remove them and such files after restore from TrashedFileNode are not shown on UI +""" +import logging + +from django.db import transaction +from django.db.models import Q +from django.core.management.base import BaseCommand + +from addons.osfstorage.models import OsfStorageFileNode + + +logger = logging.getLogger(__name__) + +class Command(BaseCommand): + + def handle(self, *args, **options): + with transaction.atomic(): + files_to_fix = OsfStorageFileNode.objects.filter( + Q(deleted__isnull=False) | Q(deleted_by__isnull=False) | Q(deleted_on__isnull=False), + ) + for file in files_to_fix: + file.deleted = None + file.deleted_by = None + file.deleted_on = None + + OsfStorageFileNode.objects.bulk_update(files_to_fix, ['deleted', 'deleted_by', 'deleted_on'], batch_size=1000) From 0a2b97c1a49039e3dcbf94aa71b3681aade51a5a Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Wed, 19 Aug 2026 19:45:30 +0300 Subject: [PATCH 3/4] fixed restore for folders --- addons/osfstorage/tests/test_models.py | 5 +++-- .../commands/fix_restored_trashed_files.py | 13 ++++--------- osf/models/files.py | 2 -- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/addons/osfstorage/tests/test_models.py b/addons/osfstorage/tests/test_models.py index 9b55634a6a9..63552a4b574 100644 --- a/addons/osfstorage/tests/test_models.py +++ b/addons/osfstorage/tests/test_models.py @@ -266,7 +266,7 @@ def test_delete_root_node(self): assert BaseFileNode.objects.get(_id=folder._id).type == 'osf.trashedfolder' assert BaseFileNode.objects.get(_id=file._id).type == 'osf.trashedfile' - def test_restore_deleted_file_without_deleted_fields(self): + def test_restore_deleted_file_without_deleted_field(self): assert models.TrashedFileNode.objects.exists() is False child = self.node_settings.get_root().append_file('Test') @@ -276,8 +276,9 @@ def test_restore_deleted_file_without_deleted_fields(self): restored_file = trashed_file.restore() assert restored_file.deleted is None + # None because we do not set deleted_by when delete the child assert restored_file.deleted_by is None - assert restored_file.deleted_on is None + assert restored_file.deleted_on is not None assert models.TrashedFileNode.objects.exists() is False diff --git a/osf/management/commands/fix_restored_trashed_files.py b/osf/management/commands/fix_restored_trashed_files.py index a7fa341b80b..a83cf49a485 100644 --- a/osf/management/commands/fix_restored_trashed_files.py +++ b/osf/management/commands/fix_restored_trashed_files.py @@ -1,11 +1,10 @@ """ -Clears deleted, deleted_on, deleted_by values for all restored OsfStorageFileNode objects -as restore() method did not remove them and such files after restore from TrashedFileNode are not shown on UI +Clears deleted field value for all restored OsfStorageFileNode objects +as restore() method did not remove it and such files after restore from TrashedFileNode are not shown on UI """ import logging from django.db import transaction -from django.db.models import Q from django.core.management.base import BaseCommand from addons.osfstorage.models import OsfStorageFileNode @@ -17,12 +16,8 @@ class Command(BaseCommand): def handle(self, *args, **options): with transaction.atomic(): - files_to_fix = OsfStorageFileNode.objects.filter( - Q(deleted__isnull=False) | Q(deleted_by__isnull=False) | Q(deleted_on__isnull=False), - ) + files_to_fix = OsfStorageFileNode.objects.filter(deleted__isnull=False) for file in files_to_fix: file.deleted = None - file.deleted_by = None - file.deleted_on = None - OsfStorageFileNode.objects.bulk_update(files_to_fix, ['deleted', 'deleted_by', 'deleted_on'], batch_size=1000) + OsfStorageFileNode.objects.bulk_update(files_to_fix, ['deleted'], batch_size=1000) diff --git a/osf/models/files.py b/osf/models/files.py index f9a4337a703..db1b85f2e60 100644 --- a/osf/models/files.py +++ b/osf/models/files.py @@ -672,8 +672,6 @@ def restore(self, recursive=True, parent=None, save=True, deleted_on=None, clien type_cls = File if self.is_file else Folder self.deleted = None - self.deleted_by = None - self.deleted_on = None self.recast(self._resolve_class(type_cls)._typedmodels_type) From df33c0463a33443dc3c4505296bb733d3cd1ca0e Mon Sep 17 00:00:00 2001 From: Ihor Sokhan Date: Fri, 21 Aug 2026 18:37:33 +0300 Subject: [PATCH 4/4] added dry run mode --- .../commands/fix_restored_trashed_files.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/osf/management/commands/fix_restored_trashed_files.py b/osf/management/commands/fix_restored_trashed_files.py index a83cf49a485..48afcabe920 100644 --- a/osf/management/commands/fix_restored_trashed_files.py +++ b/osf/management/commands/fix_restored_trashed_files.py @@ -14,10 +14,28 @@ class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Run without making changes', + ) + def handle(self, *args, **options): + files_to_fix = OsfStorageFileNode.objects.filter(deleted__isnull=False) + file_ids = list(files_to_fix.values_list('_id', flat=True)) + dry_run = options.get('dry_run', False) + + if dry_run: + logger.info(f'Running in dry-run mode, the following files would be fixed: {file_ids}') + self.stdout.write(f'Running in dry-run mode, the following files would be fixed: {file_ids}') + return + with transaction.atomic(): - files_to_fix = OsfStorageFileNode.objects.filter(deleted__isnull=False) for file in files_to_fix: file.deleted = None OsfStorageFileNode.objects.bulk_update(files_to_fix, ['deleted'], batch_size=1000) + + logger.info(f'The following files have been fixed: {file_ids}') + self.stdout.write(f'The following files have been fixed: {file_ids}')