Skip to content
Closed
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
24 changes: 23 additions & 1 deletion admin/preprints/forms.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
from django import forms

from osf.models import Preprint
from osf.models import Preprint, PreprintProvider
from osf.utils.workflows import ReviewStates


class RecoverDeletedPreprintForm(forms.Form):
"""Inputs needed to recreate a preprint that was hard-deleted by the
Create-New-Version bug (ENG-11012). The GUID and DOI are recreated exactly
as they were so existing DOIs and links keep resolving.
"""
provider = forms.ModelChoiceField(queryset=PreprintProvider.objects.all())
guid = forms.CharField(max_length=5, min_length=5, label='Base GUID')
title = forms.CharField(max_length=512)
description = forms.CharField(widget=forms.Textarea, required=False)
file_guid = forms.CharField(
required=False,
label='Source file GUID',
help_text='Optional: an existing file GUID; its latest version is copied into this '
'version as the primary file.',
)
ticket_reference = forms.CharField(
max_length=255,
label='Support ticket / JIRA reference',
help_text='Recorded in the admin log so the recreated preprint is traceable.',
)

class ChangeProviderForm(forms.ModelForm):
class Meta:
model = Preprint
Expand Down
1 change: 1 addition & 0 deletions admin/preprints/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
re_path(r'^known_spam$', views.PreprintKnownSpamList.as_view(), name='known-spam'),
re_path(r'^known_ham$', views.PreprintKnownHamList.as_view(), name='known-ham'),
re_path(r'^withdrawal_requests$', views.PreprintWithdrawalRequestList.as_view(), name='withdrawal-requests'),
re_path(r'^recover/$', views.RecoverDeletedPreprintView.as_view(), name='recover'),
re_path(r'^(?P<guid>\w+)/$', views.PreprintView.as_view(), name='preprint'),
re_path(r'^(?P<guid>\w+)/change_provider/$', views.PreprintProviderChangeView.as_view(), name='preprint-provider'),
re_path(r'^(?P<guid>\w+)/machine_state/$', views.PreprintMachineStateView.as_view(), name='preprint-machine-state'),
Expand Down
82 changes: 79 additions & 3 deletions admin/preprints/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.db import transaction
from django.db.models import F
from django.core.exceptions import PermissionDenied
from django.core.exceptions import PermissionDenied, ValidationError
from django.http import HttpResponse, JsonResponse
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
Expand All @@ -16,7 +16,7 @@
from admin.base.views import GuidView
from admin.base.forms import GuidForm
from admin.nodes.views import NodeRemoveContributorView, NodeAddSystemTag, NodeRemoveSystemTag, NodeUpdatePermissionsView
from admin.preprints.forms import ChangeProviderForm, MachineStateForm
from admin.preprints.forms import ChangeProviderForm, MachineStateForm, RecoverDeletedPreprintForm
from admin.base.utils import osf_staff_check

from api.share.utils import update_share
Expand All @@ -33,7 +33,9 @@
Preprint,
PreprintLog,
PreprintRequest,
PreprintProvider
PreprintProvider,
Guid,
BaseFileNode,
)

