diff --git a/admin/preprints/urls.py b/admin/preprints/urls.py index 4ab9bd33939..df06b547a23 100644 --- a/admin/preprints/urls.py +++ b/admin/preprints/urls.py @@ -20,6 +20,7 @@ re_path(r'^(?P\w+)/make_public/$', views.PreprintMakePublic.as_view(), name='make-public'), re_path(r'^(?P\w+)/remove/$', views.PreprintDeleteView.as_view(), name='remove'), re_path(r'^(?P\w+)/restore/$', views.PreprintDeleteView.as_view(), name='restore'), + re_path(r'^(?P[\w_]+)/hard_delete/$', views.PreprintHardDeleteView.as_view(), name='hard-delete'), re_path(r'^(?P\w+)/confirm_unflag/$', views.PreprintConfirmUnflagView.as_view(), name='confirm-unflag'), re_path(r'^(?P\w+)/confirm_spam/$', views.PreprintConfirmSpamView.as_view(), name='confirm-spam'), re_path(r'^(?P\w+)/confirm_ham/$', views.PreprintConfirmHamView.as_view(), name='confirm-ham'), diff --git a/admin/preprints/views.py b/admin/preprints/views.py index ef7d1860e76..602002f0a4a 100644 --- a/admin/preprints/views.py +++ b/admin/preprints/views.py @@ -315,6 +315,56 @@ def post(self, request, *args, **kwargs): return redirect(self.get_success_url()) +class PreprintHardDeleteView(PreprintMixin, View): + """Allows authorized users to permanently delete an initial-state preprint version. + + This removes ONLY the broken draft preprint version (N+1) and its GuidVersionsThrough + version record, preserving all previous good versions (1 through N) so that a user + can initiate a new version again. + + Based on create_version() and check_unfinished_or_unpublished_version() logic: + - Each version is a separate preprint instance + - The base Guid points to the latest published version + - We only delete the specific broken draft version, not the entire preprint lineage + """ + permission_required = ('osf.delete_preprint',) + + def post(self, request, *args, **kwargs): + preprint = self.get_object() + + if preprint.machine_state != DefaultStates.INITIAL.value: + messages.error(request, f'Only initial-state drafts can be hard deleted. Current state: {preprint.machine_state}') + return redirect(self.get_success_url()) + + try: + with transaction.atomic(): + guid_version = preprint.versioned_guids.first() + if not guid_version: + messages.error(request, 'No version record found for this draft preprint') + return redirect('preprints:search') + + version_number = guid_version.version + base_guid_obj = guid_version.guid + + previous_version = base_guid_obj.versions.filter( + version__lt=version_number, + is_rejected=False + ).order_by('-version').first() + if previous_version: + base_guid_obj.referent = previous_version.referent + base_guid_obj.object_id = previous_version.object_id + base_guid_obj.content_type = previous_version.content_type + base_guid_obj.save() + + guid_version.delete() + preprint.delete() + + messages.success(request, f'Successfully deleted draft version {version_number}. Previous versions preserved.') + return redirect('preprints:search') + except Exception as exc: + messages.error(request, f'Failed to hard delete draft preprint: {str(exc)}') + return redirect(self.get_success_url()) + class PreprintWithdrawalRequestList(PermissionRequiredMixin, ListView): """ Allows authorized users to view list of withdraw requests for preprints and approve or reject the submitted preprint withdraw requests. diff --git a/admin/templates/preprints/hard_delete_preprint.html b/admin/templates/preprints/hard_delete_preprint.html new file mode 100644 index 00000000000..1cd7af4ba24 --- /dev/null +++ b/admin/templates/preprints/hard_delete_preprint.html @@ -0,0 +1,32 @@ +{% if perms.osf.delete_preprint %} + {% if preprint.machine_state == 'initial' %} + + Hard Delete Draft + + + {% endif %} +{% endif %} + + diff --git a/admin/templates/preprints/preprint.html b/admin/templates/preprints/preprint.html index 719304d716f..27b723fe33a 100644 --- a/admin/templates/preprints/preprint.html +++ b/admin/templates/preprints/preprint.html @@ -18,6 +18,7 @@ class="btn btn-primary"> + {% include "preprints/hard_delete_preprint.html" with preprint=preprint %} {% include "preprints/remove_preprint.html" with preprint=preprint %} {% include "preprints/mark_spam.html" with preprint=preprint %} {% include "preprints/reindex_preprint_share.html" with preprint=preprint %} diff --git a/admin_tests/preprints/test_views.py b/admin_tests/preprints/test_views.py index 51164bddd0b..6a1d0b745d9 100644 --- a/admin_tests/preprints/test_views.py +++ b/admin_tests/preprints/test_views.py @@ -9,6 +9,7 @@ from tests.base import AdminTestCase from osf.models import Preprint, PreprintLog, PreprintRequest, NotificationType +from framework.auth import Auth from osf_tests.factories import ( AuthUserFactory, PreprintFactory, @@ -411,6 +412,77 @@ def test_restore_preprint(self): assert AdminLogEntry.objects.count() == count + 1 +class TestPreprintHardDeleteView(AdminTestCase): + def setUp(self): + super().setUp() + self.user = AuthUserFactory() + self.preprint = PreprintFactory(creator=self.user, machine_state=DefaultStates.INITIAL.value) + self.plain_view = views.PreprintHardDeleteView + + def test_hard_delete_initial_draft(self): + self.preprint.machine_state = DefaultStates.INITIAL.value + self.preprint.save() + + request = RequestFactory().post('/fake_path') + patch_messages(request) + + assert Preprint.objects.filter(id=self.preprint.id).exists() + assert self.preprint.machine_state == DefaultStates.INITIAL.value + + view = setup_log_view(self.plain_view(), request, guid=self.preprint._id) + view.post(request) + + assert not Preprint.objects.filter(id=self.preprint.id).exists() + + def test_hard_delete_non_initial_state_fails(self): + self.preprint.machine_state = DefaultStates.PENDING.value + self.preprint.save() + + versioned_guid = f"{self.preprint._id}_v{self.preprint.version}" + + request = RequestFactory().post('/fake_path') + patch_messages(request) + + view = setup_log_view(self.plain_view(), request, guid=versioned_guid) + view.post(request) + assert Preprint.objects.filter(id=self.preprint.id).exists() + + def test_hard_delete_with_previous_versions(self): + published_preprint = PreprintFactory(creator=self.user, is_published=True) + published_preprint.machine_state = DefaultStates.ACCEPTED.value + published_preprint.save() + + draft_preprint, _ = Preprint.create_version( + create_from_guid=published_preprint._id, + auth=Auth(published_preprint.creator), + ignore_permission=True + ) + draft_preprint.machine_state = DefaultStates.INITIAL.value + draft_preprint.save() + + base_guid = published_preprint.get_guid() + versions = base_guid.versions.all() + assert versions.count() == 2 + + assert base_guid.referent == draft_preprint + + request = RequestFactory().post('/fake_path') + patch_messages(request) + + view = setup_log_view(self.plain_view(), request, guid=base_guid._id) + view.post(request) + + assert not Preprint.objects.filter(id=draft_preprint.id).exists() + assert Preprint.objects.filter(id=published_preprint.id).exists() + + base_guid.refresh_from_db() + assert base_guid.referent == published_preprint + + versions = base_guid.versions.all() + assert versions.count() == 1 + assert versions.first().referent == published_preprint + + class TestRemoveContributor(AdminTestCase): def setUp(self): super().setUp()