Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions admin/preprints/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
re_path(r'^(?P<guid>\w+)/make_public/$', views.PreprintMakePublic.as_view(), name='make-public'),
re_path(r'^(?P<guid>\w+)/remove/$', views.PreprintDeleteView.as_view(), name='remove'),
re_path(r'^(?P<guid>\w+)/restore/$', views.PreprintDeleteView.as_view(), name='restore'),
re_path(r'^(?P<guid>[\w_]+)/hard_delete/$', views.PreprintHardDeleteView.as_view(), name='hard-delete'),
re_path(r'^(?P<guid>\w+)/confirm_unflag/$', views.PreprintConfirmUnflagView.as_view(), name='confirm-unflag'),
re_path(r'^(?P<guid>\w+)/confirm_spam/$', views.PreprintConfirmSpamView.as_view(), name='confirm-spam'),
re_path(r'^(?P<guid>\w+)/confirm_ham/$', views.PreprintConfirmHamView.as_view(), name='confirm-ham'),
Expand Down
50 changes: 50 additions & 0 deletions admin/preprints/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions admin/templates/preprints/hard_delete_preprint.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{% if perms.osf.delete_preprint %}
{% if preprint.machine_state == 'initial' %}
<a data-toggle="modal" data-target="#hardDeleteModal" class="btn btn-danger">
Hard Delete Draft
</a>
<div class="modal" id="hardDeleteModal">
<div class="modal-dialog">
<div class="modal-content">
<form class="well" method="post" action="{% url 'preprints:hard-delete' guid=preprint.guid %}">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">x</button>
<h3>Hard delete draft preprint? ({{ preprint.guid }})</h3>
</div>
<div class="modal-body">
This action permanently removes this draft preprint version and cannot be undone.
{% csrf_token %}
</div>
<div class="modal-footer">
<input class="btn btn-warning" type="submit" value="Confirm" />
<button type="button" class="btn btn-default"
data-dismiss="modal">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endif %}


1 change: 1 addition & 0 deletions admin/templates/preprints/preprint.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
class="btn btn-primary">
<i class="fa fa-search"></i>
</a>
{% 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 %}
Expand Down
72 changes: 72 additions & 0 deletions admin_tests/preprints/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down