Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions admin/nodes/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,7 @@
re_path(r'^(?P<guid>[a-z0-9]+)/system_tags/(?P<tag_id>[a-z0-9]+)/remove/$', views.NodeRemoveSystemTag.as_view(), name='remove-system-tag'),
re_path(r'^(?P<guid>[a-z0-9]+)/update_permissions/$', views.NodeUpdatePermissionsView.as_view(), name='update-permissions'),
re_path(r'^(?P<guid>[a-z0-9]+)/remove_file/$', views.NodeRemoveFileView.as_view(), name='remove-file'),
re_path(r'^(?P<guid>[a-z0-9]+)/add_osfstorage_file/$', views.NodeAddOsfStorageFileView.as_view(), name='add-osfstorage-file'),
re_path(r'^(?P<guid>[a-z0-9]+)/remove_osfstorage_file/$', views.NodeRemoveOsfStorageFileView.as_view(), name='remove-osfstorage-file'),

]
54 changes: 54 additions & 0 deletions admin/nodes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from admin.base.views import GuidView
from admin.nodes.forms import AddSystemTagForm, RegistrationDateForm
from admin.notifications.views import delete_selected_notifications
from addons.osfstorage.models import OsfStorageFolder
from api.caching.tasks import update_storage_usage_cache
from api.share.utils import update_share
from framework import status
Expand Down Expand Up @@ -846,6 +847,59 @@ def _remove_file_from_schema_response_blocks(registration, removed_file_id):
return redirect(self.get_success_url())


class NodeAddOsfStorageFileView(NodeMixin, View):
""" Allows an authorized user to add a file to osfstorage of an archived node.
"""
permission_required = 'osf.change_node'

def post(self, request, *args, **kwargs):
registration = self.get_object()
guid_id = request.POST.get('file-guid', '').strip()
guid = Guid.load(guid_id)
if not guid:
messages.error(request, 'No file found with the provided guid.')
return redirect(self.get_success_url())

file = guid.referent
Comment thread
felliott marked this conversation as resolved.
parent_node = registration.registered_from
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nitpick: move this line after the file check

if not parent_node:
messages.error(request, 'The registration does not have the parent node.')
return redirect(self.get_success_url())

if not parent_node.files.filter(id=file.id).exists():
messages.error(request, 'The file with the provided guid is not part of the parent node.')
return redirect(self.get_success_url())

osfstorage = registration.get_addon('osfstorage')
# copy file to Archive of OSF Storage folder
archive_folder = OsfStorageFolder.objects.filter(
parent=osfstorage.get_root(),
name=osfstorage.archive_folder_name
).first()
file.copy_under(archive_folder)
messages.success(request, 'The file was successfully added.')
return redirect(self.get_success_url())


class NodeRemoveOsfStorageFileView(NodeMixin, View):
""" Allows an authorized user to remove a file from osfstorage of an archived node.
"""
permission_required = 'osf.change_node'

def post(self, request, *args, **kwargs):
registration = self.get_object()
guid_id = request.POST.get('file-guid', '').strip()
guid = Guid.load(guid_id)
if not guid:
messages.error(request, 'No file found with the provided guid.')
return redirect(self.get_success_url())

file = guid.referent
Comment thread
felliott marked this conversation as resolved.
registration.files.filter(id=file.id).delete()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What if the file exists, but not under the registration?

Copy link
Copy Markdown
Contributor Author

@ihorsokhanexoft ihorsokhanexoft Apr 9, 2026

Choose a reason for hiding this comment

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

If I got it correctly, then nothing will happen as we query files of a specific registration, which means file won't be found. We should delete a file only if it's attached to a registration, not project (because in the project user can manually delete a file unlike in the archived registration, this is the purpose of this PR to add such a functionality for product team)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

d'oh! You're absolutely right, sorry for the noise.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Resolved too soon. My first message was poorly worded. I meant that if the file isn't under the registration, shouldn't this return an error message? I know it won't delete the file, but I would expect it to complain instead of saying The file was successfully removed.

messages.success(request, 'The file was successfully removed.')
return redirect(self.get_success_url())


class RemoveStuckRegistrationsView(NodeMixin, View):
""" Allows an authorized user to remove a registrations if it's stuck in the archiving process.
"""
Expand Down
36 changes: 36 additions & 0 deletions admin/templates/nodes/add_file_to_osfstorage.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{% if node.is_registration and node.archived %}
<a data-toggle="modal" data-target="#confirmAddFileModal" class="btn btn-primary">
Add File (Osfstorage)
</a>
<div id="confirmAddFileModal" class="modal fade well" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<form class="well" method="post" action="{% url 'nodes:add-osfstorage-file' guid=node.guid %}">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">x</button>
<h3>Enter file to add</h3>
</div>
{% csrf_token %}

