Skip to content

Commit 0c88bed

Browse files
committed
Add version picker and delete controls to admin file view
1 parent 40dac6c commit 0c88bed

11 files changed

Lines changed: 592 additions & 9 deletions

File tree

admin/files/tasks.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import logging
2+
3+
from django.apps import apps
4+
from django.conf import settings
5+
from django.db import transaction
6+
7+
from framework.celery_tasks import app
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
@app.task(max_retries=5, default_retry_delay=60)
13+
def purge_file_version_task(version_pk):
14+
15+
from google.cloud.storage.client import Client
16+
from google.oauth2.service_account import Credentials
17+
18+
FileVersion = apps.get_model('osf.FileVersion')
19+
with transaction.atomic():
20+
version = FileVersion.objects.filter(pk=version_pk).first()
21+
if not version or version.purged:
22+
return 0
23+
24+
creds_path = getattr(settings, 'GCS_CREDS', None)
25+
if not creds_path:
26+
logger.error(f'GCS_CREDS not configured; cannot purge FileVersion {version_pk}')
27+
return 0
28+
29+
creds = Credentials.from_service_account_file(creds_path)
30+
client = Client(credentials=creds)
31+
return version._purge(client=client)

admin/files/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@
55

66
urlpatterns = [
77
re_path(r'^$', views.FileSearchView.as_view(), name='search'),
8-
re_path(r'^(?P<guid>\w+)/$', views.FileView.as_view(), name='file')
8+
re_path(r'^(?P<guid>\w+)/$', views.FileView.as_view(), name='file'),
9+
re_path(r'^(?P<guid>\w+)/delete/$', views.FileDeleteView.as_view(), name='file-delete'),
10+
re_path(r'^(?P<guid>\w+)/versions/(?P<version_id>[\w-]+)/delete/$', views.FileVersionDeleteView.as_view(), name='file-version-delete'),
911
]

admin/files/views.py

Lines changed: 127 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
1-
from django.urls import NoReverseMatch
21
from django.contrib import messages
2+
from django.db import transaction
3+
from django.db.models import F
34
from django.shortcuts import redirect
4-
from django.views.generic import FormView
5-
from django.urls import reverse_lazy
6-
from admin.base.forms import GuidForm
5+
from django.urls import NoReverseMatch, reverse, reverse_lazy
6+
from django.utils import timezone
7+
from django.views.generic import FormView, View
78
from django.contrib.auth.mixins import PermissionRequiredMixin
9+
10+
from admin.base.forms import GuidForm
811
from admin.base.views import GuidView
9-
from osf.models import Guid, GuidMetadataRecord, BaseFileNode
12+
from admin.files.tasks import purge_file_version_task
13+
from framework.postcommit_tasks.handlers import enqueue_postcommit_task
14+
from osf.models import Guid, GuidMetadataRecord, BaseFileNode, NodeLog
15+
from osf.models.admin_log_entry import (
16+
update_admin_log,
17+
FILE_REMOVED,
18+
FILE_VERSION_REMOVED,
19+
)
20+
from osf.models.files import File, FileVersion, TrashedFile, TrashedFolder
21+
from website.files.exceptions import FileNodeCheckedOutError, FileNodeIsPrimaryFile
1022

1123