from osf.models.admin_log_entry import (
Expand All @@ -42,6 +44,7 @@
REINDEX_SHARE,
PREPRINT_REMOVED,
PREPRINT_RESTORED,
PREPRINT_RECOVERED,
CONFIRM_SPAM,
CONFIRM_HAM,
APPROVE_WITHDRAWAL,
Expand Down Expand Up @@ -249,6 +252,79 @@ def post(self, request, *args, **kwargs):
return JsonResponse({'redirect': self.get_success_url(new_preprint._id)})


class RecoverDeletedPreprintView(PermissionRequiredMixin, FormView):
"""Recreate a preprint that was hard-deleted by the Create-New-Version bug
(ENG-11012), restoring its original GUID. All versions are recreated in order
within a single atomic transaction, and the acting admin, timestamp and ticket
reference are recorded in the admin log.

Recovers one version at a time: the first run on a GUID recreates version 1,
each subsequent run on the same GUID adds the next version. This lets Product
give each version its own file and metadata. Optionally copies the latest
version of an existing file (referenced by GUID) into the recreated version as
its primary file, so admins copy from a project of uploaded files rather than
uploading here. The DOIs are deterministic from the GUID, so resyncing CrossRef
restores each version's original DOI.
"""
template_name = 'preprints/recover_preprint.html'
permission_required = ('osf.change_preprint',)
raise_exception = True
form_class = RecoverDeletedPreprintForm

def _copy_primary_file(self, preprint, file_guid):
source_file = getattr(Guid.load(file_guid), 'referent', None)
if not isinstance(source_file, BaseFileNode):
raise ValueError(f'No file found for guid "{file_guid}".')
latest_version = source_file.versions.order_by('-created').first()
if latest_version is None:
raise ValueError(f'File "{file_guid}" has no versions to copy.')
copied = copy_files(source_file, target_node=preprint, identifier=latest_version.identifier)
preprint.set_primary_file(copied, auth=self.request, save=True)

def form_valid(self, form):
data = form.cleaned_data
guid = data['guid']
file_guid = data['file_guid'].strip()
try:
with transaction.atomic():
if Preprint.load(guid):
# GUID already recovered: add the next version.
version, _ = Preprint.create_version(
create_from_guid=guid,
auth=self.request,
ignore_permission=True,
ignore_existing_versions=True,
)
else:
version = Preprint.create(
provider=data['provider'],
title=data['title'],
creator=self.request.user,
description=data['description'],
manual_guid=guid,
)
if file_guid:
self._copy_primary_file(version, file_guid)
update_admin_log(
user_id=self.request.user.id,
object_id=version._id,
object_repr='Preprint',
message=f'Preprint {version._id} recreated after deletion (ref: {data["ticket_reference"]}).',
action_flag=PREPRINT_RECOVERED,
)
except (ValidationError, ValueError) as exc:
reason = '; '.join(exc.messages) if isinstance(exc, ValidationError) else str(exc)
messages.error(self.request, f'Could not recover preprint: {reason}')
return redirect(reverse_lazy('preprints:recover'))

messages.success(
self.request,
f'Recreated preprint version {version._id}. Set contributors/metadata, then publish '
'and resync CrossRef. Run again on the same GUID to add the next version.',
)
return redirect(reverse_lazy('preprints:preprint', kwargs={'guid': version._id}))


class PreprintReindexElastic(PreprintMixin, View):
""" Allows an authorized user to reindex a node in ElasticSearch.
"""
Expand Down
34 changes: 34 additions & 0 deletions admin/templates/preprints/recover_preprint.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@

{% extends 'base.html' %}
{% load static %}
{% block title %}
<title>Recover Deleted Preprint</title>
{% endblock title %}
{% block content %}
<div class="container-fluid">
<h2>Recover Deleted Preprint</h2>
<p>
Recreates a preprint that was hard-deleted by the Create-New-Version bug,
restoring its original GUID and DOI. After recovering, attach the file,
set contributors/metadata, then publish and resync CrossRef.
</p>
<div class="row">
<form action="{% url 'preprints:recover' %}" method="post">
{% csrf_token %}
{% if form.errors %}
<div class="alert alert-danger">{{ form.errors }}</div>
{% endif %}
{% for field in form %}
<div class="form-group">
<label for="{{ field.id_for_label }}">{{ field.label }}:</label>
{{ field }}
{% if field.help_text %}
<p class="help-block">{{ field.help_text }}</p>
{% endif %}
</div>
{% endfor %}
<input type="submit" class="btn btn-primary" value="Recover" />
</form>
</div>
</div>
{% endblock content %}
3 changes: 3 additions & 0 deletions admin/templates/preprints/search.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@
<input type="submit" class="btn btn-primary" value="Submit" />
</form>
</div>
<div class="row" style="margin-top: 15px;">
<a class="btn btn-default" href="{% url 'preprints:recover' %}">Recover deleted preprint</a>
</div>
</div>
{% endblock content %}
7 changes: 7 additions & 0 deletions admin_tests/nodes/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@

from tests.base import AdminTestCase
from osf_tests.factories import (
SubjectFactory,
UserFactory,
AuthUserFactory,
ProjectFactory,
Expand Down Expand Up @@ -657,6 +658,7 @@ def setUp(self):
provider=RegistrationProviderFactory(reviews_workflow='pre-moderation'),
creator=self.user
)
pre_moderation_draft.subjects.add(SubjectFactory())
self._add_contributor(pre_moderation_draft, permissions.ADMIN, self.contr1)
self._add_contributor(pre_moderation_draft, permissions.ADMIN, self.contr2)
self._add_contributor(pre_moderation_draft, permissions.ADMIN, self.contr3)
Expand All @@ -670,6 +672,7 @@ def setUp(self):
provider=RegistrationProviderFactory(reviews_workflow='post-moderation'),
creator=self.user
)
post_moderation_draft.subjects.add(SubjectFactory())
self._add_contributor(post_moderation_draft, permissions.ADMIN, self.contr1)
self._add_contributor(post_moderation_draft, permissions.ADMIN, self.contr2)
self._add_contributor(post_moderation_draft, permissions.ADMIN, self.contr3)
Expand All @@ -682,6 +685,7 @@ def setUp(self):
registration_schema=get_default_metaschema(),
creator=self.user
)
self.no_moderation_draft.subjects.add(SubjectFactory())
self._add_contributor(self.no_moderation_draft, permissions.ADMIN, self.contr1)
self._add_contributor(self.no_moderation_draft, permissions.ADMIN, self.contr2)
self._add_contributor(self.no_moderation_draft, permissions.ADMIN, self.contr3)
Expand Down Expand Up @@ -881,6 +885,7 @@ def test_revert_node_based_registration(self):
self._add_contributor(pre_moderation_draft, permissions.ADMIN, self.contr1)
self._add_contributor(pre_moderation_draft, permissions.ADMIN, self.contr2)
self._add_contributor(pre_moderation_draft, permissions.ADMIN, self.contr3)
pre_moderation_draft.subjects.add(SubjectFactory())
pre_moderation_draft.register(auth=self.auth, save=True)
pre_moderation_registration = pre_moderation_draft.registered_node

