Implement Web Assets' theme system and selection, and add 'light' theme #118

Merged
Márton Lente merged 97 commits from martonlente/extensions-website:ui/theme-light into main 2024-05-08 14:20:07 +02:00
13 changed files with 289 additions and 176 deletions
Showing only changes of commit 3b88b0b95a - Show all commits

View File

@ -33,7 +33,8 @@
<body class="has-global-bar">
{% switch "is_alpha" %}
<div class="site-announcement-alpha">
This is a test instance. Add-ons and other data might be deleted at any time. <a class="text-underline" href="https://devtalk.blender.org/tag/extensions" target="_blank">Learn more</a>
This platform is currently in alpha.
<a class="text-underline" href="https://projects.blender.org/infrastructure/extensions-website/issues" target="_blank">Please report any issues you may find</a>, thanks! <a class="text-underline" href="https://devtalk.blender.org/tag/extensions" target="_blank">Learn more</a>
</div>
{% else %}
{% switch "is_beta" %}

View File

@ -61,10 +61,9 @@ def _extract_urls(urlpatterns, parents):
def extract_urls(urlpatterns=None):
"""
Extract URLEntry objects from the given iterable
of Django URL pattern objects. If no iterable is given,
the patterns exposed by the root resolver are used, i.e.
"""Extract URLEntry objects from the given iterable of Django URL pattern objects.
If no iterable is given, the patterns exposed by the root resolver are used, i.e.
all of the URLs routed in the project.
:param urlpatterns: Iterable of URLPattern objects
:return: Generator of `URLEntry` objects.
@ -78,7 +77,10 @@ def extract_urls(urlpatterns=None):
def _get_all_form_errors(response):
return (
{
key: response.context[key].errors
key: [
response.context[key].errors,
getattr(response.context[key], 'non_form_errors', lambda: None)(),
]
for key in response.context.keys()
if key.endswith('_formset') or key == 'form' or key.endswith('_form')
}

View File

@ -2,12 +2,14 @@ import logging
from django import forms
from django.utils.translation import gettext_lazy as _
import django.core.exceptions
from files.validators import FileMIMETypeValidator
from constants.base import ALLOWED_PREVIEW_MIMETYPES
import extensions.models
import files.models
import reviewers.models
logger = logging.getLogger(__name__)
@ -41,7 +43,8 @@ class AddPreviewFileForm(forms.ModelForm):
class Meta:
model = files.models.File
fields = ('caption', 'source')
fields = ('caption', 'source', 'original_hash', 'hash')
widgets = {'original_hash': forms.HiddenInput(), 'hash': forms.HiddenInput()}
source = forms.FileField(
allow_empty_file=False,
@ -64,6 +67,27 @@ class AddPreviewFileForm(forms.ModelForm):
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
super().__init__(*args, **kwargs)
def clean_original_hash(self, *args, **kwargs):
"""Calculate original hash of the uploaded file."""
if 'source' not in self.cleaned_data:
return
source = self.cleaned_data['source']
return files.models.File.generate_hash(source)
def clean_hash(self, *args, **kwargs):
return self.cleaned_data['original_hash']
def add_error(self, field, error):
"""Add hidden `original_hash`/`hash` errors to the visible `source` field instead."""
if isinstance(error, django.core.exceptions.ValidationError):
if getattr(error, 'error_dict', None):
hash_error = error.error_dict.pop('hash', None)
if hash_error:
error.error_dict['source'] = hash_error
# `original_hash` is treated identically to `hash`, so its errors can be discarded
error.error_dict.pop('original_hash', None)
super().add_error(field, error)
def save(self, *args, **kwargs):
"""Save Preview from the cleaned form data."""
# Fill in missing fields from request and the source file
@ -81,6 +105,8 @@ class AddPreviewFileForm(forms.ModelForm):
class AddPreviewModelFormSet(forms.BaseModelFormSet):
msg_duplicate_file = _('Please select another file instead of the duplicate')
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
self.extension = kwargs.pop('extension')
@ -94,6 +120,14 @@ class AddPreviewModelFormSet(forms.BaseModelFormSet):
form_kwargs['extension'] = self.extension
return form_kwargs
def get_unique_error_message(self, unique_check):
"""Replace duplicate `original_hash`/`hash` message with a more meaningful one."""
if len(unique_check) == 1:
field = unique_check[0]
if field in ('original_hash', 'hash'):
return self.msg_duplicate_file
return super().get_unique_error_message(unique_check)
AddPreviewFormSet = forms.modelformset_factory(
files.models.File,
@ -104,6 +138,16 @@ AddPreviewFormSet = forms.modelformset_factory(
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 = (
@ -111,17 +155,79 @@ class ExtensionUpdateForm(forms.ModelForm):
'support',
)
def clean(self):
super().clean()
if (
'convert_to_draft' in self.data
and self.instance.status != self.instance.STATUSES.AWAITING_REVIEW
):
self.add_error(
None, 'An extension can be converted to draft only while it is Awating Review'
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,
)
else:
edit_preview_formset = EditPreviewFormSet(instance=self.instance)
add_preview_formset = AddPreviewFormSet(extension=self.instance, request=self.request)
self.edit_preview_formset = edit_preview_formset
self.add_preview_formset = add_preview_formset
self.add_preview_formset.error_messages['too_few_forms'] = self.msg_need_previews
def is_valid(self, *args, **kwargs) -> bool:
"""Validate all nested forms and form(set)s first."""
# Require at least one preview image when requesting a review
if 'submit_draft' in self.data:
if not self.instance.previews.exists():
self.add_preview_formset.min_num = 1
self.add_preview_formset.validate_min = True
is_valid_flags = [
self.edit_preview_formset.is_valid(),
self.add_preview_formset.is_valid(),
super().is_valid(*args, **kwargs),
]
return all(is_valid_flags)
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.INCOMPLETE
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."""
self.edit_preview_formset.save()
self.add_preview_formset.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:

View File

@ -69,7 +69,7 @@
highlight what makes your {{ type }} special.
{% endblocktranslate %}
</p>
{% include "extensions/manage/components/edit_previews.html" %}
{% include "extensions/manage/components/add_previews.html" %}
</div>
</section>

View File

@ -0,0 +1,47 @@
{% load common %}
{% comment %}
Handles displaying existing previews, updating their captions
and deleting them on Extension Update and Extension Draft pages
{% endcomment %}
{{ edit_preview_formset.management_form }}
{{ edit_preview_formset.non_form_errors }}
{% if edit_preview_formset.forms|length %}
{# View or delete existing preview files #}
<div class="previews-list js-previews-drag-container">
{% for newform in edit_preview_formset %}
{% with inlineform=newform|add_form_classes %}
{% with file=inlineform.instance.file %}
<div class="previews-list-item" data-preview-position="{{ inlineform.instance.position }}">
<div class="js-preview-drag drag-widget is-draggable">
<i class="i-menu"></i>
<div class="previews-list-item-thumbnail">
<div class="previews-list-item-thumbnail-img" style="background-image: url('{% if file.is_image %}{{ file.source.url }}{% elif file.is_video and file.thumbnail %}{{ file.thumbnail.url }}{% endif %}');" title="Preview"></div>
</div>
</div>
<div class="details">
<div>
{% include "common/components/field.html" with field=inlineform.id %}
{% include "common/components/field.html" with field=inlineform.caption %}
{% include "common/components/field.html" with field=inlineform.position %}
</div>
<ul>
<li>
<a href="{{ inlineform.instance.file.source.url }}" target="_blank">
<small>Source File</small>
</a>
</li>
<li class="ms-auto">
{% include "common/components/field.html" with field=inlineform.DELETE %}
</li>
<li>
{{ inlineform.non_form_errors }}
</li>
</ul>
</div>
</div>
{% endwith %}
{% endwith %}
{% endfor %}
</div>
<hr class="my-4"/>
{% endif %}

View File

@ -41,49 +41,7 @@
<section class="mt-4">
<h2>{% trans 'Previews' %}</h2>
<div class="previews-upload">
{{ edit_preview_formset.management_form }}
{{ edit_preview_formset.non_form_errors }}
{% if edit_preview_formset.forms|length %}
{# View or delete existing preview files #}
<div class="previews-list js-previews-drag-container">
{% for newform in edit_preview_formset %}
{% with inlineform=newform|add_form_classes %}
{% with file=inlineform.instance.file %}
<div class="previews-list-item" data-preview-position="{{ inlineform.instance.position }}">
<div class="js-preview-drag drag-widget is-draggable">
<i class="i-menu"></i>
<div class="previews-list-item-thumbnail">
<div class="previews-list-item-thumbnail-img" style="background-image: url('{% if file.is_image %}{{ file.source.url }}{% elif file.is_video and file.thumbnail %}{{ file.thumbnail.url }}{% endif %}');" title="Preview"></div>
</div>
</div>
<div class="details">
<div>
{% include "common/components/field.html" with field=inlineform.id %}
{% include "common/components/field.html" with field=inlineform.caption %}
{% include "common/components/field.html" with field=inlineform.position %}
</div>
<ul>
<li>
<a href="{{ inlineform.instance.file.source.url }}" target="_blank">
<small>Source File</small>
</a>
</li>
<li class="ms-auto">
{% include "common/components/field.html" with field=inlineform.DELETE %}
</li>
<li>
{{ inlineform.non_form_errors }}
</li>
</ul>
</div>
</div>
{% endwith %}
{% endwith %}
{% endfor %}
</div>
<hr class="my-4"/>
{% endif %}
{% include "extensions/manage/components/edit_previews.html" %}
{% include "extensions/manage/components/add_previews.html" %}
</div>
</section>

View File

@ -85,6 +85,24 @@ EXPECTED_VALIDATION_ERRORS = {
'invalid-manifest-toml.zip': {'source': ['Could not parse the manifest file.']},
'invalid-theme-multiple-xmls.zip': {'source': ['A theme should have exactly one XML file.']},
}
POST_DATA = {
'preview_set-TOTAL_FORMS': ['0'],
'preview_set-INITIAL_FORMS': ['0'],
'preview_set-MIN_NUM_FORMS': ['0'],
'preview_set-MAX_NUM_FORMS': ['1000'],
'preview_set-0-id': [''],
# 'preview_set-0-extension': [str(extension.pk)],
'preview_set-1-id': [''],
# 'preview_set-1-extension': [str(extension.pk)],
'form-TOTAL_FORMS': ['0'],
'form-INITIAL_FORMS': ['0'],
'form-MIN_NUM_FORMS': ['0'],
'form-MAX_NUM_FORMS': ['1000'],
'form-0-id': '',
# 'form-0-caption': ['First Preview Caption Text'],
'form-1-id': '',
# 'form-1-caption': ['Second Preview Caption Text'],
}
class SubmitFileTest(TestCase):
@ -280,20 +298,19 @@ class SubmitFinaliseTest(TestCase):
def test_post_finalise_addon_validation_errors(self):
self.client.force_login(self.file.user)
response = self.client.post(self.file.get_submit_url(), {})
data = {**POST_DATA, 'submit_draft': ''}
response = self.client.post(self.file.get_submit_url(), data)
self.assertEqual(response.status_code, 200)
self.assertDictEqual(
_get_all_form_errors(response),
{
'form': {},
'extension_form': {
'description': ['This field is required.'],
},
'add_preview_formset': [],
'form': [{}, None],
'extension_form': [{'description': ['This field is required.']}, None],
'add_preview_formset': [[], ['Please add at least one preview.']],
'edit_preview_formset': [[], []],
},
)
self.assertFalse('TODO: It should include preview as required')
def test_post_finalise_addon_creates_addon_with_version_awaiting_review(self):
self.assertEqual(File.objects.count(), 1)
@ -318,6 +335,15 @@ class SubmitFinaliseTest(TestCase):
'form-0-caption': ['First Preview Caption Text'],
'form-1-id': '',
'form-1-caption': ['Second Preview Caption Text'],
# Edit form for existing previews
'preview_set-TOTAL_FORMS': ['0'],
'preview_set-INITIAL_FORMS': ['0'],
'preview_set-MIN_NUM_FORMS': ['0'],
'preview_set-MAX_NUM_FORMS': ['1000'],
'preview_set-0-id': [''],
# 'preview_set-0-extension': [str(extension.pk)],
'preview_set-1-id': [''],
# 'preview_set-1-extension': [str(extension.pk)],
# Submit for Approval.
'submit_draft': '',
}

View File

@ -3,6 +3,7 @@ from pathlib import Path
from django.test import TestCase
from common.tests.factories.extensions import create_approved_version, create_version
from common.tests.factories.files import FileFactory
from common.tests.utils import _get_all_form_errors
from extensions.models import Extension
from files.models import File
@ -201,6 +202,45 @@ class UpdateTest(TestCase):
response = self.client.post(url, {**data, **files})
self.assertEqual(response.status_code, 200)
self.maxDiff = None
self.assertEqual(
[
response.context['add_preview_formset'].forms[0].errors,
response.context['add_preview_formset'].forms[1].errors,
response.context['add_preview_formset'].non_form_errors(),
],
[
{},
{'__all__': ['Please correct the duplicate values below.']},
[
'Please select another file instead of the duplicate',
'Please select another file instead of the duplicate',
],
],
)
def test_post_upload_validation_error_image_already_exists(self):
FileFactory(
original_hash='sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
hash='sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
source='file/original_image_source.jpg',
)
extension = create_approved_version().extension
data = {
**POST_DATA,
'form-TOTAL_FORMS': ['1'],
}
file_name1 = 'test_preview_image_0001.png'
url = extension.get_manage_url()
user = extension.authors.first()
self.client.force_login(user)
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1:
files = {'form-0-source': fp1}
response = self.client.post(url, {**data, **files})
self.assertEqual(response.status_code, 200)
self.maxDiff = None
self.assertEqual(
response.context['add_preview_formset'].forms[0].errors,
{'source': ['File with this Hash already exists.']},

View File

@ -1,10 +1,8 @@
"""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, redirect, reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
@ -16,8 +14,6 @@ from .mixins import (
DraftVersionMixin,
)
from extensions.forms import (
EditPreviewFormSet,
AddPreviewFormSet,
ExtensionDeleteForm,
ExtensionUpdateForm,
VersionForm,
@ -26,7 +22,6 @@ from extensions.forms import (
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
@ -109,7 +104,12 @@ class UpdateExtensionView(
template_name = 'extensions/manage/update.html'
form_class = ExtensionUpdateForm
success_message = "Updated successfully"
msg_converted_to_draft = _('Converted to Draft')
def get_form_kwargs(self):
"""Pass request object to the form."""
kwargs = super().get_form_kwargs()
kwargs['request'] = self.request
return kwargs
def get(self, request, *args, **kwargs):
extension = self.extension
@ -124,62 +124,13 @@ class UpdateExtensionView(
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
context['edit_preview_formset'] = context['form'].edit_preview_formset
context['add_preview_formset'] = context['form'].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):
if 'convert_to_draft' in self.request.POST:
form.instance.status = form.instance.STATUSES.INCOMPLETE
form.save()
ApprovalActivity(
user=self.request.user,
extension=form.instance,
type=ApprovalActivity.ActivityType.AWAITING_CHANGES,
message=self.msg_converted_to_draft,
).save()
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)
def form_valid(self, *args, **kwargs):
return super().form_valid(*args, **kwargs)
class DeleteExtensionView(
@ -311,14 +262,6 @@ class NewVersionFinalizeView(LoginRequiredMixin, OwnsFileMixin, CreateView):
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'])
@ -374,7 +317,6 @@ class DraftExtensionView(
):
template_name = 'extensions/draft_finalise.html'
form_class = VersionForm
msg_awaiting_review = _('Ready for review')
@property
def success_message(self) -> str:
@ -398,60 +340,38 @@ class DraftExtensionView(
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):
def get_context_data(self, form=None, extension_form=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)
if not extension_form:
extension_form = ExtensionUpdateForm(instance=self.extension, request=self.request)
context['extension_form'] = extension_form
context['add_preview_formset'] = add_preview_formset
context['edit_preview_formset'] = extension_form.edit_preview_formset
context['add_preview_formset'] = extension_form.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
self.request.POST, self.request.FILES, instance=self.extension, request=self.request
)
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)
if form.is_valid() and extension_form.is_valid():
return self.form_valid(form, extension_form)
return self.form_invalid(form, extension_form)
@transaction.atomic
def form_valid(self, form, extension_form, add_preview_formset):
def form_valid(self, form, extension_form):
"""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=self.msg_awaiting_review,
).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)
extension_form.save()
form.save()
return super().form_valid(form)
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 form_invalid(self, form, extension_form):
return self.render_to_response(self.get_context_data(form, extension_form))
def get_success_url(self):
return self.extension.get_manage_url()

View File

@ -1,4 +1,5 @@
import os
import os.path
import shutil
import tempfile
import unittest
@ -12,7 +13,7 @@ import files.models
import files.tasks
@unittest.skipUnless(shutil.which('clamd'), 'requires clamd')
@unittest.skipUnless(os.path.exists('/var/run/clamav/clamd.ctl'), 'requires clamd running')
@override_settings(MEDIA_ROOT='/tmp/')
class FileScanTest(TestCase):
def setUp(self):

View File

@ -80,14 +80,16 @@ class Notification(models.Model):
def get_absolute_url(self):
if self.action.verb == Verb.RATED_EXTENSION:
url = self.action.target.get_ratings_url()
fragment = f'#review-{self.action.action_object.pk}'
url = self.action.target.get_ratings_url() + fragment
elif self.action.verb in [
Verb.APPROVED,
Verb.COMMENTED,
Verb.REQUESTED_CHANGES,
Verb.REQUESTED_REVIEW,
]:
url = self.action.target.get_review_url()
fragment = f'#activity-{self.action.action_object.pk}'
url = self.action.target.get_review_url() + fragment
elif self.action.action_object is not None:
url = self.action.action_object.get_absolute_url()
else:

View File

@ -78,9 +78,9 @@
<hr class="my-4">
<h2>Activity</h2>
{% if extension.review_activity.all %}
{% if review_activity %}
<ul class="activity-list">
{% for activity in extension.review_activity.all %}
{% for activity in review_activity %}
<li id="activity-{{ activity.id }}">
{% if activity.type in status_change_types %}

View File

@ -77,8 +77,18 @@ class ExtensionsApprovalDetailView(DetailView):
template_name = 'reviewers/extensions_review_detail.html'
def get_queryset(self):
return self.model.objects.prefetch_related(
'authors',
'previews',
'versions',
).all()
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['review_activity'] = (
self.object.review_activity.select_related('user').order_by('date_created').all()
)
ctx['status_change_types'] = STATUS_CHANGE_TYPES
if self.request.user.is_authenticated: