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 %} +

+
+ + + {% trans 'Cancel' %} + +
+ {% csrf_token %} + +
+
+
+
+
+{% 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)