extensions-website/extensions/forms.py
Oleg Komarov 3922f5d868 Version: drop file field, use files instead (#193)
Part of #74.

This PR drops Extension.file field, and introduces a cached_property Extension.file
as a temporary replacement for existing code references to the dropped field.

VersionFiles is renamed to VersionFile to match naming conventions.
All reads and writes go only via VersionFile cross-table, still assuming a single File
record per Version.

No functional changes are expected, except for one unfortunate side-effect in
recording object deletion via LogEntry: Version deletion is recorded twice:
1. during Extension deleting its Version objects via CASCADE
2. during post_delete for VersionFile when the check for the last file is done

After this change is deployed, we can start implementing multiple file uploads per
Version to support multi-platform builds (generated with `--split-platforms` option).

Reviewed-on: #193
Reviewed-by: Anna Sirota <annasirota@noreply.localhost>
2024-06-21 11:29:21 +02:00

379 lines
14 KiB
Python

import logging
import semantic_version
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from constants.base import (
ALLOWED_FEATURED_IMAGE_MIMETYPES,
ALLOWED_ICON_MIMETYPES,
ALLOWED_PREVIEW_MIMETYPES,
FILE_STATUS_CHOICES,
)
import extensions.models
import files.forms
import files.models
import reviewers.models
logger = logging.getLogger(__name__)
class EditPreviewForm(forms.ModelForm):
class Meta:
model = extensions.models.Extension.previews.through
fields = (
'caption',
'position',
)
widgets = {
'position': forms.HiddenInput(attrs={'data-position': ''}),
}
def __init__(self, *args, **kwargs):
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
super().__init__(*args, **kwargs)
EditPreviewFormSet = forms.inlineformset_factory(
extensions.models.Extension,
extensions.models.Extension.previews.through,
form=EditPreviewForm,
extra=0,
)
class AddPreviewFileForm(files.forms.BaseMediaFileForm):
allowed_mimetypes = ALLOWED_PREVIEW_MIMETYPES
error_messages = {'invalid_mimetype': _('Choose a JPEG, PNG or WebP image, or an MP4 video')}
class Meta(files.forms.BaseMediaFileForm.Meta):
fields = ('caption',) + files.forms.BaseMediaFileForm.Meta.fields
# Preview files might be videos, not only images
source = forms.FileField(allow_empty_file=False)
caption = forms.CharField(max_length=255, required=False)
def __init__(self, *args, **kwargs):
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
super().__init__(*args, **kwargs)
self.fields['source'].allow_empty_file = False
self.fields['source'].required = True
def save(self, *args, **kwargs):
"""Save Preview from the cleaned form data."""
instance = super().save(*args, **kwargs)
# Create extension preview and save caption to it, ignore duplicate records
extensions.models.Preview.objects.get_or_create(
file=instance,
extension=self.extension,
defaults={'caption': self.cleaned_data['caption']},
)
return instance
class AddPreviewModelFormSet(forms.BaseModelFormSet):
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
self.extension = kwargs.pop('extension')
super().__init__(*args, **kwargs)
# Make sure formset doesn't attempt to select existing File records
self.queryset = files.models.File.objects.none()
def get_form_kwargs(self, *args, **kwargs):
form_kwargs = super().get_form_kwargs(*args, **kwargs)
form_kwargs['request'] = self.request
form_kwargs['extension'] = self.extension
return form_kwargs
AddPreviewFormSet = forms.modelformset_factory(
files.models.File,
form=AddPreviewFileForm,
formset=AddPreviewModelFormSet,
extra=1,
)
class ExtensionUpdateForm(forms.ModelForm):
# Messages for auto-generated activity
msg_converted_to_draft = _('Converted to Draft')
msg_awaiting_review = _('Ready for review')
# Messages for additional validation
msg_cannot_convert_to_draft = _(
'An extension can be converted to draft only while it is Awating Review'
)
msg_need_previews = _('Please add at least one preview.')
class Meta:
model = extensions.models.Extension
fields = (
'description',
'support',
)
def __init__(self, *args, **kwargs):
"""Pass the request and initialise all the nested form(set)s."""
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs)
if self.request.POST:
edit_preview_formset = EditPreviewFormSet(
self.request.POST, self.request.FILES, instance=self.instance
)
add_preview_formset = AddPreviewFormSet(
self.request.POST,
self.request.FILES,
extension=self.instance,
request=self.request,
)
featured_image_form = FeaturedImageForm(
self.request.POST,
self.request.FILES,
extension=self.instance,
request=self.request,
)
icon_form = IconForm(
self.request.POST,
self.request.FILES,
extension=self.instance,
request=self.request,
)
else:
edit_preview_formset = EditPreviewFormSet(instance=self.instance)
add_preview_formset = AddPreviewFormSet(extension=self.instance, request=self.request)
featured_image_form = FeaturedImageForm(extension=self.instance, request=self.request)
icon_form = IconForm(extension=self.instance, request=self.request)
self.edit_preview_formset = edit_preview_formset
self.add_preview_formset = add_preview_formset
self.featured_image_form = featured_image_form
self.icon_form = icon_form
self.add_preview_formset.error_messages['too_few_forms'] = self.msg_need_previews
user_teams = self.request.user.teams.all()
if self.request.user in self.instance.authors.all() and len(user_teams) > 0:
team_slug = None
if self.instance.team:
team_slug = self.instance.team.slug
choices = [(None, 'None'), *[(team.slug, team.name) for team in user_teams]]
self.fields['team'] = forms.ChoiceField(
choices=choices,
required=False,
initial=team_slug,
)
def is_valid(self, *args, **kwargs) -> bool:
"""Validate all nested forms and form(set)s first."""
if 'submit_draft' in self.data:
# Require at least one preview image when requesting a review
if not self.instance.previews.exists():
self.add_preview_formset.min_num = 1
self.add_preview_formset.validate_min = True
# Make feature image and icon required too
self.featured_image_form.fields['source'].required = True
self.icon_form.fields['source'].required = True
is_valid_flags = [
self.edit_preview_formset.is_valid(),
self.add_preview_formset.is_valid(),
self.featured_image_form.is_valid(),
self.icon_form.is_valid(),
super().is_valid(*args, **kwargs),
]
new_file_forms = [
*self.add_preview_formset.forms,
self.featured_image_form,
self.icon_form,
]
seen_hashes = {}
# Ignore duplicate files in the formsets: point all forms sharing the hash to the same
# file instance, when forms call .save() it'll be on the same instance.
for f in new_file_forms:
hash = f.instance.original_hash
if not hash:
continue
if hash in seen_hashes:
f.instance = seen_hashes[hash]
else:
seen_hashes[hash] = f.instance
return all(is_valid_flags)
def clean_team(self):
# don't modify instance if the field value wasn't sent
# empty value reset the team
if 'team' in self.data:
# TODO permissions check
# shouldn't happen normally: the form doesn't render the select
if self.request.user not in self.instance.authors.all():
self.add_error('team', _('Not allowed to set the team'))
return
team_slug = self.cleaned_data['team']
if team_slug:
team = self.request.user.teams.filter(slug=team_slug).first()
if not team:
self.add_error('team', _('User does not belong to the team'))
return
else:
self.instance.team = team
else:
self.instance.team = None
def clean(self):
"""Perform additional validation and status changes."""
super().clean()
# Convert to draft, if possible
if 'convert_to_draft' in self.data:
if self.instance.status != self.instance.STATUSES.AWAITING_REVIEW:
self.add_error(None, self.msg_cannot_convert_to_draft)
else:
self.instance.status = self.instance.STATUSES.DRAFT
self.instance.converted_to_draft = True
# Send the extension and version to the review, if possible
if 'submit_draft' in self.data:
self.instance.status = self.instance.STATUSES.AWAITING_REVIEW
self.instance.sent_to_review = True
return self.cleaned_data
def save(self, *args, **kwargs):
"""Save the nested form(set)s, then the main form."""
if self.instance.is_listed:
updated_fields = set()
if 'description' in self.changed_data:
updated_fields.add('description')
if 'source' in self.featured_image_form.changed_data:
updated_fields.add('featured image')
if 'source' in self.icon_form.changed_data:
updated_fields.add('icon')
for form in self.add_preview_formset:
if 'source' in form.changed_data:
updated_fields.add('previews')
if updated_fields:
reviewers.models.ApprovalActivity(
user=self.request.user,
extension=self.instance,
type=reviewers.models.ApprovalActivity.ActivityType.COMMENT,
message='updated ' + ', '.join(sorted(updated_fields)),
).save()
self.edit_preview_formset.save()
self.add_preview_formset.save()
# Featured image and icon are only required when ready for review,
# and can be empty or unchanged.
if self.featured_image_form.has_changed():
# Automatically approve featured image of an already approved extension
if self.instance.is_approved:
self.featured_image_form.instance.status = FILE_STATUS_CHOICES.APPROVED
self.featured_image_form.save()
if self.icon_form.has_changed():
# Automatically approve icon of an already approved extension
if self.instance.is_approved:
self.icon_form.instance.status = FILE_STATUS_CHOICES.APPROVED
self.icon_form.save()
if getattr(self.instance, 'converted_to_draft', False):
reviewers.models.ApprovalActivity(
user=self.request.user,
extension=self.instance,
type=reviewers.models.ApprovalActivity.ActivityType.AWAITING_CHANGES,
message=self.msg_converted_to_draft,
).save()
if getattr(self.instance, 'sent_to_review', False):
reviewers.models.ApprovalActivity(
user=self.request.user,
extension=self.instance,
type=reviewers.models.ApprovalActivity.ActivityType.AWAITING_REVIEW,
message=self.msg_awaiting_review,
).save()
return super().save(*args, **kwargs)
class ExtensionDeleteForm(forms.ModelForm):
class Meta:
model = extensions.models.Extension
fields = []
class VersionForm(forms.ModelForm):
class Meta:
model = extensions.models.Version
fields = {'files', 'release_notes'}
def __init__(self, *args, **kwargs):
"""Limit 'file' choices to the initial file value."""
super().__init__(*args, **kwargs)
# Mark 'file' field as disabled so that Django form allows using its initial value.
self.fields['files'].disabled = True
def clean_files(self, *args, **kwargs):
"""Return file that was passed to the form via the initial values.
This ensures that it doesn't have to be supplied by the form data.
"""
return self.initial['files']
class VersionUpdateForm(forms.ModelForm):
class Meta:
fields = ['blender_version_max', 'release_notes']
model = extensions.models.Version
def clean_blender_version_max(self, *args, **kwargs):
if 'blender_version_max' in self.data:
blender_version_max = self.cleaned_data['blender_version_max']
if blender_version_max is None:
return None
try:
max = semantic_version.Version(blender_version_max)
if max <= semantic_version.Version(self.instance.blender_version_min):
self.add_error(
'blender_version_max', _('Must be greater than min blender version')
)
except (TypeError, ValueError):
self.add_error('blender_version_max', _('Must be a valid version'))
return blender_version_max
class VersionDeleteForm(forms.ModelForm):
class Meta:
model = extensions.models.Version
fields = []
class FeaturedImageForm(files.forms.BaseMediaFileForm):
prefix = 'featured-image'
to_field = 'featured_image'
allowed_mimetypes = ALLOWED_FEATURED_IMAGE_MIMETYPES
error_messages = {'invalid_mimetype': _('Choose a JPEG, PNG or WebP image')}
class IconForm(files.forms.BaseMediaFileForm):
prefix = 'icon'
to_field = 'icon'
allowed_mimetypes = ALLOWED_ICON_MIMETYPES
error_messages = {
'invalid_mimetype': _('Choose a PNG image'),
'invalid_size_px': _('Choose a 256 x 256 PNG image'),
}
expected_size_px = 256
def clean_source(self, *args, **kwargs):
"""Check image resolution."""
source = super().clean_source(*args, **kwargs)
if not source:
return
image = getattr(source, 'image', None)
if not image:
return
if image.width > self.expected_size_px or image.height > self.expected_size_px:
raise ValidationError(self.error_messages['invalid_size_px'])
return source