<div class="modal-body">
<div style="display:flex; align-items:center; gap:12px;">
<label for="file-guid" style="margin:0; white-space:nowrap;">File guid:</label>
<input id="file-guid"
type="text"
name="file-guid"
class="form-control"
required
style="flex:1; min-width:0;">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger" name="action" value="ham" type="submit">Confirm</button>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nitpick: value="ham"?

<button type="button" class="btn btn-default" data-dismiss="modal">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
8 changes: 7 additions & 1 deletion admin/templates/nodes/node.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
<a href="{% url 'nodes:search' %}" class="btn btn-primary"> <i class="fa fa-search"></i></a>
<a href="{% url 'nodes:node-logs' guid=node.guid %}" class="btn btn-primary">View Logs</a>
{% include "nodes/remove_node.html" with node=node %}
{% include "nodes/remove_file.html" with node=node %}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nitpick: Can we delete this file? Is it used anywhere else?

Copy link
Copy Markdown
Contributor Author

@ihorsokhanexoft ihorsokhanexoft Apr 9, 2026

Choose a reason for hiding this comment

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

I believe I removed it accidentally because supposed I had to override this file removal workflow

{% include "nodes/registration_force_archive.html" with node=node %}
{% include "nodes/make_private.html" with node=node %}
{% include "nodes/make_public.html" with node=node %}
Expand All @@ -29,6 +28,13 @@
</div>
</div>
</div>
<div class="row">
<br>
<div class="col-md-12">
{% include "nodes/add_file_to_osfstorage.html" with node=node %}
{% include "nodes/remove_file_from_osfstorage.html" with node=node %}
</div>
</div>
<div class="row" style="overflow-x: auto; width: 100%;">
<h2>{{ node.type|cut:'osf.'|title }}: <b>{{ node.title }}</b> <a href="{{ node.absolute_url }}"> ({{node.guid}})</a> </h2>
<table class="table table-striped">
Expand Down
36 changes: 36 additions & 0 deletions admin/templates/nodes/remove_file_from_osfstorage.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{% if node.is_registration and node.archived %}
<a data-toggle="modal" data-target="#confirmRemoveFileModal" class="btn btn-danger">
Remove File (Osfstorage)
</a>
<div id="confirmRemoveFileModal" class="modal fade well" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<form class="well" method="post" action="{% url 'nodes:remove-osfstorage-file' guid=node.guid %}">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">x</button>
<h3>Enter file to remove</h3>
</div>
{% csrf_token %}

<div class="modal-body">
<div style="display:flex; align-items:center; gap:12px;">
<label for="file-guid" style="margin:0; white-space:nowrap;">File guid:</label>
<input id="file-guid"
type="text"
name="file-guid"
class="form-control"
required
style="flex:1; min-width:0;">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger" name="action" value="ham" type="submit">Confirm</button>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nitpick: value="ham"? Is that needed?

<button type="button" class="btn btn-default" data-dismiss="modal">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
140 changes: 140 additions & 0 deletions admin_tests/nodes/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType

from addons.osfstorage.models import OsfStorageFile
from osf.models import (
AdminLogEntry,
NodeLog,
Expand All @@ -21,9 +22,11 @@
DraftRegistration,
)
from admin.nodes.views import (
NodeAddOsfStorageFileView,
NodeConfirmSpamView,
NodeDeleteView,
NodeRemoveContributorView,
NodeRemoveOsfStorageFileView,
NodeView,
NodeReindexShare,
NodeReindexElastic,
Expand All @@ -40,6 +43,7 @@
)
from admin_tests.utilities import setup_log_view, setup_view, handle_post_view_request
from api_tests.share._utils import mock_update_share
from osf.models.files import Folder
from tests.utils import capture_notifications
from website import settings
from framework.auth.core import Auth
Expand Down Expand Up @@ -905,3 +909,139 @@ def test_embargo_is_reset_after_reversion(self):
self.registration = self.no_moderation_draft.registered_node

assert self.registration.sanction is None


class TestOsfStorageRegistrationFileAdd(AdminTestCase):

def _create_file(self, instance, filename):
return OsfStorageFile.create(
target_object_id=instance.id,
target_content_type=ContentType.objects.get_for_model(instance),
path=f'/{filename}',
name=filename,
materialized_path=f'/{filename}'
)

