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
31 changes: 31 additions & 0 deletions admin/files/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import logging

from django.apps import apps
from django.conf import settings
from django.db import transaction

from framework.celery_tasks import app

logger = logging.getLogger(__name__)


@app.task(max_retries=5, default_retry_delay=60)
def purge_file_version_task(version_pk):

from google.cloud.storage.client import Client
from google.oauth2.service_account import Credentials

FileVersion = apps.get_model('osf.FileVersion')
with transaction.atomic():
version = FileVersion.objects.filter(pk=version_pk).first()
if not version or version.purged:
return 0

creds_path = getattr(settings, 'GCS_CREDS', None)
if not creds_path:
logger.error(f'GCS_CREDS not configured; cannot purge FileVersion {version_pk}')
return 0

creds = Credentials.from_service_account_file(creds_path)
client = Client(credentials=creds)
return version._purge(client=client)
4 changes: 3 additions & 1 deletion admin/files/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@

urlpatterns = [
re_path(r'^$', views.FileSearchView.as_view(), name='search'),
re_path(r'^(?P<guid>\w+)/$', views.FileView.as_view(), name='file')
re_path(r'^(?P<guid>\w+)/$', views.FileView.as_view(), name='file'),
re_path(r'^(?P<guid>\w+)/delete/$', views.FileDeleteView.as_view(), name='file-delete'),
re_path(r'^(?P<guid>\w+)/versions/(?P<version_id>[\w-]+)/delete/$', views.FileVersionDeleteView.as_view(), name='file-version-delete'),
]
134 changes: 127 additions & 7 deletions admin/files/views.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
from django.urls import NoReverseMatch
from django.contrib import messages
from django.db import transaction
from django.db.models import F
from django.shortcuts import redirect
from django.views.generic import FormView
from django.urls import reverse_lazy
from admin.base.forms import GuidForm
from django.urls import NoReverseMatch, reverse, reverse_lazy
from django.utils import timezone
from django.views.generic import FormView, View
from django.contrib.auth.mixins import PermissionRequiredMixin

from admin.base.forms import GuidForm
from admin.base.views import GuidView
from osf.models import Guid, GuidMetadataRecord, BaseFileNode
from admin.files.tasks import purge_file_version_task
from framework.postcommit_tasks.handlers import enqueue_postcommit_task
from osf.models import Guid, GuidMetadataRecord, BaseFileNode, NodeLog
from osf.models.admin_log_entry import (
update_admin_log,
FILE_REMOVED,
FILE_VERSION_REMOVED,
)
from osf.models.files import File, FileVersion, TrashedFile, TrashedFolder
from website.files.exceptions import FileNodeCheckedOutError, FileNodeIsPrimaryFile


class FileSearchView(PermissionRequiredMixin, FormView):
Expand Down Expand Up @@ -52,12 +64,120 @@ def get_context_data(self, **kwargs):
)
file = context['object']
node = file.target
latest_modified = file.versions.latest('created')
is_trashed = isinstance(file, (TrashedFile, TrashedFolder))
# Annotate version_id because django templates prohibit accessing attributes that start with underscores
versions = file.versions.all().order_by('-created').annotate(version_id=F('_id')) if isinstance(file, File) else FileVersion.objects.none()

selected_version_id = self.request.GET.get('version')
selected_version = versions.filter(version_id=selected_version_id).first() if selected_version_id else None
if selected_version is None:
selected_version = versions.first()

context.update({
'guid': guid,
'node_id': node._id if node else None,
'node': node,
'file_metadata': metadata_record,
'version': latest_modified.location.get('version', '')
'version': selected_version.location.get('version', '') if selected_version else '',
'versions': versions,
'selected_version': selected_version,
'is_trashed': is_trashed,
})
return context


class FileDeleteView(FileMixin, View):
""" Allows authorized users to delete a file or folder (soft delete / trash).
"""
permission_required = 'osf.delete_basefilenode'
raise_exception = True

def post(self, request, *args, **kwargs):
file = self.get_object()
if isinstance(file, (TrashedFile, TrashedFolder)):
messages.error(request, 'This file has already been deleted.')
return redirect(self.get_success_url())