1224
class FileSearchView(PermissionRequiredMixin, FormView):
@@ -52,12 +64,120 @@ def get_context_data(self, **kwargs):
5264
)
5365
file = context['object']
5466
node = file.target
55-
latest_modified = file.versions.latest('created')
67+
is_trashed = isinstance(file, (TrashedFile, TrashedFolder))
68+
# Annotate version_id because django templates prohibit accessing attributes that start with underscores
69+
versions = file.versions.all().order_by('-created').annotate(version_id=F('_id')) if isinstance(file, File) else FileVersion.objects.none()
70+
71+
selected_version_id = self.request.GET.get('version')
72+
selected_version = versions.filter(version_id=selected_version_id).first() if selected_version_id else None
73+
if selected_version is None:
74+
selected_version = versions.first()
75+
5676
context.update({
5777
'guid': guid,
5878
'node_id': node._id if node else None,
5979
'node': node,
6080
'file_metadata': metadata_record,
61-
'version': latest_modified.location.get('version', '')
81+
'version': selected_version.location.get('version', '') if selected_version else '',
82+
'versions': versions,
83+
'selected_version': selected_version,
84+
'is_trashed': is_trashed,
6285
})
6386
return context
87+
88+
89+
class FileDeleteView(FileMixin, View):
90+
""" Allows authorized users to delete a file or folder (soft delete / trash).
91+
"""
92+
permission_required = 'osf.delete_basefilenode'
93+
raise_exception = True
94+
95+
def post(self, request, *args, **kwargs):
96+
file = self.get_object()
97+
if isinstance(file, (TrashedFile, TrashedFolder)):
98+
messages.error(request, 'This file has already been deleted.')
99+
return redirect(self.get_success_url())
100+
101+
node = file.target
102+
file_path = getattr(file, 'materialized_path', None) or getattr(file, 'path', None) or ''
103+
guid = self.kwargs['guid']
104+
105+
try:
106+
with transaction.atomic():
107+
file.delete(user=request.user)
108+
if node is not None and hasattr(node, 'add_log'):
109+
params = dict(getattr(node, 'log_params', {}))
110+
params.update({
111+
'pathType': 'file',
112+
'path': file_path,
113+
})
114+
node.add_log(
115+
action=NodeLog.FILE_REMOVED,
116+
auth=None,
117+
foreign_user=NodeLog.SUPPORT_USER_LABEL,
118+
params=params,
119+
log_date=timezone.now(),
120+
should_hide=False,
121+
)
122+
except FileNodeCheckedOutError:
123+
messages.error(request, 'This file is checked out and cannot be deleted until it is checked in.')
124+
return redirect(self.get_success_url())
125+
except FileNodeIsPrimaryFile:
126+
messages.error(request, 'This file is the primary file of a preprint and cannot be deleted.')
127+
return redirect(self.get_success_url())
128+
129+
update_admin_log(
130+
user_id=request.user.id,
131+
object_id=guid,
132+
object_repr='BaseFileNode',
133+
message=f'File {guid} deleted by admin.',
134+
action_flag=FILE_REMOVED,
135+
)
136+
messages.success(request, 'File deleted.')
137+
return redirect(reverse('home'))
138+
139+
140+
class FileVersionDeleteView(FileMixin, View):
141+
""" Allows authorized users to delete a single version of a file, unlinking
142+
it from the file and enqueueing a task to purge the underlying storage blob.
143+
"""
144+
permission_required = 'osf.delete_fileversion'
145+
raise_exception = True
146+
147+
def post(self, request, *args, **kwargs):
148+
file = self.get_object()
149+
guid = self.kwargs['guid']
150+
151+
if not isinstance(file, File):
152+
messages.error(request, 'Only individual files have versions.')
153+
return redirect(self.get_success_url())
154+
155+
version_id = self.kwargs.get('version_id')
156+
version = FileVersion.load(version_id)
157+
if version is None:
158+
messages.error(request, 'Version not found.')
159+
return redirect(self.get_success_url())
160+
161+
through = version.get_basefilenode_version(file)
162+
if through is None:
163+
messages.error(request, 'This version does not belong to this file.')
164+
return redirect(self.get_success_url())
165+
166+
if file.versions.count() <= 1:
167+
messages.error(request, 'Cannot delete the only version of a file. Delete the whole file instead.')
168+
return redirect(self.get_success_url())
169+
170+
with transaction.atomic():
171+
through.delete()
172+
173+
enqueue_postcommit_task(purge_file_version_task, (version.pk,), {}, celery=True)
174+
175+
update_admin_log(
176+
user_id=request.user.id,
177+
object_id=guid,
178+
object_repr='FileVersion',
179+
message=f'Version {version_id} of file {guid} unlinked by admin; GCS purge enqueued.',
180+
action_flag=FILE_VERSION_REMOVED,
181+
)
182+
messages.success(request, 'File version deleted.')
183+
return redirect(self.get_success_url())

admin/nodes/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ def get_context_data(self, **kwargs):
148148
children = AbstractNode.objects.filter(
149149
id__in=[child.id for child in children]
150150
).prefetch_related('guids').annotate(guid=F('guids___id'))
151+
node_files = node.files.filter(deleted__isnull=True).prefetch_related('guids').annotate(
152+
guid=F('guids___id')).order_by('name')[:200]
151153
context.update({
152154
'SPAM_STATUS': SpamStatus,
153155
'STORAGE_LIMITS': settings.StorageLimits,
@@ -156,6 +158,7 @@ def get_context_data(self, **kwargs):
156158
'annotated_contributors': node.contributor_set.prefetch_related('user__guids').annotate(
157159
guid=F('user__guids___id')),
158160
'children': children,
161+
'node_files': node_files,
159162
'permissions': API_CONTRIBUTOR_PERMISSIONS,
160163
'has_update_permission': self.request.user.has_perm('osf.change_node'),
161164
})

