diff --git a/common/model_mixins.py b/common/model_mixins.py
index 398d9f92..9d74d8c6 100644
--- a/common/model_mixins.py
+++ b/common/model_mixins.py
@@ -166,13 +166,13 @@ class SoftDeleteMixin(models.Model):
if hard:
super().delete()
else:
+ if hasattr(self, 'file'):
+ # TODO(oleg) move to an archived directory, add random suffix
+ self.file.delete()
self.date_deleted = timezone.now()
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):
"""Given a queryset, soft-delete it from the database."""
@@ -186,4 +186,4 @@ class SoftDeleteMixin(models.Model):
if save:
self.save()
- logger.warning('%r pk=%r deleted', self.__class__, self.pk)
+ logger.info('%r pk=%r undeleted', self.__class__, self.pk)
diff --git a/extensions/forms.py b/extensions/forms.py
index 322472d0..04dd3222 100644
--- a/extensions/forms.py
+++ b/extensions/forms.py
@@ -131,6 +131,12 @@ class ExtensionUpdateForm(forms.ModelForm):
)
+class ExtensionDeleteForm(forms.ModelForm):
+ class Meta:
+ model = extensions.models.Extension
+ fields = []
+
+
class VersionForm(forms.ModelForm):
class Meta:
model = extensions.models.Version
@@ -151,7 +157,7 @@ class VersionForm(forms.ModelForm):
return self.initial['file']
-class DeleteViewForm(forms.ModelForm):
+class VersionDeleteForm(forms.ModelForm):
class Meta:
model = extensions.models.Version
fields = []
diff --git a/extensions/models.py b/extensions/models.py
index a6521a13..8cd79d8c 100644
--- a/extensions/models.py
+++ b/extensions/models.py
@@ -234,6 +234,13 @@ class Extension(
self.status = self.STATUSES.APPROVED
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):
return reverse('extensions:detail', args=[self.type_slug, self.slug])
@@ -246,6 +253,9 @@ class Extension(
def get_manage_versions_url(self):
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):
return reverse('extensions:new-version', args=[self.type_slug, self.slug])
diff --git a/extensions/templates/extensions/confirm_delete.html b/extensions/templates/extensions/confirm_delete.html
new file mode 100644
index 00000000..b5c7e4f2
--- /dev/null
+++ b/extensions/templates/extensions/confirm_delete.html
@@ -0,0 +1,32 @@
+{% extends "common/base.html" %}
+{% load i18n %}
+{% block content %}
+
+
+
+
+ {% blocktranslate with extension_name=extension_name %} Delete {{ extension_name }}?{% endblocktranslate %}
+
+
+ {% blocktranslate with extension_name=extension_name %}
+ By deleting {{ extension_name }} you will lose the files,
+ download count, reviews as well ratings for the whole extension.
+ {% endblocktranslate %}
+
+
+
+
+
+{% endblock content %}
diff --git a/extensions/templates/extensions/manage/update.html b/extensions/templates/extensions/manage/update.html
index 8087b57e..c37ac88b 100644
--- a/extensions/templates/extensions/manage/update.html
+++ b/extensions/templates/extensions/manage/update.html
@@ -115,6 +115,11 @@
{% trans 'Version History' %}
+
+
+ {% trans 'Delete Extension' %}
+
+
{% if request.user.is_staff %}
Admin
diff --git a/extensions/tests/test_delete.py b/extensions/tests/test_delete.py
new file mode 100644
index 00000000..093b2c72
--- /dev/null
+++ b/extensions/tests/test_delete.py
@@ -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()))
diff --git a/extensions/urls.py b/extensions/urls.py
index d03358d6..a44d54d6 100644
--- a/extensions/urls.py
+++ b/extensions/urls.py
@@ -44,6 +44,11 @@ urlpatterns = [
manage.UpdateExtensionView.as_view(),
name='manage',
),
+ path(
+ '/delete/',
+ manage.DeleteExtensionView.as_view(),
+ name='delete',
+ ),
path(
'/manage/versions/',
manage.ManageVersionsView.as_view(),
diff --git a/extensions/views/manage.py b/extensions/views/manage.py
index 8b77a12c..eac467e9 100644
--- a/extensions/views/manage.py
+++ b/extensions/views/manage.py
@@ -18,9 +18,10 @@ from .mixins import (
from extensions.forms import (
EditPreviewFormSet,
AddPreviewFormSet,
+ ExtensionDeleteForm,
ExtensionUpdateForm,
VersionForm,
- DeleteViewForm,
+ VersionDeleteForm,
)
from extensions.models import Extension, Version
from files.forms import FileForm
@@ -159,6 +160,29 @@ class UpdateExtensionView(
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(
LoginRequiredMixin,
MaintainedExtensionMixin,
@@ -166,7 +190,7 @@ class VersionDeleteView(
):
model = Version
template_name = 'extensions/version_confirm_delete.html'
- form_class = DeleteViewForm
+ form_class = VersionDeleteForm
def get_success_url(self):
return reverse(
diff --git a/extensions/views/public.py b/extensions/views/public.py
index 29c98e96..db731ddc 100644
--- a/extensions/views/public.py
+++ b/extensions/views/public.py
@@ -2,6 +2,7 @@ import logging
from django.contrib.auth import get_user_model
from django.db.models import Q
+from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.views.generic.list import ListView
@@ -53,6 +54,8 @@ class HomeView(ListedExtensionsView):
def extension_version_download(request, type_slug, slug, version):
"""Download an extension version and count downloads."""
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)
VersionDownload.create_from_request(request, object_id=extension_version.pk)
return redirect(extension_version.downloadable_signed_url)