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
2 changes: 1 addition & 1 deletion admin/base/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def reverse_qs(view, urlconf=None, args=None, kwargs=None, current_app=None, que


def osf_staff_check(user):
return user.is_authenticated and user.is_staff
return user and user.is_authenticated and user.is_staff


def get_subject_rules(subjects_selected):
Expand Down
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
55 changes: 55 additions & 0 deletions admin/preprints/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from admin.base.forms import GuidForm
from admin.nodes.views import NodeRemoveContributorView
from admin.preprints.forms import ChangeProviderForm, MachineStateForm
from admin.base.utils import osf_staff_check

from api.share.utils import update_share
from api.providers.workflows import Workflows
Expand Down Expand Up @@ -315,6 +316,60 @@ 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',)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is osf.delete_preprint only available to OSF Admins? If not, we might want something that's a bit more restrictive.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, osf.delete_preprint is django-generated permission, I copied it from the PreprintDeleteView. As far as I know there's no django-generated permission to check if user is admin, so I'll add explicit check inside of the view


def post(self, request, *args, **kwargs):
if not osf_staff_check(request.user):
messages.error(request, 'Only staff can perform hard deletes.')
return redirect(self.get_success_url())

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
77 changes: 77 additions & 0 deletions admin_tests/preprints/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.contrib.messages.storage.fallback import FallbackStorage

from tests.base import AdminTestCase
from framework.auth import Auth
from osf.models import Preprint, PreprintLog, PreprintRequest
from osf_tests.factories import (
AuthUserFactory,
Expand Down Expand Up @@ -410,6 +411,82 @@ def test_restore_preprint(self):
assert AdminLogEntry.objects.count() == count + 1


class TestPreprintHardDeleteView(AdminTestCase):
def setUp(self):
super().setUp()
self.user = AuthUserFactory()
self.user.is_staff = True
self.user.save()
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')
request.user = self.user
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')
request.user = self.user
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')
request.user = self.user
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
Loading