Draft: Make sure extensions always have a user #54

Merged
Francesco Siddi merged 1 commits from dfelinto/extensions-website:fix-no-orphans into main 2024-03-14 18:36:06 +01:00
9 changed files with 163 additions and 118 deletions
Showing only changes of commit 489da73683 - Show all commits

View File

@ -237,6 +237,9 @@ class Extension(
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])
def get_draft_url(self):
return reverse('extensions:draft', args=[self.type_slug, self.slug])
def get_manage_url(self): def get_manage_url(self):
return reverse('extensions:manage', args=[self.type_slug, self.slug]) return reverse('extensions:manage', args=[self.type_slug, self.slug])

View File

@ -100,21 +100,12 @@
<button id="btn-save" type="submit" class="btn btn-primary"> <button id="btn-save" type="submit" class="btn btn-primary">
<i class="i-check"></i> <i class="i-check"></i>
<span> <span>
{% if extension.is_approved %}
{% trans 'Save Changes' %} {% trans 'Save Changes' %}
{% else %}
{% trans 'Save Draft' %}
{% endif %}
</span> </span>
</button> </button>
<hr> <hr>
{% if extension.status == extension. %}
<button type="submit" class="btn btn-block disabled" disabled>{% trans 'Mark ready for review' %}</button>
{% elif 'Incomplete' != extension.get_status_display %}
{% endif %}
<a href="{{ extension.get_new_version_url }}" class="btn"> <a href="{{ extension.get_new_version_url }}" class="btn">
<i class="i-upload"></i> <i class="i-upload"></i>
<span>{% trans 'Upload New Version' %}</span> <span>{% trans 'Upload New Version' %}</span>

View File

@ -224,12 +224,24 @@ class SubmitFinaliseTest(TestCase):
hash=file_data['file_hash'], hash=file_data['file_hash'],
metadata=file_data['metadata'], metadata=file_data['metadata'],
) )
self.version = create_version(
file=self.file,
extension__name=file_data['metadata']['name'],
extension__slug=file_data['metadata']['id'].replace("_", "-"),
extension__website=None,
tagline=file_data['metadata']['tagline'],
version=file_data['metadata']['version'],
blender_version_min=file_data['metadata']['blender_version_min'],
schema_version=file_data['metadata']['schema_version'],
)
def test_get_finalise_addon_redirects_if_anonymous(self): def test_get_finalise_addon_redirects_if_anonymous(self):
response = self.client.post(self.file.get_submit_url(), {}) response = self.client.post(self.file.get_submit_url(), {})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], f'/oauth/login?next=/submit/{self.file.pk}/') self.assertEqual(
response['Location'], f'/oauth/login?next=/add-ons/{self.file.extension.slug}/draft/'
)
def test_get_finalise_addon_not_allowed_if_different_user(self): def test_get_finalise_addon_not_allowed_if_different_user(self):
user = UserFactory() user = UserFactory()
@ -237,7 +249,9 @@ class SubmitFinaliseTest(TestCase):
response = self.client.post(self.file.get_submit_url(), {}) response = self.client.post(self.file.get_submit_url(), {})
self.assertEqual(response.status_code, 403) # Technically this could (should) be a 403, but changing this means changing
# the MaintainedExtensionMixin which is used in multiple places.
self.assertEqual(response.status_code, 404)
def test_post_finalise_addon_validation_errors(self): def test_post_finalise_addon_validation_errors(self):
self.client.force_login(self.file.user) self.client.force_login(self.file.user)
@ -258,8 +272,8 @@ class SubmitFinaliseTest(TestCase):
def test_post_finalise_addon_creates_addon_with_version_awaiting_review(self): def test_post_finalise_addon_creates_addon_with_version_awaiting_review(self):
self.assertEqual(File.objects.count(), 1) self.assertEqual(File.objects.count(), 1)
self.assertEqual(Extension.objects.count(), 0) self.assertEqual(Extension.objects.count(), 1)
self.assertEqual(Version.objects.count(), 0) self.assertEqual(Version.objects.count(), 1)
self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 0) self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 0)
self.client.force_login(self.file.user) self.client.force_login(self.file.user)

View File