node = file.target
file_path = getattr(file, 'materialized_path', None) or getattr(file, 'path', None) or ''
guid = self.kwargs['guid']

try:
with transaction.atomic():
file.delete(user=request.user)
if node is not None and hasattr(node, 'add_log'):
params = dict(getattr(node, 'log_params', {}))
params.update({
'pathType': 'file',
'path': file_path,
})
node.add_log(
action=NodeLog.FILE_REMOVED,
auth=None,
foreign_user=NodeLog.SUPPORT_USER_LABEL,
params=params,
log_date=timezone.now(),
should_hide=False,
)
except FileNodeCheckedOutError:
messages.error(request, 'This file is checked out and cannot be deleted until it is checked in.')
return redirect(self.get_success_url())
except FileNodeIsPrimaryFile:
messages.error(request, 'This file is the primary file of a preprint and cannot be deleted.')
return redirect(self.get_success_url())

update_admin_log(
user_id=request.user.id,
object_id=guid,
object_repr='BaseFileNode',
message=f'File {guid} deleted by admin.',
action_flag=FILE_REMOVED,
)
messages.success(request, 'File deleted.')
return redirect(reverse('home'))


class FileVersionDeleteView(FileMixin, View):
""" Allows authorized users to delete a single version of a file, unlinking
it from the file and enqueueing a task to purge the underlying storage blob.
"""
permission_required = 'osf.delete_fileversion'
raise_exception = True

def post(self, request, *args, **kwargs):
file = self.get_object()
guid = self.kwargs['guid']

if not isinstance(file, File):
messages.error(request, 'Only individual files have versions.')
return redirect(self.get_success_url())

version_id = self.kwargs.get('version_id')
version = FileVersion.load(version_id)
if version is None:
messages.error(request, 'Version not found.')
return redirect(self.get_success_url())

through = version.get_basefilenode_version(file)
if through is None:
messages.error(request, 'This version does not belong to this file.')
return redirect(self.get_success_url())

if file.versions.count() <= 1:
messages.error(request, 'Cannot delete the only version of a file. Delete the whole file instead.')
return redirect(self.get_success_url())

with transaction.atomic():
through.delete()

enqueue_postcommit_task(purge_file_version_task, (version.pk,), {}, celery=True)