@property
def _view(self):
return NodeAddOsfStorageFileView()

def check_message(self, expected_message):
assert expected_message == self.request._messages._queued_messages[0].message

def setUp(self):
super().setUp()
self.project = ProjectFactory()
self.project2 = ProjectFactory()
self.registration_registered_from = RegistrationFactory(project=self.project)
self.registration_without_registered_from = RegistrationFactory()
self.registration_without_registered_from.registered_from = None
self.registration_without_registered_from.save()

self.request = RequestFactory().get('/fake_path')
patch_messages(self.request)

def test_no_guid_found(self):
self.request.POST = {'file-guid': '1234'}
view = setup_log_view(self._view, self.request, guid=self.registration_registered_from._id)
view.post(self.request)
self.check_message('No file found with the provided guid.')

def test_no_parent_registration(self):
file = self._create_file(self.project, 'file.txt')
file.save()
file_guid = file.get_guid(create=True)
self.request.POST = {'file-guid': file_guid._id}
view = setup_log_view(self._view, self.request, guid=self.registration_without_registered_from._id)
view.post(self.request)
self.check_message('The registration does not have the parent node.')

def test_file_is_not_attached_to_parent(self):
file = self._create_file(self.project2, 'file.txt')
file.save()
file_guid = file.get_guid(create=True)
self.request.POST = {'file-guid': file_guid._id}
view = setup_log_view(self._view, self.request, guid=self.registration_registered_from._id)
view.post(self.request)
self.check_message('The file with the provided guid is not part of the parent node.')

def test_file_is_added_to_registration_osfstorage(self):
file = self._create_file(self.project, 'file.txt')
file.save()
file_guid = file.get_guid(create=True)
self.request.POST = {'file-guid': file_guid._id}
registration_osfstorage = self.registration_registered_from.get_addon('osfstorage')
# create archive folder for a registration
registration_osfstorage.get_root()._create_child(name=registration_osfstorage.archive_folder_name, kind=Folder)
view = setup_log_view(self._view, self.request, guid=self.registration_registered_from._id)
view.post(self.request)

# check that file is added to registration osfstorage under archive folder
assert registration_osfstorage.get_root().children.get(
name=registration_osfstorage.archive_folder_name
).children.filter(name=file.name).exists()


class TestOsfStorageRegistrationFileRemove(AdminTestCase):

def _create_file(self, instance, filename):
return OsfStorageFile.create(
target_object_id=instance.id,
target_content_type=ContentType.objects.get_for_model(instance),
path=f'/{filename}',
name=filename,
materialized_path=f'/{filename}'
)

@property
def _view(self):
return NodeRemoveOsfStorageFileView()

def check_message(self, expected_message):
assert expected_message == self.request._messages._queued_messages[0].message

def setUp(self):
super().setUp()
self.project = ProjectFactory()
self.registration_registered_from = RegistrationFactory(project=self.project)

self.request = RequestFactory().get('/fake_path')
patch_messages(self.request)

def test_no_guid_found(self):
self.request.POST = {'file-guid': '1234'}
view = setup_log_view(self._view, self.request, guid=self.registration_registered_from._id)
view.post(self.request)
self.check_message('No file found with the provided guid.')

def test_file_is_removed_from_registration_osfstorage(self):
file = self._create_file(self.project, 'file2.txt')
file.save()
file_guid = file.get_guid(create=True)

# create archive folder for a registration
registration_osfstorage = self.registration_registered_from.get_addon('osfstorage')
registration_osfstorage.get_root()._create_child(name=registration_osfstorage.archive_folder_name, kind=Folder)

# add file to osfstorage
self.request.POST = {'file-guid': file_guid._id}
view = setup_log_view(NodeAddOsfStorageFileView(), self.request, guid=self.registration_registered_from._id)
view.post(self.request)

# file exists in archive folder
assert registration_osfstorage.get_root().children.get(
name=registration_osfstorage.archive_folder_name
).children.filter(name=file.name).exists()
# file exists but with different guid
registration_file = self.registration_registered_from.files.get(name=file.name)
registration_file.get_guid(create=True)

# delete this file with different guid
self.request.POST = {'file-guid': registration_file.guids.first()._id}
view = setup_log_view(NodeRemoveOsfStorageFileView(), self.request, guid=self.registration_registered_from._id)
view.post(self.request)
# check that file is removed from registration osfstorage
assert not registration_osfstorage.get_root().children.get(
name=registration_osfstorage.archive_folder_name
).children.exists()
assert not self.registration_registered_from.files.exists()
Loading