admin/templates/files/file.html

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,64 @@ <h2>File: <b>{{ object.name }}</b></h2>
3030
</tr>
3131
<tr>
3232
<td>Version</td>
33-
<td>{{ version }}</td>
33+
<td>
34+
{% if versions %}
35+
<form method="get" action="{% url 'files:file' guid=guid %}" class="form-inline">
36+
<select name="version" class="form-control input-sm" onchange="this.form.submit()">
37+
{% for file_version in versions %}
38+
<option value="{{ file_version.version_id }}" {% if selected_version.version_id == file_version.version_id %}selected{% endif %}>
39+
{{ file_version.version_id }} &mdash; {{ file_version.created | date:'SHORT_DATETIME_FORMAT' }}
40+
</option>
41+
{% endfor %}
42+
</select>
43+
</form>
44+
{% if selected_version %}
45+
<p class="text-muted" style="margin-top: 5px;">
46+
{{ version }} &middot; uploaded {{ selected_version.created | date:'SHORT_DATETIME_FORMAT' }}
47+
by {{ selected_version.creator }} &middot; {{ selected_version.size }} bytes
48+
</p>
49+
{% endif %}
50+
{% if perms.osf.delete_fileversion and not is_trashed %}
51+
{% if versions|length > 1 and selected_version %}
52+
<a data-toggle="modal" data-target="#deleteVersionModal" class="btn btn-danger btn-xs">
53+
Delete Version
54+
</a>
55+
<div class="modal" id="deleteVersionModal">
56+
<div class="modal-dialog">
57+
<div class="modal-content">
58+
<form method="post"
59+
action="{% url 'files:file-version-delete' guid=guid version_id=selected_version.version_id %}">
60+
<div class="modal-header">
61+
<button type="button" class="close" data-dismiss="modal">x</button>
62+
<h3>Are you sure you want to delete this version?</h3>
63+
</div>
64+
<div class="modal-body">
65+
This will permanently remove version
66+
<b>{{ selected_version.version_id }}</b> and its stored content.
67+
This action is not reversible.
68+
{% csrf_token %}
69+
</div>
70+
<div class="modal-footer">
71+
<input class="btn btn-danger" type="submit" value="Delete Version" />
72+
<button type="button" class="btn btn-default" data-dismiss="modal">
73+
Cancel
74+
</button>
75+
</div>
76+
</form>
77+
</div>
78+
</div>
79+
</div>
80+
{% else %}
81+
<button type="button" class="btn btn-danger btn-xs" disabled
82+
title="Deleting the only version will delete the entire file. Use the Delete File button instead.">
83+
Delete Version
84+
</button>
85+
{% endif %}
86+
{% endif %}
87+
{% else %}
88+
{{ version }}
89+
{% endif %}
90+
</td>
3491
</tr>
3592
{% if node_id %}
3693
<tr>
@@ -67,6 +124,37 @@ <h2>File: <b>{{ object.name }}</b></h2>
67124
</tr>
68125
</tbody>
69126
</table>
127+
128+
{% if perms.osf.delete_basefilenode and not is_trashed %}
129+
<a data-toggle="modal" data-target="#deleteFileModal" class="btn btn-danger">
130+
Delete File
131+
</a>
132+
<div class="modal" id="deleteFileModal">
133+
<div class="modal-dialog">
134+
<div class="modal-content">
135+
<form method="post" action="{% url 'files:file-delete' guid=guid %}">
136+
<div class="modal-header">
137+
<button type="button" class="close" data-dismiss="modal">x</button>
138+
<h3>Are you sure you want to delete this file?</h3>
139+
</div>
140+
<div class="modal-body">
141+
This will delete <b>{{ object.name }}</b> and, if it is a folder, all of its
142+
contents. This action is not reversible through this page.
143+
{% csrf_token %}
144+
</div>
145+
<div class="modal-footer">
146+
<input class="btn btn-danger" type="submit" value="Delete File" />
147+
<button type="button" class="btn btn-default" data-dismiss="modal">
148+
Cancel
149+
</button>
150+
</div>
151+
</form>
152+
</div>
153+
</div>
154+
</div>
155+
{% elif is_trashed %}
156+
<p><i>This file has already been deleted.</i></p>
157+
{% endif %}
70158
</div>
71159
</div>
72160
{% endblock content %}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<tr>
2+
<td colspan="2">
3+
<h3>Files</h3>
4+
{% if node_files %}
5+
<table class="table table-striped table-hover table-responsive">
6+
<thead>
7+
<tr>
8+
<th>Name</th>
9+
<th>Provider</th>
10+
<th>Guid</th>
11+
<th>Actions</th>
12+
</tr>
13+
</thead>
14+
<tbody>
15+
{% for file in node_files %}
16+
<tr>
17+
<td>{{ file.name }}</td>
18+
<td>{{ file.provider }}</td>
19+
<td>{{ file.guid }}</td>
20+
<td>
21+
{% if file.guid %}
22+
<a href="{% url 'files:file' guid=file.guid %}" class="btn btn-primary btn-xs">
23+
Manage
24+
</a>
25+
{% endif %}
26+
</td>
27+
</tr>
28+
{% endfor %}
29+
</tbody>
30+
</table>
31+
{% else %}
32+
<p>No files found.</p>
33+
{% endif %}
34+
</td>
35+
</tr>

admin/templates/nodes/node.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ <h2>{{ node.type|cut:'osf.'|title }}: <b>{{ node.title }}</b> <a href="{{ node.a
140140
{% include "nodes/registration_approval.html" with registration_approval=node.registration_approval %}
141141
{% include "nodes/actions.html" with actions=node.actions.all|order_by:"created" %}
142142
{% include "nodes/storage_usage.html" with node=node %}
143+
{% include "nodes/file_list.html" with node_files=node_files %}
143144

144145
<tr>
145146
<td colspan="2">

0 commit comments

Comments
 (0)