extensions-website/extensions/views/manage.py
Anna Sirota caae613747 Make it possible to fully delete unlisted/unrated extensions and versions (#81)
* removes all soft-deletion;
* shows a "Delete extension" button on the draft page in case it can be deleted;
* shows a "Delete version" button on the version page in case it can be deleted;
* a version can be deleted if
  * its file isn't approved, and it doesn't have any ratings;
* an extension can be deleted if
  * it's not listed, and doesn't have any ratings or abuse reports;
  * all it's versions can also be deleted;
* changes default `File.status` from `APPROVED` to `AWAITING_REVIEW`
  With version's file status being `APPROVED` by default, a version can never be deleted, even when the extension is still a draft.
  This change doesn't affect the approval process because
   * when an extension is approved its latest version becomes approved automatically (no change here);
   * when a new version is uploaded to an approved extension, it's approved automatically (this is new).

This allows authors to delete their drafts, freeing the extension slug and making it possible to re-upload the same file.
This also makes it possible to easily fix mistakes during the drafting of a new extension (e.g. delete a version and re-upload it without bumping a version for each typo/mistake in packaging and so on).
(see #78 and #63)

Reviewed-on: #81
2024-04-19 11:00:13 +02:00

436 lines
15 KiB
Python

"""Contains views allowing developers to manage their add-ons."""
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.db import transaction
from django.shortcuts import get_object_or_404, reverse
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
from .mixins import (
ExtensionQuerysetMixin,
OwnsFileMixin,
MaintainedExtensionMixin,
DraftVersionMixin,
DraftMixin,
)
from extensions.forms import (
EditPreviewFormSet,
AddPreviewFormSet,
ExtensionDeleteForm,
ExtensionUpdateForm,
VersionForm,
VersionDeleteForm,
)
from extensions.models import Extension, Version
from files.forms import FileForm
from files.models import File
from reviewers.models import ApprovalActivity
from stats.models import ExtensionView
import ratings.models
class ExtensionDetailView(ExtensionQuerysetMixin, DetailView):
model = Extension
context_object_name = 'extension'
template_name = 'extensions/detail.html'
def get_queryset(self):
"""Allow logged in users to view unlisted add-ons in certain conditions.
* maintainers should be able to preview their yet unlisted add-ons;
* staff should be able to preview yet unlisted add-ons;
"""
return self.get_extension_queryset()
def get_object(self, queryset=None):
"""Record a page view when returning the Extension object."""
obj = super().get_object(queryset=queryset)
if obj.is_listed and (
self.request.user.is_anonymous or not obj.has_maintainer(self.request.user)
):
ExtensionView.create_from_request(self.request, object_id=obj.pk)
return obj
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.user.is_authenticated:
context['my_rating'] = ratings.models.Rating.get_for(
self.request.user.pk, self.object.pk
)
return context
class VersionsView(ExtensionQuerysetMixin, ListView):
model = Version
paginate_by = 15
def get_queryset(self):
"""Allow logged in users to view unlisted versions in certain conditions.
* maintainers should be able to preview their yet unlisted versions;
* staff should be able to preview yet unlisted versions;
"""
self.extension_queryset = self.get_extension_queryset()
self.extension = get_object_or_404(self.extension_queryset, slug=self.kwargs['slug'])
queryset = self.extension.versions
if self.request.user.is_staff or self.extension.has_maintainer(self.request.user):
return queryset.all()
return queryset.listed
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['extension'] = self.extension
return context
class ManageListView(LoginRequiredMixin, ListView):
model = Extension
paginate_by = 15
template_name = 'extensions/manage/list.html'
def get_queryset(self):
return Extension.objects.authored_by(user_id=self.request.user.pk)
class UpdateExtensionView(
LoginRequiredMixin,
MaintainedExtensionMixin,
SuccessMessageMixin,
DraftMixin,
UpdateView,
):
model = Extension
template_name = 'extensions/manage/update.html'
form_class = ExtensionUpdateForm
success_message = "Updated successfully"
def get_success_url(self):
self.object.refresh_from_db()
return self.object.get_manage_url()
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
edit_preview_formset = kwargs.pop('edit_preview_formset', None)
add_preview_formset = kwargs.pop('add_preview_formset', None)
if not (edit_preview_formset and add_preview_formset):
if self.request.POST:
edit_preview_formset = EditPreviewFormSet(
self.request.POST, self.request.FILES, instance=self.object
)
add_preview_formset = AddPreviewFormSet(
self.request.POST,
self.request.FILES,
extension=self.object,
request=self.request,
)
else:
edit_preview_formset = EditPreviewFormSet(instance=self.object)
add_preview_formset = AddPreviewFormSet(extension=self.object, request=self.request)
context['edit_preview_formset'] = edit_preview_formset
context['add_preview_formset'] = add_preview_formset
return context
def form_invalid(self, form, edit_preview_formset=None, add_preview_formset=None):
return self.render_to_response(
self.get_context_data(
form=form,
edit_preview_formset=edit_preview_formset,
add_preview_formset=add_preview_formset,
)
)
@transaction.atomic
def form_valid(self, form):
edit_preview_formset = EditPreviewFormSet(
self.request.POST, self.request.FILES, instance=self.object
)
add_preview_formset = AddPreviewFormSet(
self.request.POST, self.request.FILES, extension=self.object, request=self.request
)
if edit_preview_formset.is_valid() and add_preview_formset.is_valid():
try:
edit_preview_formset.save()
add_preview_formset.save()
response = super().form_valid(form)
return response
except forms.ValidationError as e:
if 'hash' in e.error_dict:
add_preview_formset.forms[0].add_error('source', e.error_dict['hash'])
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:
obj = self.get_object()
# Only maintainers allowed
if not obj.has_maintainer(self.request.user):
return False
# Unless this extension cannot be deleted anymore
cannot_be_deleted_reasons = obj.cannot_be_deleted_reasons
if len(cannot_be_deleted_reasons) > 0:
return False
return True
class VersionDeleteView(
LoginRequiredMixin,
MaintainedExtensionMixin,
UserPassesTestMixin,
DeleteView,
):
model = Version
template_name = 'extensions/version_confirm_delete.html'
form_class = VersionDeleteForm
def get_success_url(self):
return reverse(
'extensions:manage-versions',
kwargs={
'type_slug': self.kwargs['type_slug'],
'slug': self.kwargs['slug'],
},
)
def get_object(self, queryset=None):
return get_object_or_404(
Version,
extension__slug=self.kwargs['slug'],
pk=self.kwargs['pk'],
)
def _get_version_from_id(self):
version_id = self.kwargs['pk']
version = self.extension.versions.filter(id=version_id).first()
if version is None:
raise RuntimeError(
f'Could not find version {version_id} for extension {self.extension.id}'
)
return version
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
version = self._get_version_from_id()
context['extension_name'] = self.extension.name
context['version'] = version.version
context['confirm_url'] = version.get_delete_url()
return context
def test_func(self) -> bool:
obj = self.get_object()
# Unless this version cannot be deleted anymore
cannot_be_deleted_reasons = obj.cannot_be_deleted_reasons
if len(cannot_be_deleted_reasons) > 0:
return False
return True
class ManageVersionsView(
LoginRequiredMixin,
MaintainedExtensionMixin,
VersionsView,
):
pass
class NewVersionView(
LoginRequiredMixin,
MaintainedExtensionMixin,
CreateView,
):
"""Upload a file for a new version of existing extension."""
model = File
template_name = 'extensions/submit.html'
form_class = FileForm
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['extension'] = self.extension
return ctx
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['request'] = self.request
kwargs['extension'] = self.extension
return kwargs
def get_success_url(self):
return reverse(
'extensions:new-version-finalise',
kwargs={
'type_slug': self.extension.type_slug,
'slug': self.extension.slug,
'pk': self.object.pk,
},
)
class NewVersionFinalizeView(LoginRequiredMixin, OwnsFileMixin, CreateView):
"""Finalise a new version of existing extension and send it to review."""
template_name = 'extensions/new_version_finalise.html'
form_class = VersionForm
def form_valid(self, form):
# Automatically approve new versions, for now.
# We rely on approval of the first submission, for now.
self.file.status = self.file.STATUSES.APPROVED
self.file.save()
response = super().form_valid(form)
return response
def _get_extension(self) -> 'Extension':
return get_object_or_404(Extension, slug=self.kwargs['slug'])
def _get_version(self, extension) -> 'Version':
return Version.objects.update_or_create(
extension=extension, file=self.file, **self.file.parsed_version_fields
)[0]
def get_form_kwargs(self):
form_kwargs = super().get_form_kwargs()
form_kwargs['instance'] = self._get_version(self.extension)
return form_kwargs
def get_initial(self):
"""Return initial values for the version, based on the file."""
initial = super().get_initial()
initial['file'] = self.file
initial.update(**self.file.parsed_version_fields)
return initial
def get_success_url(self):
return self.extension.get_manage_versions_url()
class UpdateVersionView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
"""Update release notes for an existing version."""
template_name = 'extensions/new_version_finalise.html'
model = Version
fields = ['release_notes']
def get_success_url(self):
return reverse(
'extensions:versions',
kwargs={
'type_slug': self.object.extension.type_slug,
'slug': self.object.extension.slug,
},
)
def test_func(self) -> bool:
# Only maintainers are allowed to perform this
return self.get_object().extension.has_maintainer(self.request.user)
class DraftExtensionView(
LoginRequiredMixin,
MaintainedExtensionMixin,
DraftVersionMixin,
UserPassesTestMixin,
SuccessMessageMixin,
FormView,
):
template_name = 'extensions/draft_finalise.html'
form_class = VersionForm
@property
def success_message(self) -> str:
if self.extension.status == Extension.STATUSES.INCOMPLETE:
return "Updated successfully"
return "Submitted to the Approval Queue"
def test_func(self) -> bool:
return self.extension.status == Extension.STATUSES.INCOMPLETE
def get_form_kwargs(self):
form_kwargs = super().get_form_kwargs()
form_kwargs['instance'] = self.extension.versions.first()
return form_kwargs
def get_initial(self):
"""Return initial values for the version, based on the file."""
initial = super().get_initial()
if self.version:
initial['file'] = self.version.file
initial.update(**self.version.file.parsed_version_fields)
return initial
def get_context_data(self, form=None, extension_form=None, add_preview_formset=None, **kwargs):
"""Add all the additional forms to the context."""
context = super().get_context_data(**kwargs)
if not (add_preview_formset and extension_form):
extension_form = ExtensionUpdateForm(instance=self.extension)
add_preview_formset = AddPreviewFormSet(extension=self.extension, request=self.request)
context['extension_form'] = extension_form
context['add_preview_formset'] = add_preview_formset
return context
def post(self, request, *args, **kwargs):
"""Handle bound forms and valid/invalid logic with the extra forms."""
form = self.get_form()
extension_form = ExtensionUpdateForm(
self.request.POST, self.request.FILES, instance=self.extension
)
add_preview_formset = AddPreviewFormSet(
self.request.POST, self.request.FILES, extension=self.extension, request=self.request
)
if form.is_valid() and extension_form.is_valid() and add_preview_formset.is_valid():
return self.form_valid(form, extension_form, add_preview_formset)
return self.form_invalid(form, extension_form, add_preview_formset)
@transaction.atomic
def form_valid(self, form, extension_form, add_preview_formset):
"""Save all the forms in correct order.
Extension must be saved first.
"""
try:
# Send the extension and version to the review
if 'submit_draft' in self.request.POST:
extension_form.instance.status = extension_form.instance.STATUSES.AWAITING_REVIEW
extension_form.save()
add_preview_formset.save()
form.save()
if 'submit_draft' in self.request.POST:
# TODO allow to submit a custom message via the form
ApprovalActivity(
user=self.request.user,
extension=extension_form.instance,
type=ApprovalActivity.ActivityType.AWAITING_REVIEW,
message="initial submission",
).save()
return super().form_valid(form)
except forms.ValidationError as e:
if 'hash' in e.error_dict:
add_preview_formset.forms[0].add_error('source', e.error_dict['hash'])
return self.form_invalid(form, extension_form, add_preview_formset)
def form_invalid(self, form, extension_form, add_preview_formset):
return self.render_to_response(
self.get_context_data(form, extension_form, add_preview_formset)
)
def get_success_url(self):
return self.extension.get_manage_url()