@ -7,7 +7,6 @@ from extensions.views import api, public, submit, manage
app_name = 'extensions' app_name = 'extensions'
urlpatterns = [ urlpatterns = [
path('submit/', submit.UploadFileView.as_view(), name='submit'), path('submit/', submit.UploadFileView.as_view(), name='submit'),
path('submit/<int:pk>/', submit.SubmitFileView.as_view(), name='submit-finalise'),
# TODO: move /accounts pages under its own app when User model is replaced # TODO: move /accounts pages under its own app when User model is replaced
path('accounts/extensions/', manage.ManageListView.as_view(), name='manage-list'), path('accounts/extensions/', manage.ManageListView.as_view(), name='manage-list'),
# FIXME: while there's no profile page, redirect to My Extensions instead # FIXME: while there's no profile page, redirect to My Extensions instead
@ -35,6 +34,11 @@ urlpatterns = [
rf'^(?P<type_slug>{EXTENSION_SLUGS_PATH})/', rf'^(?P<type_slug>{EXTENSION_SLUGS_PATH})/',
include( include(
[ [
path(
'<slug:slug>/draft/',
manage.DraftExtensionView.as_view(),
name='draft',
),
path( path(
'<slug:slug>/manage/', '<slug:slug>/manage/',
manage.UpdateExtensionView.as_view(), manage.UpdateExtensionView.as_view(),

View File

@ -5,10 +5,16 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404, reverse from django.shortcuts import get_object_or_404, reverse
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, UpdateView, DeleteView from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
from .mixins import ExtensionQuerysetMixin, OwnsFileMixin, MaintainedExtensionMixin from .mixins import (
ExtensionQuerysetMixin,
OwnsFileMixin,
MaintainedExtensionMixin,
DraftVersionMixin,
DraftMixin,
)
from extensions.forms import ( from extensions.forms import (
EditPreviewFormSet, EditPreviewFormSet,
AddPreviewFormSet, AddPreviewFormSet,
@ -91,6 +97,7 @@ class UpdateExtensionView(
MaintainedExtensionMixin, MaintainedExtensionMixin,
SuccessMessageMixin, SuccessMessageMixin,
UpdateView, UpdateView,
DraftMixin,
): ):
model = Extension model = Extension
template_name = 'extensions/manage/update.html' template_name = 'extensions/manage/update.html'
@ -293,3 +300,78 @@ class UpdateVersionView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
def test_func(self) -> bool: def test_func(self) -> bool:
# Only maintainers are allowed to perform this # Only maintainers are allowed to perform this
return self.get_object().extension.has_maintainer(self.request.user) return self.get_object().extension.has_maintainer(self.request.user)
class DraftExtensionView(
LoginRequiredMixin,
MaintainedExtensionMixin,
DraftVersionMixin,
UserPassesTestMixin,
FormView,
):
template_name = 'extensions/draft_finalise.html'
form_class = VersionForm
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()
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
extension_form.instance.status = extension_form.instance.STATUSES.AWAITING_REVIEW
extension_form.save()
add_preview_formset.save()
form.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()

View File

@ -1,5 +1,6 @@
from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.auth.mixins import UserPassesTestMixin
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404, redirect
from django.core.exceptions import BadRequest
from extensions.models import Extension from extensions.models import Extension
from files.models import File from files.models import File
@ -54,3 +55,30 @@ class ExtensionMixin:
Extension.objects.exclude_deleted, slug=self.kwargs['slug'] Extension.objects.exclude_deleted, slug=self.kwargs['slug']
) )
return super().dispatch(*args, **kwargs) return super().dispatch(*args, **kwargs)
class DraftVersionMixin:
"""Fetch the version object which is being edited as a draft."""
def dispatch(self, *args, **kwargs):
self.version = self.extension.versions.first()
if self.version is None:
error_message = f'Extension "{self.extension.name}" has no versions.'
raise BadRequest(error_message)
return super().dispatch(*args, **kwargs)
class DraftMixin:
"""If the extension is incomplete, returns the FinalizeDraftView"""
def dispatch(self, request, *args, **kwargs):
extension = (
Extension.objects.listed_or_authored_by(user_id=self.request.user.pk)
.filter(status=Extension.STATUSES.INCOMPLETE)
.first()
)
if not extension:
return super().dispatch(request, *args, **kwargs)
return redirect(extension.get_draft_url())

View File

@ -1,25 +1,18 @@
import logging import logging
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction from django.db import transaction
from django.shortcuts import reverse from django.views.generic.edit import CreateView
from django.views.generic.edit import CreateView, FormView
from .mixins import OwnsFileMixin from .mixins import DraftMixin
from extensions.models import Version, Extension from extensions.models import Version, Extension
from extensions.forms import (
ExtensionUpdateForm,
VersionForm,
AddPreviewFormSet,
)
from files.forms import FileForm from files.forms import FileForm
from files.models import File from files.models import File
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class UploadFileView(LoginRequiredMixin, CreateView): class UploadFileView(LoginRequiredMixin, DraftMixin, CreateView):
model = File model = File
template_name = 'extensions/submit.html' template_name = 'extensions/submit.html'
form_class = FileForm form_class = FileForm
@ -30,30 +23,12 @@ class UploadFileView(LoginRequiredMixin, CreateView):
return kwargs return kwargs
def get_success_url(self): def get_success_url(self):
return reverse('extensions:submit-finalise', kwargs={'pk': self.object.pk}) return self.extension.get_draft_url()
@transaction.atomic
class SubmitFileView(LoginRequiredMixin, OwnsFileMixin, FormView): def form_valid(self, form):
template_name = 'extensions/submit_finalise.html' """Create an extension and a version already, associated with the user."""
form_class = VersionForm self.file = form.instance
def _get_extension(self) -> 'Extension':
# Get the existing Extension and Version, if they were partially saved already
existing_version = getattr(self.file, 'version', None)
if existing_version:
extension = existing_version.extension
assert (
extension.can_request_review
), 'TODO: cannot request review for extension: {extension.get_status_display}'
assert not extension.authors.count() or extension.authors.filter(
user__id=self.request.user.pk
), 'TODO: cannot request review for extension: maintained by someone else'
logger.warning(
'Found existing version pk=%s for file pk=%s',
existing_version.pk,
self.file.pk,
)
return extension
parsed_extension_fields = self.file.parsed_extension_fields parsed_extension_fields = self.file.parsed_extension_fields
if parsed_extension_fields: if parsed_extension_fields:
@ -69,72 +44,21 @@ class SubmitFileView(LoginRequiredMixin, OwnsFileMixin, FormView):
extension.pk, extension.pk,
self.file.pk, self.file.pk,
) )
return extension return False
return Extension.objects.update_or_create(type=self.file.type, **parsed_extension_fields)[0]
def _get_version(self, extension) -> 'Version': # Make sure an extension has a user associated to it from the beginning, otherwise
return Version.objects.update_or_create( # it will prevent it from being re-uploaded and yet not show on My Extensions.
extension=extension, file=self.file, **self.file.parsed_version_fields self.extension = Extension.objects.update_or_create(
type=self.file.type, **parsed_extension_fields
)[0]
self.extension.authors.add(self.request.user)
self.extension.save()
# Need to save the form to be able to use the file to create the version.
self.object = self.file = form.save()
Version.objects.update_or_create(
extension=self.extension, file=self.file, **self.file.parsed_version_fields
)[0] )[0]
def get_form_kwargs(self): return super().form_valid(form)
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_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
extension_form.instance.status = extension_form.instance.STATUSES.AWAITING_REVIEW
extension_form.save()
self.extension.authors.add(self.request.user)
add_preview_formset.save()
form.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.file.extension.get_manage_url()

View File

@ -6,7 +6,6 @@ import re
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.urls import reverse
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin
from files.utils import get_sha256 from files.utils import get_sha256
@ -209,7 +208,7 @@ class File(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Mode
} }
def get_submit_url(self) -> str: def get_submit_url(self) -> str:
return reverse('extensions:submit-finalise', kwargs={'pk': self.pk}) return self.extension.get_draft_url()
class FileValidation(CreatedModifiedMixin, TrackChangesMixin, models.Model): class FileValidation(CreatedModifiedMixin, TrackChangesMixin, models.Model):