Expand All @@ -901,6 +906,7 @@ def test_can_revert_embargo_registration_to_draft(self):
registration_schema=get_default_metaschema(),
creator=self.user
)
self.no_moderation_draft.subjects.add(SubjectFactory())
self.no_moderation_draft.register(auth=self.auth, save=True)
self.registration = self.no_moderation_draft.registered_node

Expand Down Expand Up @@ -929,6 +935,7 @@ def test_embargo_is_reset_after_reversion(self):
registration_schema=get_default_metaschema(),
creator=self.user
)
self.no_moderation_draft.subjects.add(SubjectFactory())
self.no_moderation_draft.register(auth=self.auth, save=True)
self.registration = self.no_moderation_draft.registered_node

Expand Down
64 changes: 63 additions & 1 deletion admin_tests/preprints/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
SubjectFactory,

)
from osf.models.admin_log_entry import AdminLogEntry
from osf.models.admin_log_entry import AdminLogEntry, PREPRINT_RECOVERED
from osf.models.spam import SpamStatus
from osf.utils.workflows import DefaultStates, RequestTypes
from osf.utils.permissions import ADMIN
Expand Down Expand Up @@ -1000,3 +1000,65 @@ def test_admin_user_can_fix_coi_only_when_coi_is_set(self, user, preprint, plain

assert preprint.has_coi
assert preprint.conflict_of_interest_statement == ''


@pytest.mark.urls('admin.base.urls')
class TestRecoverDeletedPreprintView(AdminTestCase):
def setUp(self):
super().setUp()
self.user = AuthUserFactory()
self.user.user_permissions.add(Permission.objects.get(codename='change_preprint'))
self.provider = PreprintProviderFactory(doi_prefix='10.31219')

def _post(self, data):
request = RequestFactory().post(reverse('preprints:recover'), data=data)
request.user = self.user
patch_messages(request)
return views.RecoverDeletedPreprintView.as_view()(request)

def _base_data(self, **overrides):
data = {
'provider': self.provider.id,
'guid': 'abcde',
'title': 'Recovered Title',
'description': 'desc',
'file_guid': '',
'ticket_reference': 'ENG-1234',
}
data.update(overrides)
return data

def test_first_run_recreates_version_1_with_audit(self):
response = self._post(self._base_data())
assert response.status_code == 302

preprint = Preprint.load('abcde')
assert preprint is not None
assert preprint._id == 'abcde_v1'
assert preprint.title == 'Recovered Title'

log = AdminLogEntry.objects.get(action_flag=PREPRINT_RECOVERED)
assert log.user_id == self.user.id
assert 'ENG-1234' in log.change_message

def test_second_run_adds_next_version(self):
from osf.models import Guid
assert self._post(self._base_data()).status_code == 302
assert self._post(self._base_data()).status_code == 302

versions = Guid.load('abcde').versions.order_by('version')
assert list(versions.values_list('version', flat=True)) == [1, 2]
for version_through in versions:
assert version_through.referent._id == f'abcde_v{version_through.version}'

def test_copies_primary_file_from_source_guid(self):
source = PreprintFactory(provider=self.provider)
source_file = source.primary_file
file_guid = source_file.get_guid(create=True)._id

response = self._post(self._base_data(file_guid=file_guid))
assert response.status_code == 302

recovered = Preprint.load('abcde')
assert recovered.primary_file is not None
assert recovered.primary_file.copied_from_id == source_file.id
4 changes: 2 additions & 2 deletions api/registrations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from addons.base.views import DOWNLOAD_ACTIONS
from website.archiver import signals, ARCHIVER_NETWORK_ERROR, ARCHIVER_SUCCESS, ARCHIVER_FAILURE
from website.project import signals as project_signals
from website.archiver.tasks import archive_callback

from osf.models import Registration, OSFUser, RegistrationProvider, OutcomeArtifact, CedarMetadataRecord
from osf.models.spam import SpamStatus
Expand Down Expand Up @@ -1114,7 +1114,7 @@ def update(self, request, *args, **kwargs):
src_provider,
ARCHIVER_SUCCESS,
)
project_signals.archive_callback.send(registration)
archive_callback(registration._id)
return Response(status=status.HTTP_200_OK)
except HTTPError as e:
registration.archive_status = ARCHIVER_NETWORK_ERROR
Expand Down
4 changes: 4 additions & 0 deletions api/share/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ def _schedule_cedar_record_updates(guid_instance):
acks_late=True,
max_retries=4,
retry_backoff=True,
soft_time_limit=settings.SHARE_UPDATE_TASK_SOFT_TIME_LIMIT,
time_limit=settings.SHARE_UPDATE_TASK_HARD_TIME_LIMIT,
)
def task__update_share(self, guid: str, is_backfill=False, osfmap_partition_name='MAIN'):
"""
Expand Down Expand Up @@ -278,6 +280,7 @@ def pls_send_trove_record(osf_item, *, is_backfill: bool, osfmap_partition: Osfm
**_shtrove_auth_headers(osf_item),
},
data=ensure_bytes(_serialized_record),
timeout=settings.EXTERNAL_REQUEST_TIMEOUT,
)


Expand All @@ -288,6 +291,7 @@ def pls_delete_trove_record(osf_item, osfmap_partition: OsfmapPartition):
'record_identifier': _shtrove_record_identifier(osf_item, osfmap_partition),
},
headers=_shtrove_auth_headers(osf_item),
timeout=settings.EXTERNAL_REQUEST_TIMEOUT,
)


Expand Down
4 changes: 3 additions & 1 deletion api_tests/draft_nodes/views/test_draft_node_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from osf_tests.factories import (
DraftRegistrationFactory,
AuthUserFactory,
ProjectFactory
ProjectFactory,
SubjectFactory
)


Expand All @@ -23,6 +24,7 @@ def user_two(self):
def test_detail_response(self, app, user, user_two):
draft_reg = DraftRegistrationFactory(initiator=user)
draft_reg.add_contributor(user_two)
draft_reg.subjects.add(SubjectFactory())
draft_reg.save()

draft_node = draft_reg.branched_from
Expand Down
Loading