update_admin_log(
user_id=request.user.id,
object_id=guid,
object_repr='FileVersion',
message=f'Version {version_id} of file {guid} unlinked by admin; GCS purge enqueued.',
action_flag=FILE_VERSION_REMOVED,
)
messages.success(request, 'File version deleted.')
return redirect(self.get_success_url())
3 changes: 3 additions & 0 deletions admin/nodes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ def get_context_data(self, **kwargs):
children = AbstractNode.objects.filter(
id__in=[child.id for child in children]
).prefetch_related('guids').annotate(guid=F('guids___id'))
node_files = node.files.filter(deleted__isnull=True).prefetch_related('guids').annotate(
guid=F('guids___id')).order_by('name')[:200]
context.update({
'SPAM_STATUS': SpamStatus,
'STORAGE_LIMITS': settings.StorageLimits,
Expand All @@ -156,6 +158,7 @@ def get_context_data(self, **kwargs):
'annotated_contributors': node.contributor_set.prefetch_related('user__guids').annotate(
guid=F('user__guids___id')),
'children': children,
'node_files': node_files,
'permissions': API_CONTRIBUTOR_PERMISSIONS,
'has_update_permission': self.request.user.has_perm('osf.change_node'),
})
Expand Down
90 changes: 89 additions & 1 deletion admin/templates/files/file.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,64 @@ <h2>File: <b>{{ object.name }}</b></h2>
</tr>
<tr>
<td>Version</td>
<td>{{ version }}</td>
<td>
{% if versions %}
<form method="get" action="{% url 'files:file' guid=guid %}" class="form-inline">
<select name="version" class="form-control input-sm" onchange="this.form.submit()">
{% for file_version in versions %}
<option value="{{ file_version.version_id }}" {% if selected_version.version_id == file_version.version_id %}selected{% endif %}>
{{ file_version.version_id }} &mdash; {{ file_version.created | date:'SHORT_DATETIME_FORMAT' }}
</option>
{% endfor %}
</select>
</form>
{% if selected_version %}
<p class="text-muted" style="margin-top: 5px;">
{{ version }} &middot; uploaded {{ selected_version.created | date:'SHORT_DATETIME_FORMAT' }}
by {{ selected_version.creator }} &middot; {{ selected_version.size }} bytes
</p>
{% endif %}
{% if perms.osf.delete_fileversion and not is_trashed %}
{% if versions|length > 1 and selected_version %}
<a data-toggle="modal" data-target="#deleteVersionModal" class="btn btn-danger btn-xs">
Delete Version
</a>
<div class="modal" id="deleteVersionModal">
<div class="modal-dialog">
<div class="modal-content">
<form method="post"
action="{% url 'files:file-version-delete' guid=guid version_id=selected_version.version_id %}">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">x</button>
<h3>Are you sure you want to delete this version?</h3>
</div>
<div class="modal-body">
This will permanently remove version
<b>{{ selected_version.version_id }}</b> and its stored content.
This action is not reversible.
{% csrf_token %}
</div>
<div class="modal-footer">
<input class="btn btn-danger" type="submit" value="Delete Version" />
<button type="button" class="btn btn-default" data-dismiss="modal">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
{% else %}
<button type="button" class="btn btn-danger btn-xs" disabled
title="Deleting the only version will delete the entire file. Use the Delete File button instead.">
Delete Version
</button>
{% endif %}
{% endif %}
{% else %}
{{ version }}
{% endif %}
</td>
</tr>
{% if node_id %}
<tr>
Expand Down Expand Up @@ -67,6 +124,37 @@ <h2>File: <b>{{ object.name }}</b></h2>
</tr>
</tbody>
</table>

{% if perms.osf.delete_basefilenode and not is_trashed %}
<a data-toggle="modal" data-target="#deleteFileModal" class="btn btn-danger">
Delete File
</a>
<div class="modal" id="deleteFileModal">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'files:file-delete' guid=guid %}">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">x</button>
<h3>Are you sure you want to delete this file?</h3>
</div>
<div class="modal-body">
This will delete <b>{{ object.name }}</b> and, if it is a folder, all of its
contents. This action is not reversible through this page.
{% csrf_token %}
</div>
<div class="modal-footer">
<input class="btn btn-danger" type="submit" value="Delete File" />
<button type="button" class="btn btn-default" data-dismiss="modal">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
{% elif is_trashed %}
<p><i>This file has already been deleted.</i></p>
{% endif %}
</div>
</div>
{% endblock content %}
35 changes: 35 additions & 0 deletions admin/templates/nodes/file_list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<tr>
<td colspan="2">
<h3>Files</h3>
{% if node_files %}
<table class="table table-striped table-hover table-responsive">
<thead>
<tr>
<th>Name</th>
<th>Provider</th>
<th>Guid</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for file in node_files %}
<tr>
<td>{{ file.name }}</td>
<td>{{ file.provider }}</td>
<td>{{ file.guid }}</td>
<td>
{% if file.guid %}
<a href="{% url 'files:file' guid=file.guid %}" class="btn btn-primary btn-xs">
Manage
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No files found.</p>
{% endif %}
</td>
</tr>
1 change: 1 addition & 0 deletions admin/templates/nodes/node.html
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ <h2>{{ node.type|cut:'osf.'|title }}: <b>{{ node.title }}</b> <a href="{{ node.a
{% include "nodes/registration_approval.html" with registration_approval=node.registration_approval %}
{% include "nodes/actions.html" with actions=node.actions.all|order_by:"created" %}
{% include "nodes/storage_usage.html" with node=node %}
{% include "nodes/file_list.html" with node_files=node_files %}

<tr>
<td colspan="2">
Expand Down
Empty file added admin_tests/files/__init__.py
Empty file.
Loading