support deleting extensions #69
@ -166,13 +166,13 @@ class SoftDeleteMixin(models.Model):
|
|||||||
if hard:
|
if hard:
|
||||||
super().delete()
|
super().delete()
|
||||||
else:
|
else:
|
||||||
|
if hasattr(self, 'file'):
|
||||||
|
# TODO(oleg) move to an archived directory, add random suffix
|
||||||
|
self.file.delete()
|
||||||
self.date_deleted = timezone.now()
|
self.date_deleted = timezone.now()
|
||||||
self.save()
|
self.save()
|
||||||
if hasattr(self, 'file'):
|
|
||||||
# .file should always exist but we don't want to break delete regardless
|
|
||||||
self.file.delete()
|
|
||||||
|
|
||||||
logger.warning('%r pk=%r deleted', self.__class__, self.pk)
|
logger.info('%r pk=%r deleted', self.__class__, self.pk)
|
||||||
|
|
||||||
def delete_queryset(self, request, queryset):
|
def delete_queryset(self, request, queryset):
|
||||||
"""Given a queryset, soft-delete it from the database."""
|
"""Given a queryset, soft-delete it from the database."""
|
||||||
@ -186,4 +186,4 @@ class SoftDeleteMixin(models.Model):
|
|||||||
if save:
|
if save:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
logger.warning('%r pk=%r deleted', self.__class__, self.pk)
|
logger.info('%r pk=%r undeleted', self.__class__, self.pk)
|
||||||
|
@ -131,6 +131,12 @@ class ExtensionUpdateForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionDeleteForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = extensions.models.Extension
|
||||||
|
fields = []
|
||||||
|
|
||||||
|
|
||||||
class VersionForm(forms.ModelForm):
|
class VersionForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = extensions.models.Version
|
model = extensions.models.Version
|
||||||
@ -151,7 +157,7 @@ class VersionForm(forms.ModelForm):
|
|||||||
return self.initial['file']
|
return self.initial['file']
|
||||||
|
|
||||||
|
|
||||||
class DeleteViewForm(forms.ModelForm):
|
class VersionDeleteForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = extensions.models.Version
|
model = extensions.models.Version
|
||||||
fields = []
|
fields = []
|
||||||
|
@ -234,6 +234,13 @@ class Extension(
|
|||||||
self.status = self.STATUSES.APPROVED
|
self.status = self.STATUSES.APPROVED
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def delete(self, hard=False):
|
||||||
|
versions = self.versions.filter(date_deleted__isnull=True)
|
||||||
|
for v in versions:
|
||||||
|
v.delete(hard=hard)
|
||||||
|
super().delete(hard=hard)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('extensions:detail', args=[self.type_slug, self.slug])
|
return reverse('extensions:detail', args=[self.type_slug, self.slug])
|
||||||
|
|
||||||
@ -246,6 +253,9 @@ class Extension(
|
|||||||
def get_manage_versions_url(self):
|
def get_manage_versions_url(self):
|
||||||
return reverse('extensions:manage-versions', args=[self.type_slug, self.slug])
|
return reverse('extensions:manage-versions', args=[self.type_slug, self.slug])
|
||||||
|
|
||||||
|
def get_delete_url(self):
|
||||||
|
return reverse('extensions:delete', args=[self.type_slug, self.slug])
|
||||||
|
|
||||||
def get_new_version_url(self):
|
def get_new_version_url(self):
|
||||||
return reverse('extensions:new-version', args=[self.type_slug, self.slug])
|
return reverse('extensions:new-version', args=[self.type_slug, self.slug])
|
||||||
|
|
||||||
|
32
extensions/templates/extensions/confirm_delete.html
Normal file
32
extensions/templates/extensions/confirm_delete.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{% extends "common/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-8 mx-auto my-4">
|
||||||
|
<div class="box">
|
||||||
|
<h2>
|
||||||
|
{% blocktranslate with extension_name=extension_name %} Delete {{ extension_name }}?{% endblocktranslate %}
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
{% blocktranslate with extension_name=extension_name %}
|
||||||
|
By deleting <strong>{{ extension_name }}</strong> you will lose the files,
|
||||||
|
download count, reviews as well ratings for the whole extension.
|
||||||
|
{% endblocktranslate %}
|
||||||
|
</p>
|
||||||
|
<div class="btn-row-fluid">
|
||||||
|
<a href="#" class="btn js-btn-back">
|
||||||
|
<i class="i-cancel"></i>
|
||||||
|
<span>{% trans 'Cancel' %}</span>
|
||||||
|
</a>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-block btn-danger">
|
||||||
|
<i class="i-trash"></i>
|
||||||
|
<span>{% trans 'Confirm Deletion' %}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
@ -115,6 +115,11 @@
|
|||||||
<span>{% trans 'Version History' %}</span>
|
<span>{% trans 'Version History' %}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="{{ extension.get_delete_url }}" class="btn btn-danger">
|
||||||
|
<i class="i-trash"></i>
|
||||||
|
<span>{% trans 'Delete Extension' %}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
{% if request.user.is_staff %}
|
{% if request.user.is_staff %}
|
||||||
<a href="{% url 'admin:extensions_extension_change' extension.pk %}" class="btn btn-admin">
|
<a href="{% url 'admin:extensions_extension_change' extension.pk %}" class="btn btn-admin">
|
||||||
<span>Admin</span>
|
<span>Admin</span>
|
||||||
|
34
extensions/tests/test_delete.py
Normal file
34
extensions/tests/test_delete.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from common.tests.factories.extensions import create_approved_version
|
||||||
|
from common.tests.factories.users import UserFactory
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteTest(TestCase):
|
||||||
|
fixtures = ['dev', 'licenses']
|
||||||
|
|
||||||
|
def test_happy_path(self):
|
||||||
|
extension = create_approved_version().extension
|
||||||
|
|
||||||
|
url = extension.get_delete_url()
|
||||||
|
user = extension.authors.first()
|
||||||
|
self.client.force_login(user)
|
||||||
|
response = self.client.post(url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
extension.refresh_from_db()
|
||||||
|
self.assertIsNotNone(extension.date_deleted)
|
||||||
|
self.assertTrue(all(v.date_deleted is not None for v in extension.versions.all()))
|
||||||
|
|
||||||
|
def test_random_user_cant_delete(self):
|
||||||
|
extension = create_approved_version().extension
|
||||||
|
|
||||||
|
url = extension.get_delete_url()
|
||||||
|
user = UserFactory()
|
||||||
|
self.client.force_login(user)
|
||||||
|
response = self.client.post(url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
extension.refresh_from_db()
|
||||||
|
self.assertIsNone(extension.date_deleted)
|
||||||
|
self.assertTrue(all(v.date_deleted is None for v in extension.versions.all()))
|
@ -44,6 +44,11 @@ urlpatterns = [
|
|||||||
manage.UpdateExtensionView.as_view(),
|
manage.UpdateExtensionView.as_view(),
|
||||||
name='manage',
|
name='manage',
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
'<slug:slug>/delete/',
|
||||||
|
manage.DeleteExtensionView.as_view(),
|
||||||
|
name='delete',
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
'<slug:slug>/manage/versions/',
|
'<slug:slug>/manage/versions/',
|
||||||
manage.ManageVersionsView.as_view(),
|
manage.ManageVersionsView.as_view(),
|
||||||
|
@ -18,9 +18,10 @@ from .mixins import (
|
|||||||
from extensions.forms import (
|
from extensions.forms import (
|
||||||
EditPreviewFormSet,
|
EditPreviewFormSet,
|
||||||
AddPreviewFormSet,
|
AddPreviewFormSet,
|
||||||
|
ExtensionDeleteForm,
|
||||||
ExtensionUpdateForm,
|
ExtensionUpdateForm,
|
||||||
VersionForm,
|
VersionForm,
|
||||||
DeleteViewForm,
|
VersionDeleteForm,
|
||||||
)
|
)
|
||||||
from extensions.models import Extension, Version
|
from extensions.models import Extension, Version
|
||||||
from files.forms import FileForm
|
from files.forms import FileForm
|
||||||
@ -159,6 +160,29 @@ class UpdateExtensionView(
|
|||||||
return self.form_invalid(form, edit_preview_formset, add_preview_formset)
|
return self.form_invalid(form, edit_preview_formset, add_preview_formset)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteExtensionView(
|
||||||
|
LoginRequiredMixin,
|
||||||
|
UserPassesTestMixin,
|
||||||
|
DeleteView,
|
||||||
|
):
|
||||||
|
model = Extension
|
||||||
|
template_name = 'extensions/confirm_delete.html'
|
||||||
|
form_class = ExtensionDeleteForm
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('extensions:manage-list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['extension_name'] = self.object.name
|
||||||
|
context['confirm_url'] = self.object.get_delete_url()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def test_func(self) -> bool:
|
||||||
|
# Only maintainers allowed
|
||||||
|
return self.get_object().has_maintainer(self.request.user)
|
||||||
|
|
||||||
|
|
||||||
class VersionDeleteView(
|
class VersionDeleteView(
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
MaintainedExtensionMixin,
|
MaintainedExtensionMixin,
|
||||||
@ -166,7 +190,7 @@ class VersionDeleteView(
|
|||||||
):
|
):
|
||||||
model = Version
|
model = Version
|
||||||
template_name = 'extensions/version_confirm_delete.html'
|
template_name = 'extensions/version_confirm_delete.html'
|
||||||
form_class = DeleteViewForm
|
form_class = VersionDeleteForm
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return reverse(
|
return reverse(
|
||||||
|
@ -2,6 +2,7 @@ import logging
|
|||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.views.generic.list import ListView
|
from django.views.generic.list import ListView
|
||||||
|
|
||||||
@ -53,6 +54,8 @@ class HomeView(ListedExtensionsView):
|
|||||||
def extension_version_download(request, type_slug, slug, version):
|
def extension_version_download(request, type_slug, slug, version):
|
||||||
"""Download an extension version and count downloads."""
|
"""Download an extension version and count downloads."""
|
||||||
extension_version = get_object_or_404(Version, extension__slug=slug, version=version)
|
extension_version = get_object_or_404(Version, extension__slug=slug, version=version)
|
||||||
|
if extension_version.date_deleted is not None:
|
||||||
|
raise Http404("This extension version has been deleted")
|
||||||
ExtensionDownload.create_from_request(request, object_id=extension_version.extension_id)
|
ExtensionDownload.create_from_request(request, object_id=extension_version.extension_id)
|
||||||
VersionDownload.create_from_request(request, object_id=extension_version.pk)
|
VersionDownload.create_from_request(request, object_id=extension_version.pk)
|
||||||
return redirect(extension_version.downloadable_signed_url)
|
return redirect(extension_version.downloadable_signed_url)
|
||||||
|
Loading…
Reference in New Issue
Block a user