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
64 changed files with 1288 additions and 616 deletions
Showing only changes of commit 1d00ba5d8b - Show all commits

View File

@ -10,7 +10,7 @@ import abuse.models
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ReportForm(forms.ModelForm): class ReportExtensionForm(forms.ModelForm):
class Meta: class Meta:
model = abuse.models.AbuseReport model = abuse.models.AbuseReport
fields = ('reason', 'version', 'message') fields = ('reason', 'version', 'message')
@ -35,3 +35,9 @@ class ReportForm(forms.ModelForm):
return compare.version(self.cleaned_data[field]) return compare.version(self.cleaned_data[field])
except ValidationError as e: except ValidationError as e:
self.add_error(field, forms.ValidationError([e.message], code='invalid')) self.add_error(field, forms.ValidationError([e.message], code='invalid'))
class ReportRatingForm(forms.ModelForm):
class Meta:
model = abuse.models.AbuseReport
fields = ('reason', 'message')

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2024-05-06 13:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('abuse', '0006_remove_abusereport_date_deleted'),
]
operations = [
migrations.AlterField(
model_name='abusereport',
name='status',
field=models.PositiveSmallIntegerField(choices=[(1, 'Untriaged'), (2, 'Confirmed'), (3, 'Resolved')], default=1),
),
]

View File

@ -29,8 +29,8 @@ class AbuseReport(CreatedModifiedMixin, TrackChangesMixin, models.Model):
STATUSES = Choices( STATUSES = Choices(
('UNTRIAGED', 1, 'Untriaged'), ('UNTRIAGED', 1, 'Untriaged'),
('VALID', 2, 'Valid'), ('CONFIRMED', 2, 'Confirmed'),
('SUSPICIOUS', 3, 'Suspicious'), ('RESOLVED', 3, 'Resolved'),
) )
# NULL if the reporter is anonymous. # NULL if the reporter is anonymous.

View File

@ -18,7 +18,7 @@ urlpatterns = [
), ),
path( path(
'<slug:slug>/<str:version>/<int:rating>/report/', '<slug:slug>/<str:version>/<int:rating>/report/',
views.ReportReviewView.as_view(), views.ReportRatingView.as_view(),
name='report-ratings', name='report-ratings',
), ),
], ],

View File

@ -7,7 +7,7 @@ from django.views.generic.list import ListView
from django.views.generic.edit import CreateView from django.views.generic.edit import CreateView
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from .forms import ReportForm from .forms import ReportExtensionForm, ReportRatingForm
from constants.base import ABUSE_TYPE_EXTENSION, ABUSE_TYPE_RATING from constants.base import ABUSE_TYPE_EXTENSION, ABUSE_TYPE_RATING
from abuse.models import AbuseReport from abuse.models import AbuseReport
from ratings.models import Rating from ratings.models import Rating
@ -40,7 +40,7 @@ class ReportExtensionView(
CreateView, CreateView,
): ):
model = AbuseReport model = AbuseReport
form_class = ReportForm form_class = ReportExtensionForm
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
extension = get_object_or_404(Extension.objects.listed, slug=self.kwargs['slug']) extension = get_object_or_404(Extension.objects.listed, slug=self.kwargs['slug'])
@ -69,14 +69,14 @@ class ReportExtensionView(
return self.object.get_absolute_url() return self.object.get_absolute_url()
class ReportReviewView( class ReportRatingView(
LoginRequiredMixin, LoginRequiredMixin,
extensions.views.mixins.ListedExtensionMixin, extensions.views.mixins.ListedExtensionMixin,
UserPassesTestMixin, UserPassesTestMixin,
CreateView, CreateView,
): ):
model = AbuseReport model = AbuseReport
form_class = ReportForm form_class = ReportRatingForm
def test_func(self) -> bool: def test_func(self) -> bool:
# TODO: best to redirect to existing report or show a friendly message # TODO: best to redirect to existing report or show a friendly message

@ -1 +1 @@
Subproject commit af61a962e1a30898279b4efdbb07a2dcb230a257 Subproject commit 1126f102d8542ffb76af0269854048f276d9e50b

View File

@ -1,5 +1,27 @@
// Create function btnBack (function() {
function btnBack() { // Create function agreeWithTerms
function agreeWithTerms() {
const agreeWithTermsInput = document.querySelector('.js-agree-with-terms-input');
if (!agreeWithTermsInput) {
// Stop function execution if agreeWithTermsInput is not present
return;
}
agreeWithTermsInput.addEventListener('change', function(e) {
const agreeWithTermsBtnSubmit = document.querySelector('.js-agree-with-terms-btn-submit');
// Check if checkbox is checked
if (e.target.checked == true) {
agreeWithTermsBtnSubmit.removeAttribute('disabled');
} else {
agreeWithTermsBtnSubmit.setAttribute('disabled', true);
}
});
}
// Create function btnBack
function btnBack() {
const btnBack = document.querySelectorAll('.js-btn-back'); const btnBack = document.querySelectorAll('.js-btn-back');
btnBack.forEach(function(item) { btnBack.forEach(function(item) {
@ -8,10 +30,10 @@ function btnBack() {
window.history.back(); window.history.back();
}); });
}); });
} }
// Create finction commentForm // Create finction commentForm
function commentForm() { function commentForm() {
const commentForm = document.querySelector('.js-comment-form'); const commentForm = document.querySelector('.js-comment-form');
if (!commentForm) { if (!commentForm) {
return; return;
@ -27,7 +49,7 @@ function commentForm() {
let value = e.target.value; let value = e.target.value;
let verb = 'Comment'; let verb = 'Comment';
const activitySubmitButton = document.getElementById('activity-submit'); const activitySubmitButton = document.getElementById('activity-submit');
activitySubmitButton.classList.remove('btn-success', 'btn-warning'); activitySubmitButton.classList.remove('btn-primary', 'btn-success', 'btn-warning');
// Hide or show comment form msg on change // Hide or show comment form msg on change
if (value == 'AWC') { if (value == 'AWC') {
@ -38,14 +60,16 @@ function commentForm() {
} else if (value == 'APR') { } else if (value == 'APR') {
verb = 'Approve!'; verb = 'Approve!';
activitySubmitButton.classList.add('btn-success'); activitySubmitButton.classList.add('btn-success');
} else {
activitySubmitButton.classList.add('btn-primary');
} }
activitySubmitButton.querySelector('span').textContent = verb; activitySubmitButton.querySelector('span').textContent = verb;
}); });
} }
// Create function copyInstallUrl // Create function copyInstallUrl
function copyInstallUrl() { function copyInstallUrl() {
function init() { function init() {
// Create variables // Create variables
const btnInstall = document.querySelector('.js-btn-install'); const btnInstall = document.querySelector('.js-btn-install');
@ -86,14 +110,16 @@ function copyInstallUrl() {
} }
init(); init();
} }
// Create function init // Create function init
function init() { function init() {
agreeWithTerms();
btnBack(); btnBack();
commentForm(); commentForm();
copyInstallUrl(); copyInstallUrl();
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
init(); init();
}); });
}())

View File

@ -0,0 +1,4 @@
button,
.btn
&[type=submit]
transition: opacity var(--transition-speed)

View File

@ -27,7 +27,8 @@
.hero-overlay .hero-overlay
background-color: transparent background-color: transparent
background-image: linear-gradient(0deg, hsl(213, 10%, 12%), hsla(213, 10%, 14%, 0)) // --color-bg theme dark background-image: linear-gradient(0deg, var(--color-bg), hsla(213, 10%, 14%, 0))
&.extension-review &.extension-review
--hero-min-height: 21.0rem --hero-min-height: 21.0rem
@ -79,13 +80,6 @@
.ext-detail-tagline .ext-detail-tagline
+margin(2, bottom) +margin(2, bottom)
.ext-detail-description
+padding(4)
+style-rich-text
pre
+margin(3, bottom)
.ext-detail-info .ext-detail-info
dd dd
color: var(--color-text) color: var(--color-text)
@ -269,8 +263,6 @@
+margin(3, left) +margin(3, left)
details details
padding: 0
&[open] &[open]
.show-on-collapse .show-on-collapse
display: none display: none
@ -327,6 +319,8 @@
a a
color: var(--color-text) color: var(--color-text)
// TODO: @web-assets check arbitrary style table link display specificity
display: inline !important
+padding(1, y) +padding(1, y)
padding-inline: 0 !important padding-inline: 0 !important
@ -375,3 +369,7 @@
@extend .dropdown-divider @extend .dropdown-divider
+margin(0, top) +margin(0, top)
.dropdown-item
&a
+padding(3, x)

View File

@ -1,4 +1,7 @@
/* Aliases to use existing icons as permission slugs. */ /* Aliases to use existing icons as permission slugs. */
.i-permission-clipboard
@extend .i-copy
.i-permission-files .i-permission-files
@extend .i-folder @extend .i-folder

View File

@ -11,7 +11,12 @@
.form-control .form-control
&[type="file"] &[type="file"]
height: calc(var(--spacer) * 2.5) // TODO: @web-assets improve component style
height: calc(var(--spacer) * 3.5)
.invalid-feedback
ul
+padding(3, left)
/* Override Tagger's styling. */ /* Override Tagger's styling. */
.was-validated .form-control:invalid, .was-validated .form-control:invalid,

View File

@ -9,6 +9,11 @@
--nav-global-spacer-sm: var(--spacer-2) --nav-global-spacer-sm: var(--spacer-2)
--nav-global-spacer-xs: var(--spacer-1) --nav-global-spacer-xs: var(--spacer-1)
.btn
&:hover
background-color: var(--nav-global-color-button-bg-hover)
color: var(--nav-global-color-text-hover) !important
.btn-primary .btn-primary
color: var(--color-accent) !important color: var(--color-accent) !important

View File

@ -0,0 +1,13 @@
// TODO: remove style if 'mark as unread' is implemented
// .notifications-item
// &.is-read
// .dropdown-toggle
// color: var(--color-text-secondary) !important
//
// &:hover
// cursor: default
.notifications-item
.dropdown-item
padding-left: var(--spacer) !important
padding-right: var(--spacer) !important

View File

@ -37,6 +37,9 @@
.show .show
opacity: 1 opacity: 1
.style-rich-text
+style-rich-text
.text-accent .text-accent
color: var(--color-accent) color: var(--color-accent)

View File

@ -19,6 +19,7 @@ $container-width: map-get($container-max-widths, 'xl')
@import '_alert.sass' @import '_alert.sass'
@import '_badge.sass' @import '_badge.sass'
@import '_box.sass' @import '_box.sass'
@import '_button.sass'
@import '_cards.sass' @import '_cards.sass'
@import '_code.sass' @import '_code.sass'
@import '_comments.sass' @import '_comments.sass'

View File

@ -33,7 +33,8 @@
<body class="has-global-bar"> <body class="has-global-bar">
{% switch "is_alpha" %} {% switch "is_alpha" %}
<div class="site-announcement-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> </div>
{% else %} {% else %}
{% switch "is_beta" %} {% switch "is_beta" %}
@ -125,7 +126,7 @@
</li> </li>
{% block nav-upload %} {% block nav-upload %}
<li class="me-2"> <li>
<a href="{% url 'extensions:submit' %}" class="btn btn-primary"> <a href="{% url 'extensions:submit' %}" class="btn btn-primary">
<i class="i-upload"></i> <i class="i-upload"></i>
<span>Upload Extension</span> <span>Upload Extension</span>
@ -138,11 +139,13 @@
</li> </li>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a href="{% url 'notifications:notifications' %}"> <li>
<i class="i-bell {% if user|unread_notification_count %}text-primary{% endif %}"></i> <a class="btn btn-link px-2" href="{% url 'notifications:notifications' %}">
<i class="i-bell {% if user|unread_notification_count %}text-accent{% endif %}"></i>
</a> </a>
</li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<button id="navbarDropdown" aria-expanded="false" aria-haspopup="true" data-toggle-menu-id="nav-account-dropdown" role="button" class="nav-link dropdown-toggle js-dropdown-toggle"> <button id="navbarDropdown" aria-expanded="false" aria-haspopup="true" data-toggle-menu-id="nav-account-dropdown" role="button" class="nav-link dropdown-toggle js-dropdown-toggle pe-3 px-2">
<i class="i-user"></i> <i class="i-user"></i>
<i class="i-chevron-down"></i> <i class="i-chevron-down"></i>
</button> </button>
@ -205,7 +208,10 @@
</ul> </ul>
</li> </li>
{% elif page_id != 'login' and page_id != 'register' %} {% elif page_id != 'login' and page_id != 'register' %}
{% include "common/components/nav_item.html" with name="oauth:login" title="Sign in" %} <a href="{% url 'oauth:login' %}" class="btn btn-link">
<i class="i-log-in"></i>
<span>{% trans "Sign in" %}</span>
</a>
{% endif %} {% endif %}
<li> <li>

View File

@ -1,5 +1,7 @@
{% load common %}
{% spaceless %} {% spaceless %}
{% with type=field.field.widget.input_type %} {% with type=field.field.widget.input_type classes=classes|default:"" placeholder=placeholder|default:"" %}
{% with field=field|remove_cols_rows|add_classes:classes|set_placeholder:placeholder %}
{% firstof label field.label as label %} {% firstof label field.label as label %}
{% comment %} Checkboxes {% endcomment %} {% comment %} Checkboxes {% endcomment %}
@ -43,4 +45,5 @@
<div class="invalid-feedback">{{ field.errors }}</div> <div class="invalid-feedback">{{ field.errors }}</div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endwith %}
{% endspaceless %} {% endspaceless %}

View File

@ -29,17 +29,17 @@
<span>{{ status }}</span> <span>{{ status }}</span>
</div> </div>
{% elif 'untriaged' in status.lower %} {% elif 'confirmed' in status.lower %}
<div class="badge badge-danger {{ class }}"> <div class="badge badge-danger {{ class }}">
<span>{{ status }}</span> <span>{{ status }}</span>
</div> </div>
{% elif 'suspicious' in status.lower %} {% elif 'untriaged' in status.lower %}
<div class="badge badge-warning {{ class }}"> <div class="badge badge-warning {{ class }}">
<span>{{ status }}</span> <span>{{ status }}</span>
</div> </div>
{% elif 'valid' in status.lower %} {% elif 'resolved' in status.lower %}
<div class="badge badge-success {{ class }}"> <div class="badge badge-success {{ class }}">
<span>{{ status }}</span> <span>{{ status }}</span>
</div> </div>

View File

@ -2,6 +2,7 @@ from urllib.parse import urlparse
import json import json
import logging import logging
from django.forms.boundfield import BoundField
from django.template import Library, loader from django.template import Library, loader
from django.template.defaultfilters import stringfilter from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -166,3 +167,32 @@ def replace(value, old_char_new_char):
@register.filter(name='unread_notification_count') @register.filter(name='unread_notification_count')
def unread_notification_count(user): def unread_notification_count(user):
return Notification.objects.filter(recipient=user, read_at__isnull=True).count() return Notification.objects.filter(recipient=user, read_at__isnull=True).count()
@register.filter(name='add_classes')
def add_classes(bound_field: BoundField, classes: str):
"""Add classes to the `class` attribute of the given form field's widget.
Expects a string of space-separated classes.
"""
class_value = bound_field.field.widget.attrs.get('class', '')
bound_field.field.widget.attrs['class'] = class_value + f' {classes}'
return bound_field
@register.filter(name='set_placeholder')
def set_placeholder(bound_field: BoundField, placeholder: str):
"""Set `placeholder` attribute of the given form field's widget."""
bound_field.field.widget.attrs['placeholder'] = placeholder
return bound_field
@register.filter(name='remove_cols_rows')
def remove_cols_rows(bound_field: BoundField):
"""Removes cols and rows attributes from the form field's widget.
We'd rather go the CSS route when it comes to styling textareas.
"""
bound_field.field.widget.attrs.pop('cols', None)
bound_field.field.widget.attrs.pop('rows', None)
return bound_field

View File

@ -1,8 +1,19 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from factory.django import DjangoModelFactory from factory.django import DjangoModelFactory
from faker import Faker
import actstream.models
import factory import factory
from common.tests.factories.extensions import ExtensionFactory, RatingFactory
from common.tests.factories.reviewers import ApprovalActivityFactory
from common.tests.factories.users import UserFactory
from constants.activity import Verb
import notifications.models
import reviewers.models
RELATION_ALLOWED_MODELS = [] RELATION_ALLOWED_MODELS = []
fake = Faker()
def generic_foreign_key_id_for_type_factory(generic_relation_type_field): def generic_foreign_key_id_for_type_factory(generic_relation_type_field):
@ -17,3 +28,58 @@ def generic_foreign_key_id_for_type_factory(generic_relation_type_field):
class ContentTypeFactory(DjangoModelFactory): class ContentTypeFactory(DjangoModelFactory):
class Meta: class Meta:
model = ContentType model = ContentType
class ActionFactory(DjangoModelFactory):
class Meta:
model = actstream.models.Action
class NotificationFactory(DjangoModelFactory):
class Meta:
model = notifications.models.Notification
action = factory.SubFactory(ActionFactory)
def construct_fake_notifications() -> list['NotificationFactory']:
"""Construct notifications of known types without persisting them in the DB."""
fake_extension = ExtensionFactory.build(slug='test')
verb_to_action_object = {
Verb.APPROVED: ApprovalActivityFactory.build(
extension=fake_extension,
type=reviewers.models.ApprovalActivity.ActivityType.APPROVED,
message=fake.paragraph(nb_sentences=1),
),
Verb.COMMENTED: ApprovalActivityFactory.build(
extension=fake_extension,
type=reviewers.models.ApprovalActivity.ActivityType.COMMENT,
message=fake.paragraph(nb_sentences=1),
),
Verb.RATED_EXTENSION: RatingFactory.build(
text=fake.paragraph(nb_sentences=2),
),
Verb.REPORTED_EXTENSION: None, # TODO: fake action_object
Verb.REPORTED_RATING: None, # TODO: fake action_object
Verb.REQUESTED_CHANGES: ApprovalActivityFactory.build(
extension=fake_extension,
type=reviewers.models.ApprovalActivity.ActivityType.AWAITING_CHANGES,
message=fake.paragraph(nb_sentences=1),
),
Verb.REQUESTED_REVIEW: ApprovalActivityFactory.build(
extension=fake_extension,
type=reviewers.models.ApprovalActivity.ActivityType.AWAITING_REVIEW,
message=fake.paragraph(nb_sentences=1),
),
}
fake_notifications = [
NotificationFactory.build(
recipient=UserFactory.build(),
action__actor=UserFactory.build(),
action__target=fake_extension,
action__verb=verb,
action__action_object=action_object,
)
for verb, action_object in verb_to_action_object.items()
]
return fake_notifications

View File

@ -0,0 +1,8 @@
from factory.django import DjangoModelFactory
from reviewers.models import ApprovalActivity
class ApprovalActivityFactory(DjangoModelFactory):
class Meta:
model = ApprovalActivity

View File

@ -45,8 +45,8 @@ class UserFactory(DjangoModelFactory):
oauth_info = factory.RelatedFactory(OAuthUserInfoFactory, factory_related_name='user') oauth_info = factory.RelatedFactory(OAuthUserInfoFactory, factory_related_name='user')
def create_moderator(): def create_moderator(**kwargs):
user = UserFactory() user = UserFactory(**kwargs)
moderators = Group.objects.get(name='moderators') moderators = Group.objects.get(name='moderators')
user.groups.add(moderators) user.groups.add(moderators)
return user return user

View File

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

View File

@ -9,7 +9,7 @@ from django.utils.safestring import mark_safe
import django.core.mail import django.core.mail
from emails.models import Email from emails.models import Email
from emails.util import construct_email, get_template_context from emails.util import construct_email
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
@ -92,16 +92,15 @@ class EmailPreviewAdmin(NoAddDeleteMixin, EmailAdmin):
def _get_email_sent_message(self, obj): def _get_email_sent_message(self, obj):
return f'Sent a test email "{obj.subject}" to {obj.to} from {obj.from_email}' return f'Sent a test email "{obj.subject}" to {obj.to} from {obj.from_email}'
def get_object(self, request, object_id, from_field=None): def get_object(self, request, object_id, from_field=None, fake_context=None):
"""Construct the Email on th fly from known subscription email templates.""" """Construct the Email on the fly from known email templates."""
context = { if not fake_context:
'user': User(), fake_context = self._get_emails_with_fake_context(request)
**get_template_context(), context = fake_context[object_id]
} mail_name = context.get('template', object_id)
mail_name = object_id
email_body_html, email_body_txt, subject = construct_email(mail_name, context) email_body_html, email_body_txt, subject = construct_email(mail_name, context)
return EmailPreview( return EmailPreview(
id=mail_name, id=object_id,
subject=subject, subject=subject,
from_email=settings.DEFAULT_FROM_EMAIL, from_email=settings.DEFAULT_FROM_EMAIL,
reply_to=settings.DEFAULT_REPLY_TO_EMAIL, reply_to=settings.DEFAULT_REPLY_TO_EMAIL,
@ -109,10 +108,26 @@ class EmailPreviewAdmin(NoAddDeleteMixin, EmailAdmin):
message=email_body_txt, message=email_body_txt,
) )
def _get_emails_with_fake_context(self, request):
email_with_fake_context = {'feedback': {}}
if settings.DEBUG:
from common.tests.factories.notifications import construct_fake_notifications
fake_notifications = construct_fake_notifications()
for fake_notification in fake_notifications:
mail_name = fake_notification.original_template_name
email_with_fake_context[mail_name] = {
'template': fake_notification.template_name,
**fake_notification.get_template_context(),
}
return email_with_fake_context
def _get_emails_list(self, request): def _get_emails_list(self, request):
emails = [] emails = []
for mail_name in ('feedback',): fake_context = self._get_emails_with_fake_context(request)
emails.append(self.get_object(request, object_id=mail_name)) for mail_name in fake_context:
emails.append(self.get_object(request, object_id=mail_name, fake_context=fake_context))
return emails return emails
def _changeform_view(self, request, object_id, form_url, extra_context): def _changeform_view(self, request, object_id, form_url, extra_context):

View File

@ -1,16 +1,97 @@
{% extends "emails/email_base.html" %} {% spaceless %}
<html style="
height: 100%;
width: 100%;
padding: 0;
margin: 0;">
<head>
<style>
a {color: #0030aa;}
a:hover {color: #009eff}
</style>
</head>
<body style="
height: 100%;
width: 100%;
padding: 0;
margin: 0;">
<div style="
background-color: #E9ECEF;
color: #4C4D52;
text-align:center;
font-family: 'Lucida Grande', 'Helvetica Neue', 'Helvetica', 'Arial', 'Verdana', sans-serif;">
{% block header_logo %} <div class="header" style="
width: 100%;
padding-top: 15px;
padding-bottom: 15px;
background-color: #FFFFFF;
border-bottom: thin solid #E9ECEF;
">
<div class="container" style="
width: 90%;
max-width: 800px;
margin-left: auto; margin-right: auto;
font-family: 'Lucida Grande', 'Helvetica Neue', 'Helvetica', 'Arial', 'Verdana', sans-serif;
text-align: left;">
{% block header_logo %}
{# have a title instead of the logo with the remote image #} {# have a title instead of the logo with the remote image #}
<div style="text-align: center; font-weight: bold;">{{ subject }}</div> <div style="text-align: center; font-weight: bold;">{{ subject }}</div>
{% endblock header_logo %} {% endblock %}
<div style="clear:both"></div>
</div>
</div>
<div class="body" style="
width: 80%;
max-width: 800px;
background-color: #FFFFFF;
margin-top: 20px; margin-bottom: 20px;
margin-left: auto; margin-right: auto;
padding-top: 10px; padding-bottom: 10px;
padding-left: 20px; padding-right: 20px;
border: 1px solid #E9ECEF;
border-right: 1px solid #E9ECEF;
border-bottom: 1px solid #E9ECEF;
border-radius: 8px;
text-align: left;">
{% block body %} {% block body %}{% block content %}{% endblock content %}{% endblock body %}
<p>Dear {% firstof user.full_name user.email %},</p>
{% block content %}{% endblock content %} </div>
<p> <div class="footer" style="
--<br /> width: 100%;
Kind regards,<br /> color: #4C4D52;
Blender Extensions Team font-family: 'Lucida Sans Unicode', 'Lucida Grande', sans-serif;
font-size: 0.7em;
font-weight: lighter;
padding-top: 20px; padding-bottom: 20px;
background-color: #FFFFFF;
border-top: 1px solid #E9ECEF;
">
<div class="container" style="
width: 80%;
max-width: 800px;
margin-left: auto; margin-right: auto;
text-align: left;">
{% block footer_links %}
<p style="margin: auto; text-align: center">
Manage your profile here: <a href="{{ profile_url }}">{{ profile_url }}</a>
</p> </p>
{% endblock body %} {% endblock footer_links %}
{% block footer_brand %}
<p style="margin: auto; text-align: center; margin-top: 1em;">
<a href="{{ site_url }}" style="text-decoration: none; color: #4C4D52;">
<strong>BLENDER EXTENSIONS</strong>
</a>
</p>
{% endblock footer_brand %}
{% block footer %}{% endblock footer %}
</div>
</div>
</div>
</body>
</html>
{% endspaceless %}

View File

@ -1,9 +1,3 @@
Dear {% firstof user.full_name user.email %},
{% block content %}{% endblock content %} {% block content %}{% endblock content %}
Manage your profile: {{ profile_url }} Manage your profile: {{ profile_url }}
--
Kind regards,
Blender Extensions Team

View File

@ -0,0 +1,21 @@
{% spaceless %}{% load i18n %}
{% with target_type=target.get_type_display what=target|safe someone=action.actor verb=action.verb %}
{% if verb == Verb.APPROVED %}
{% blocktrans %}{{ someone }} {{ verb }} {{ what }}{% endblocktrans %}
{% elif action.verb == Verb.COMMENTED %}
{% blocktrans %}{{ someone }} {{ verb }} on {{ what }}{% endblocktrans %}
{% elif verb == Verb.RATED_EXTENSION %}
{% blocktrans %}{{ someone }} {{ verb }} {{ what }}{% endblocktrans %}
{% elif verb == Verb.REPORTED_EXTENSION %}
{% blocktrans %}{{ someone }} reported {{ what }}{% endblocktrans %}
{% elif verb == Verb.REPORTED_RATING %}
{% blocktrans %}{{ someone }} {{ verb }} of {{ what }}{% endblocktrans %}
{% elif verb == Verb.REQUESTED_CHANGES %}
{% blocktrans %}{{ someone }} {{ verb }} on {{ what }}{% endblocktrans %}
{% elif verb == Verb.REQUESTED_REVIEW %}
{% blocktrans %}{{ someone }} {{ verb }} of {{ what }}{% endblocktrans %}
{% else %}
{% blocktrans %}{{ someone }} {{ verb }} {{ what }}{% endblocktrans %}
{% endif %}
{% endwith %}
{% endspaceless %}

View File

@ -1,4 +1,4 @@
{% extends "emails/email_base.html" %} {% extends "emails/base.html" %}
{% block body %} {% block body %}
{{ email.html_message|safe }} {{ email.html_message|safe }}

View File

@ -1,95 +0,0 @@
<html style="
height: 100%;
width: 100%;
padding: 0;
margin: 0;">
<head>
<style>
a {color: #0030aa;}
a:hover {color: #009eff}
</style>
</head>
<body style="
height: 100%;
width: 100%;
padding: 0;
margin: 0;">
<div style="
background-color: #E9ECEF;
color: #4C4D52;
text-align:center;
font-family: 'Lucida Grande', 'Helvetica Neue', 'Helvetica', 'Arial', 'Verdana', sans-serif;">
<div class="header" style="
width: 100%;
padding-top: 15px;
padding-bottom: 15px;
background-color: #FFFFFF;
border-bottom: thin solid #E9ECEF;
">
<div class="container" style="
width: 90%;
max-width: 800px;
margin-left: auto; margin-right: auto;
font-family: 'Lucida Grande', 'Helvetica Neue', 'Helvetica', 'Arial', 'Verdana', sans-serif;
text-align: left;">
{% block header_logo %}
<a style="float: left; text-decoration: none; line-height: 0;" href="https://extensions.blender.org">
</a>
{% endblock %}
<div style="clear:both"></div>
</div>
</div>
<div class="body" style="
width: 80%;
max-width: 800px;
background-color: #FFFFFF;
margin-top: 20px; margin-bottom: 20px;
margin-left: auto; margin-right: auto;
padding-top: 10px; padding-bottom: 10px;
padding-left: 20px; padding-right: 20px;
border: 1px solid #E9ECEF;
border-right: 1px solid #E9ECEF;
border-bottom: 1px solid #E9ECEF;
border-radius: 8px;
text-align: left;">
{% block body %}{% endblock body %}
</div>
<div class="footer" style="
width: 100%;
color: #4C4D52;
font-family: 'Lucida Sans Unicode', 'Lucida Grande', sans-serif;
font-size: 0.7em;
font-weight: lighter;
padding-top: 20px; padding-bottom: 20px;
background-color: #FFFFFF;
border-top: 1px solid #E9ECEF;
">
<div class="container" style="
width: 80%;
max-width: 800px;
margin-left: auto; margin-right: auto;
text-align: left;">
{% block footer_links %}
<p style="margin: auto; text-align: center">
Manage your profile here: <a href="{{ profile_url }}">{{ profile_url }}</a>
</p>
{% endblock footer_links %}
{% block footer_brand %}
<p style="margin: auto; text-align: center; margin-top: 1em;">
<a href="{{ site_url }}" style="text-decoration: none; color: #4C4D52;">
<strong>BLENDER EXTENSIONS</strong>
</a>
</p>
{% endblock footer_brand %}
{% block footer %}{% endblock footer %}
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,24 @@
{% extends "emails/base.html" %}
{% block content %}{% spaceless %}
<div>
{% include "emails/components/new_activity_action" %}{% if quoted_message %}:{% endif %}
{% if quoted_message %}
<p>
<q>{{ quoted_message|truncatewords:15|truncatechars:140 }}</q>
</p>
{% endif %}
</div>
<p><a href="{{ url }}">View it at Blender Extensions</a></p>
<p>
Read all notifications at {{ notifications_url }}
</p>
{# TODO: store follow flags on Notifications, otherwise it's impossible to tell why this email is sent #}
{% comment %}
You are receiving this email because you are a moderator subscribed to notification emails.
You are receiving this email because you are subscribed to notifications on this extension.
{% endcomment %}
{% endspaceless %}{% endblock content %}
{% block footer_links %}{% spaceless %}
Unsubscribe by adjusting your preferences at {{ profile_url }}
{% endspaceless %}{% endblock footer_links %}

View File

@ -0,0 +1,21 @@
{% spaceless %}{% load i18n %}
{% with target_type=target.get_type_display what=target|safe name=target.name someone=action.actor verb=action.verb %}
{% if verb == Verb.APPROVED %}
{% blocktrans %}{{ target_type }} approved: "{{ name }}"{% endblocktrans %}
{% elif verb == Verb.COMMENTED %}
{% blocktrans %}New comment on {{ what }}{% endblocktrans %}
{% elif verb == Verb.RATED_EXTENSION %}
{% blocktrans %}{{ target_type }} rated: "{{ name }}"{% endblocktrans %}
{% elif verb == Verb.REPORTED_EXTENSION %}
{% blocktrans %}{{ target_type }} reported: "{{ name }}"{% endblocktrans %}
{% elif verb == Verb.REPORTED_RATING %}
{% blocktrans %}{{ target_type }} rating reported: "{{ name }}"{% endblocktrans %}
{% elif verb == Verb.REQUESTED_CHANGES %}
{% blocktrans %}{{ target_type }} changes requested: "{{ name }}"{% endblocktrans %}
{% elif verb == Verb.REQUESTED_REVIEW %}
{% blocktrans %}{{ target_type }} review requested: "{{ name }}"{% endblocktrans %}
{% else %}
{% blocktrans %}{{ someone }} {{ verb }} on {{ what }}{% endblocktrans %}
{% endif %}
{% endwith %}
{% endspaceless %}

View File

@ -1,35 +1,17 @@
"""Utilities for rendering email templates.""" """Utilities for rendering email templates."""
from typing import List, Optional, Tuple, Dict, Any from typing import List, Tuple, Dict, Any
import logging import logging
from django.conf import settings from django.conf import settings
from django.contrib.sites.shortcuts import get_current_site
from django.template import loader
from django.core.mail import get_connection, EmailMultiAlternatives from django.core.mail import get_connection, EmailMultiAlternatives
from django.urls import reverse from django.template import loader, TemplateDoesNotExist
from utils import absolute_url, html_to_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
def _get_site_url():
domain = get_current_site(None).domain
return f'https://{domain}'
def absolute_url(
view_name: str, args: Optional[tuple] = None, kwargs: Optional[dict] = None
) -> str:
"""Same as django.urls.reverse() but then as absolute URL.
For simplicity this assumes HTTPS is used.
"""
from urllib.parse import urljoin
relative_url = reverse(view_name, args=args, kwargs=kwargs)
return urljoin(_get_site_url(), relative_url)
def is_noreply(email: str) -> bool: def is_noreply(email: str) -> bool:
"""Return True if the email address is a no-reply address.""" """Return True if the email address is a no-reply address."""
return email.startswith('noreply@') or email.startswith('no-reply@') return email.startswith('noreply@') or email.startswith('no-reply@')
@ -38,8 +20,9 @@ def is_noreply(email: str) -> bool:
def get_template_context() -> Dict[str, str]: def get_template_context() -> Dict[str, str]:
"""Return additional context for use in an email template.""" """Return additional context for use in an email template."""
return { return {
'site_url': _get_site_url(), 'site_url': absolute_url('extensions:home'),
# 'profile_url': absolute_url('profile_update'), 'profile_url': absolute_url('users:my-profile'),
'notifications_url': absolute_url('notifications:notifications'),
'DEFAULT_REPLY_TO_EMAIL': settings.DEFAULT_REPLY_TO_EMAIL, 'DEFAULT_REPLY_TO_EMAIL': settings.DEFAULT_REPLY_TO_EMAIL,
} }
@ -47,8 +30,11 @@ def get_template_context() -> Dict[str, str]:
def construct_email(email_name: str, context: Dict[str, Any]) -> Tuple[str, str, str]: def construct_email(email_name: str, context: Dict[str, Any]) -> Tuple[str, str, str]:
"""Construct an email message. """Construct an email message.
If plain text template is not found, text version will be generated from the HTML of the email.
:return: tuple (html, text, subject) :return: tuple (html, text, subject)
""" """
context.update(**get_template_context())
base_path = 'emails' base_path = 'emails'
subj_tmpl, html_tmpl, txt_tmpl = ( subj_tmpl, html_tmpl, txt_tmpl = (
f'{base_path}/{email_name}_subject.txt', f'{base_path}/{email_name}_subject.txt',
@ -60,7 +46,11 @@ def construct_email(email_name: str, context: Dict[str, Any]) -> Tuple[str, str,
context['subject'] = subject.strip() context['subject'] = subject.strip()
email_body_html = loader.render_to_string(html_tmpl, context) email_body_html = loader.render_to_string(html_tmpl, context)
try:
email_body_txt = loader.render_to_string(txt_tmpl, context) email_body_txt = loader.render_to_string(txt_tmpl, context)
except TemplateDoesNotExist:
# Generate plain text content from the HTML one
email_body_txt = html_to_text(email_body_html)
return email_body_html, email_body_txt, context['subject'] return email_body_html, email_body_txt, context['subject']

View File

@ -202,8 +202,13 @@ class TagAdmin(admin.ModelAdmin):
return () return ()
class VersionPermissionAdmin(admin.ModelAdmin):
list_display = ('name', 'slug')
admin.site.register(models.Extension, ExtensionAdmin) admin.site.register(models.Extension, ExtensionAdmin)
admin.site.register(models.Version, VersionAdmin) admin.site.register(models.Version, VersionAdmin)
admin.site.register(models.Maintainer, MaintainerAdmin) admin.site.register(models.Maintainer, MaintainerAdmin)
admin.site.register(models.License, LicenseAdmin) admin.site.register(models.License, LicenseAdmin)
admin.site.register(models.Tag, TagAdmin) admin.site.register(models.Tag, TagAdmin)
admin.site.register(models.VersionPermission, VersionPermissionAdmin)

View File

@ -2,12 +2,14 @@ import logging
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import django.core.exceptions
from files.validators import FileMIMETypeValidator from files.validators import FileMIMETypeValidator
from constants.base import ALLOWED_PREVIEW_MIMETYPES from constants.base import ALLOWED_PREVIEW_MIMETYPES
import extensions.models import extensions.models
import files.models import files.models
import reviewers.models
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -41,7 +43,8 @@ class AddPreviewFileForm(forms.ModelForm):
class Meta: class Meta:
model = files.models.File model = files.models.File
fields = ('caption', 'source') fields = ('caption', 'source', 'original_hash', 'hash')
widgets = {'original_hash': forms.HiddenInput(), 'hash': forms.HiddenInput()}
source = forms.FileField( source = forms.FileField(
allow_empty_file=False, allow_empty_file=False,
@ -64,6 +67,27 @@ class AddPreviewFileForm(forms.ModelForm):
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'}) self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
super().__init__(*args, **kwargs) 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): def save(self, *args, **kwargs):
"""Save Preview from the cleaned form data.""" """Save Preview from the cleaned form data."""
# Fill in missing fields from request and the source file # Fill in missing fields from request and the source file
@ -81,6 +105,8 @@ class AddPreviewFileForm(forms.ModelForm):
class AddPreviewModelFormSet(forms.BaseModelFormSet): class AddPreviewModelFormSet(forms.BaseModelFormSet):
msg_duplicate_file = _('Please select another file instead of the duplicate')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request') self.request = kwargs.pop('request')
self.extension = kwargs.pop('extension') self.extension = kwargs.pop('extension')
@ -94,6 +120,14 @@ class AddPreviewModelFormSet(forms.BaseModelFormSet):
form_kwargs['extension'] = self.extension form_kwargs['extension'] = self.extension
return form_kwargs 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( AddPreviewFormSet = forms.modelformset_factory(
files.models.File, files.models.File,
@ -104,6 +138,16 @@ AddPreviewFormSet = forms.modelformset_factory(
class ExtensionUpdateForm(forms.ModelForm): 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: class Meta:
model = extensions.models.Extension model = extensions.models.Extension
fields = ( fields = (
@ -111,6 +155,79 @@ class ExtensionUpdateForm(forms.ModelForm):
'support', '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,
)
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 ExtensionDeleteForm(forms.ModelForm):
class Meta: class Meta:

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.11 on 2024-05-06 12:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extensions', '0027_unique_preview_files'),
]
operations = [
migrations.AlterField(
model_name='license',
name='slug',
field=models.SlugField(help_text='Should be taken from https://spdx.org/licenses/', unique=True),
),
migrations.AlterField(
model_name='versionpermission',
name='slug',
field=models.SlugField(help_text='Permissions add-ons are expected to need.', unique=True),
),
]

View File

@ -19,8 +19,6 @@ from constants.base import (
EXTENSION_TYPE_SLUGS, EXTENSION_TYPE_SLUGS,
FILE_STATUS_CHOICES, FILE_STATUS_CHOICES,
) )
from constants.licenses import ALL_LICENSES
from constants.version_permissions import ALL_VERSION_PERMISSIONS
import common.help_texts import common.help_texts
import extensions.fields import extensions.fields
@ -90,22 +88,13 @@ class License(CreatedModifiedMixin, models.Model):
blank=False, blank=False,
null=False, null=False,
help_text='Should be taken from https://spdx.org/licenses/', help_text='Should be taken from https://spdx.org/licenses/',
unique=True,
) )
url = models.URLField(blank=False, null=False) url = models.URLField(blank=False, null=False)
def __str__(self) -> str: def __str__(self) -> str:
return f'{self.name}' return f'{self.name}'
@classmethod
def generate(cls):
"""Generate License records from constants."""
licenses = [cls(id=li.id, name=li.name, slug=li.slug, url=li.url) for li in ALL_LICENSES]
cls.objects.bulk_create(licenses)
@classmethod
def get_by_name(cls, name: str):
return cls.objects.filter(name__startswith=name).first()
@classmethod @classmethod
def get_by_slug(cls, slug: str): def get_by_slug(cls, slug: str):
return cls.objects.filter(slug__startswith=slug).first() return cls.objects.filter(slug__startswith=slug).first()
@ -386,28 +375,16 @@ class VersionPermission(CreatedModifiedMixin, models.Model):
blank=False, blank=False,
null=False, null=False,
help_text='Permissions add-ons are expected to need.', help_text='Permissions add-ons are expected to need.',
unique=True,
) )
help = models.CharField(max_length=128, null=False, blank=False, unique=True) help = models.CharField(max_length=128, null=False, blank=False, unique=True)
def __str__(self) -> str: def __str__(self) -> str:
return f'{self.name}' return f'{self.name}'
@classmethod
def generate(cls):
"""Generate Permission records from constants."""
permissions = [
cls(id=li.id, name=li.name, slug=li.slug, help=li.help)
for li in ALL_VERSION_PERMISSIONS
]
cls.objects.bulk_create(permissions)
@classmethod
def get_by_name(cls, name: str):
return cls.objects.filter(name__startswith=name).first()
@classmethod @classmethod
def get_by_slug(cls, slug: str): def get_by_slug(cls, slug: str):
return cls.objects.filter(slug__startswith=slug).first() return cls.objects.get(slug=slug)
class Tag(CreatedModifiedMixin, models.Model): class Tag(CreatedModifiedMixin, models.Model):
@ -537,12 +514,7 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
return return
for permission_name in _permissions: for permission_name in _permissions:
permission = VersionPermission.get_by_name(permission_name) permission = VersionPermission.get_by_slug(permission_name)
# Just ignore versions that are incompatible.
if not permission:
continue
self.permissions.add(permission) self.permissions.add(permission)
def set_initial_licenses(self, _licenses): def set_initial_licenses(self, _licenses):

View File

@ -4,10 +4,10 @@
href="https://www.blender.org/download/releases/{{ version.blender_version_min|version_without_patch|replace:".,-" }}/" href="https://www.blender.org/download/releases/{{ version.blender_version_min|version_without_patch|replace:".,-" }}/"
title="{{ version.blender_version_min }}">Blender {{ version.blender_version_min|version_without_patch }}</a> title="{{ version.blender_version_min }}">Blender {{ version.blender_version_min|version_without_patch }}</a>
{% if is_editable %} {% if is_editable %}
&mdash; <span class="me-2">&mdash;</span>
<input name="blender_version_max" class="form-control-sm" <input name="blender_version_max" class="form-control"
value="{{version.blender_version_max|default_if_none:''}}" value="{{version.blender_version_max|default_if_none:''}}"
placeholder="{% trans 'maximum Blender version' %}" placeholder="{% trans 'max. Blender version' %}"
pattern="^([0-9]+\.[0-9]+\.[0-9]+)?$" pattern="^([0-9]+\.[0-9]+\.[0-9]+)?$"
title="{% trans 'Blender version, e.g. 4.1.0' %}" title="{% trans 'Blender version, e.g. 4.1.0' %}"
/> />
@ -25,4 +25,5 @@
{% else %} {% else %}
{% trans 'and newer' %} {% trans 'and newer' %}
{% endif %} {% endif %}
<a href="{{ version.extension.get_review_url }}?report_compatibility_issue&version={{ version.version }}#id_message" title="{% trans 'Report compatibility issue' %}"><i class="i-flag"></i></a>
{% endif %} {% endif %}

View File

@ -1,5 +1,5 @@
{% load common filters %} {% load common filters %}
{% with latest=extension.latest_version thumbnail_360p_url=extension.previews.listed.first.thumbnail_360p_url %} {% with latest=extension.latest_version thumbnail_360p_url=extension.get_previews.0.thumbnail_360p_url %}
<div class="cards-item"> <div class="cards-item">
<div class="cards-item-content"> <div class="cards-item-content">
<a href="{{ extension.get_absolute_url }}"> <a href="{{ extension.get_absolute_url }}">

View File

@ -76,7 +76,9 @@
<div class="dl-row"> <div class="dl-row">
<div class="dl-col"> <div class="dl-col">
<dt>{% trans 'Compatibility' %}</dt> <dt>{% trans 'Compatibility' %}</dt>
<dd>{% include "extensions/components/blender_version.html" with version=version is_editable=is_editable form=form %}</dd> <dd class="align-items-center d-flex">
{% include "extensions/components/blender_version.html" with version=version is_editable=is_editable form=form %}
</dd>
</div> </div>
</div> </div>

View File

@ -21,7 +21,7 @@
{% block extension_description %} {% block extension_description %}
{% if extension.description %} {% if extension.description %}
<section id="about" class="mt-3"> <section id="about" class="mt-3">
<div class="box ext-detail-description"> <div class="box style-rich-text">
{{ extension.description|markdown }} {{ extension.description|markdown }}
</div> </div>
</section> </section>
@ -42,7 +42,7 @@
</span> </span>
</summary> </summary>
<div class="px-4"> <div>
{{ latest.release_notes|markdown }} {{ latest.release_notes|markdown }}
</div> </div>
</details> </details>

View File

@ -45,7 +45,7 @@
{# TODO: fix handling of tags #} {# TODO: fix handling of tags #}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include "common/components/field.html" %} {% include "common/components/field.html" with placeholder="Enter the text here..." %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -55,7 +55,7 @@
<section class="mt-4"> <section class="mt-4">
<h2>{% trans 'Initial Version' %}</h2> <h2>{% trans 'Initial Version' %}</h2>
<div class="card p-3"> <div class="card p-3">
{% include "common/components/field.html" with field=form.release_notes %} {% include "common/components/field.html" with field=form.release_notes placeholder="Add the release notes..." %}
</div> </div>
</section> </section>
@ -69,7 +69,7 @@
highlight what makes your {{ type }} special. highlight what makes your {{ type }} special.
{% endblocktranslate %} {% endblocktranslate %}
</p> </p>
{% include "extensions/manage/components/edit_previews.html" %}
{% include "extensions/manage/components/add_previews.html" %} {% include "extensions/manage/components/add_previews.html" %}
</div> </div>
</section> </section>

View File

@ -17,7 +17,7 @@
</div> </div>
<div class="details flex-grow-1"> <div class="details flex-grow-1">
<div class="js-input-img-caption-helper mb-2"> <div class="js-input-img-caption-helper mb-2">
{% include "common/components/field.html" with field=inlineform.caption label='Caption' %} {% include "common/components/field.html" with field=inlineform.caption label='Caption' placeholder="Describe the preview" %}
</div> </div>
<div class="align-items-center d-flex js-input-img-helper justify-content-between"> <div class="align-items-center d-flex js-input-img-helper justify-content-between">
{% include "common/components/field.html" with field=inlineform.source label='File' %} {% include "common/components/field.html" with field=inlineform.source label='File' %}

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

@ -30,60 +30,18 @@
<section class="card p-3"> <section class="card p-3">
<div> <div>
{% include "common/components/field.html" with field=form.description label="Description" %} {% include "common/components/field.html" with field=form.description label="Description" placeholder="Describe the extension..." %}
</div> </div>
<div> <div>
{% include "common/components/field.html" with field=form.support %} {% include "common/components/field.html" with field=form.support placeholder="https://example.com" %}
</div> </div>
</section> </section>
<section class="mt-4"> <section class="mt-4">
<h2>{% trans 'Previews' %}</h2> <h2>{% trans 'Previews' %}</h2>
<div class="previews-upload"> <div class="previews-upload">
{{ edit_preview_formset.management_form }} {% include "extensions/manage/components/edit_previews.html" %}
{{ 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/add_previews.html" %} {% include "extensions/manage/components/add_previews.html" %}
</div> </div>
</section> </section>
@ -97,13 +55,22 @@
<section class="card p-3 mt-3"> <section class="card p-3 mt-3">
<div class="btn-col"> <div class="btn-col">
<button id="btn-save" type="submit" class="btn btn-primary"> <button id="btn-save" type="submit" class="btn btn-primary" name="save">
<i class="i-check"></i> <i class="i-check"></i>
<span> <span>
{% trans 'Save Changes' %} {% trans 'Save Changes' %}
</span> </span>
</button> </button>
{% if extension.status == extension.STATUSES.AWAITING_REVIEW %}
<button id="btn-convert-to-draft" type="submit" class="btn" name="convert_to_draft">
<i class="i-refresh"></i>
<span>
{% trans 'Convert to Draft' %}
</span>
</button>
{% endif %}
<hr> <hr>
<a href="{{ extension.get_new_version_url }}" class="btn"> <a href="{{ extension.get_new_version_url }}" class="btn">

View File

@ -25,7 +25,7 @@
<section class="card p-3"> <section class="card p-3">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include "common/components/field.html" with field=form.release_notes %} {% include "common/components/field.html" with field=form.release_notes placeholder="Add the release notes..." %}
</div> </div>
</div> </div>
{% if form.non_field_errors or form.file.errors %} {% if form.non_field_errors or form.file.errors %}

View File

@ -27,6 +27,16 @@
<a href="{{ extension.get_absolute_url }}"><strong>{{ extension.name }}</strong></a> <a href="{{ extension.get_absolute_url }}"><strong>{{ extension.name }}</strong></a>
</p> </p>
<hr> <hr>
{% if not extension and drafts %}
<div>
<span class="text-warning">You have unfinished drafts:</span>
<ul>
{% for d in drafts %}
<li><a href="{{ d.get_draft_url }}">{{ d }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
<p>Please make sure that:</p> <p>Please make sure that:</p>
<ul> <ul>
<li><strong>You are the creator or maintainer</strong> of this extension.</li> <li><strong>You are the creator or maintainer</strong> of this extension.</li>
@ -56,11 +66,11 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col mx-4 mt-4"> <div class="col mx-4 mt-4">
{% include "common/components/field.html" with field=form.agreed_with_terms %} {% include "common/components/field.html" with field=form.agreed_with_terms classes="js-agree-with-terms-input" %}
</div> </div>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<button type="submit" class="btn btn-block btn-primary px-5 py-2"> <button type="submit" class="btn btn-block btn-primary js-agree-with-terms-btn-submit px-5 py-2" disabled>
<i class="i-upload"></i> <i class="i-upload"></i>
<span> <span>
{% if extension %} {% if extension %}

View File

@ -42,7 +42,7 @@
<div class="row"> <div class="row">
<div class="col-md-7"> <div class="col-md-7">
<div class="px-4"> <div>
{% if version.release_notes %} {% if version.release_notes %}
<h3 class="mb-3">Changelog</h3> <h3 class="mb-3">Changelog</h3>
{{ version.release_notes|markdown }} {{ version.release_notes|markdown }}
@ -55,7 +55,7 @@
<section class="ext-detail-info box-outline mb-3"> <section class="ext-detail-info box-outline mb-3">
<div class="dl-row"> <div class="dl-row">
<div class="dl-col"> <div class="dl-col">
<dt>Compatibility</dt> <dt>{% trans 'Compatibility' %}</dt>
<dd>{% include "extensions/components/blender_version.html" with version=version %}</dd> <dd>{% include "extensions/components/blender_version.html" with version=version %}</dd>
</div> </div>
</div> </div>

View File

@ -23,11 +23,12 @@ EXPECTED_EXTENSION_DATA = {
'name': 'Edit Breakdown', 'name': 'Edit Breakdown',
'version': '0.1.0', 'version': '0.1.0',
'blender_version_min': '4.2.0', 'blender_version_min': '4.2.0',
'blender_version_max': '4.3.0',
'type': 'add-on', 'type': 'add-on',
'schema_version': "1.0.0", 'schema_version': "1.0.0",
}, },
'file_hash': 'sha256:4f3664940fc41641c7136a909270a024bbcfb2f8523a06a0d22f85c459b0b1ae', 'file_hash': 'sha256:28313858b9be34e6ecd15a63e28f626fb914dbdcc74c6d21c6536c9fad9de426',
'size_bytes': 53959, 'size_bytes': 53969,
'tags': ['Sequencer'], 'tags': ['Sequencer'],
'version_str': '0.1.0', 'version_str': '0.1.0',
'slug': 'edit-breakdown', 'slug': 'edit-breakdown',
@ -84,6 +85,24 @@ EXPECTED_VALIDATION_ERRORS = {
'invalid-manifest-toml.zip': {'source': ['Could not parse the manifest file.']}, '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.']}, '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): class SubmitFileTest(TestCase):
@ -279,20 +298,19 @@ class SubmitFinaliseTest(TestCase):
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)
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.assertEqual(response.status_code, 200)
self.assertDictEqual( self.assertDictEqual(
_get_all_form_errors(response), _get_all_form_errors(response),
{ {
'form': {}, 'form': [{}, None],
'extension_form': { 'extension_form': [{'description': ['This field is required.']}, None],
'description': ['This field is required.'], 'add_preview_formset': [[], ['Please add at least one preview.']],
}, 'edit_preview_formset': [[], []],
'add_preview_formset': [],
}, },
) )
self.assertFalse('TODO: It should include preview as required')
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)
@ -317,6 +335,15 @@ class SubmitFinaliseTest(TestCase):
'form-0-caption': ['First Preview Caption Text'], 'form-0-caption': ['First Preview Caption Text'],
'form-1-id': '', 'form-1-id': '',
'form-1-caption': ['Second Preview Caption Text'], '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 for Approval.
'submit_draft': '', 'submit_draft': '',
} }
@ -460,3 +487,15 @@ class NewVersionTest(TestCase):
).count(), ).count(),
1, 1,
) )
class DraftsWarningTest(TestCase):
fixtures = ['licenses']
def test_page_contains_warning(self):
version = create_version(extension__extension_id='draft_warning')
extension = version.extension
self.assertEqual(extension.status, Extension.STATUSES.INCOMPLETE)
self.client.force_login(extension.authors.all()[0])
response = self.client.get(reverse_lazy('extensions:submit'))
self.assertContains(response, extension.get_draft_url())

View File

@ -2,9 +2,12 @@ from pathlib import Path
from django.test import TestCase from django.test import TestCase
from common.tests.factories.extensions import create_approved_version 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 common.tests.utils import _get_all_form_errors
from extensions.models import Extension
from files.models import File from files.models import File
from reviewers.models import ApprovalActivity
TEST_FILES_DIR = Path(__file__).resolve().parent / 'files' TEST_FILES_DIR = Path(__file__).resolve().parent / 'files'
POST_DATA = { POST_DATA = {
@ -199,6 +202,45 @@ class UpdateTest(TestCase):
response = self.client.post(url, {**data, **files}) response = self.client.post(url, {**data, **files})
self.assertEqual(response.status_code, 200) 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( self.assertEqual(
response.context['add_preview_formset'].forms[0].errors, response.context['add_preview_formset'].forms[0].errors,
{'source': ['File with this Hash already exists.']}, {'source': ['File with this Hash already exists.']},
@ -280,3 +322,28 @@ class UpdateTest(TestCase):
response.context['add_preview_formset'].forms[0].errors, response.context['add_preview_formset'].forms[0].errors,
{'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']}, {'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']},
) )
def test_convert_to_draft(self):
version = create_version(extension__status=Extension.STATUSES.AWAITING_REVIEW)
extension = version.extension
url = extension.get_manage_url()
user = extension.authors.first()
self.client.force_login(user)
response = self.client.get(url)
self.assertContains(response, 'convert_to_draft')
response2 = self.client.post(
url,
{
**POST_DATA,
'convert_to_draft': '',
},
)
self.assertEqual(response2.status_code, 302)
extension.refresh_from_db()
self.assertEqual(extension.status, extension.STATUSES.INCOMPLETE)
self.assertEqual(
extension.review_activity.last().type, ApprovalActivity.ActivityType.AWAITING_CHANGES
)
response3 = self.client.get(url)
self.assertEqual(response3.status_code, 302)
self.assertEqual(response3['Location'], extension.get_draft_url())

View File

@ -1,10 +1,8 @@
"""Contains views allowing developers to manage their add-ons.""" """Contains views allowing developers to manage their add-ons."""
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin 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, redirect, reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
@ -14,11 +12,8 @@ from .mixins import (
OwnsFileMixin, OwnsFileMixin,
MaintainedExtensionMixin, MaintainedExtensionMixin,
DraftVersionMixin, DraftVersionMixin,
DraftMixin,
) )
from extensions.forms import ( from extensions.forms import (
EditPreviewFormSet,
AddPreviewFormSet,
ExtensionDeleteForm, ExtensionDeleteForm,
ExtensionUpdateForm, ExtensionUpdateForm,
VersionForm, VersionForm,
@ -27,7 +22,6 @@ from extensions.forms import (
from extensions.models import Extension, Version from extensions.models import Extension, Version
from files.forms import FileForm from files.forms import FileForm
from files.models import File from files.models import File
from reviewers.models import ApprovalActivity
from stats.models import ExtensionView from stats.models import ExtensionView
import ratings.models import ratings.models
@ -104,7 +98,6 @@ class UpdateExtensionView(
LoginRequiredMixin, LoginRequiredMixin,
MaintainedExtensionMixin, MaintainedExtensionMixin,
SuccessMessageMixin, SuccessMessageMixin,
DraftMixin,
UpdateView, UpdateView,
): ):
model = Extension model = Extension
@ -112,59 +105,32 @@ class UpdateExtensionView(
form_class = ExtensionUpdateForm form_class = ExtensionUpdateForm
success_message = "Updated successfully" success_message = "Updated successfully"
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
if extension.status == extension.STATUSES.INCOMPLETE:
return redirect('extensions:draft', slug=extension.slug, type_slug=extension.type_slug)
else:
return super().get(request, *args, **kwargs)
def get_success_url(self): def get_success_url(self):
self.object.refresh_from_db() self.object.refresh_from_db()
return self.object.get_manage_url() return self.object.get_manage_url()
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs) context = super().get_context_data(*args, **kwargs)
edit_preview_formset = kwargs.pop('edit_preview_formset', None) context['edit_preview_formset'] = context['form'].edit_preview_formset
add_preview_formset = kwargs.pop('add_preview_formset', None) context['add_preview_formset'] = context['form'].add_preview_formset
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 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 @transaction.atomic
def form_valid(self, form): def form_valid(self, *args, **kwargs):
edit_preview_formset = EditPreviewFormSet( return super().form_valid(*args, **kwargs)
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( class DeleteExtensionView(
@ -296,14 +262,6 @@ class NewVersionFinalizeView(LoginRequiredMixin, OwnsFileMixin, CreateView):
template_name = 'extensions/new_version_finalise.html' template_name = 'extensions/new_version_finalise.html'
form_class = VersionForm 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': def _get_extension(self) -> 'Extension':
return get_object_or_404(Extension, slug=self.kwargs['slug']) return get_object_or_404(Extension, slug=self.kwargs['slug'])
@ -359,7 +317,6 @@ class DraftExtensionView(
): ):
template_name = 'extensions/draft_finalise.html' template_name = 'extensions/draft_finalise.html'
form_class = VersionForm form_class = VersionForm
msg_awaiting_review = _('Extension is ready for initial review')
@property @property
def success_message(self) -> str: def success_message(self) -> str:
@ -383,60 +340,38 @@ class DraftExtensionView(
initial.update(**self.version.file.parsed_version_fields) initial.update(**self.version.file.parsed_version_fields)
return initial 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.""" """Add all the additional forms to the context."""
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
if not (add_preview_formset and extension_form): if not extension_form:
extension_form = ExtensionUpdateForm(instance=self.extension) extension_form = ExtensionUpdateForm(instance=self.extension, request=self.request)
add_preview_formset = AddPreviewFormSet(extension=self.extension, request=self.request)
context['extension_form'] = extension_form 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 return context
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Handle bound forms and valid/invalid logic with the extra forms.""" """Handle bound forms and valid/invalid logic with the extra forms."""
form = self.get_form() form = self.get_form()
extension_form = ExtensionUpdateForm( 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( if form.is_valid() and extension_form.is_valid():
self.request.POST, self.request.FILES, extension=self.extension, request=self.request return self.form_valid(form, extension_form)
) return self.form_invalid(form, extension_form)
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 @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. """Save all the forms in correct order.
Extension must be saved first. 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() extension_form.save()
add_preview_formset.save()
form.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) 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): def form_invalid(self, form, extension_form):
return self.render_to_response( return self.render_to_response(self.get_context_data(form, extension_form))
self.get_context_data(form, extension_form, add_preview_formset)
)
def get_success_url(self): def get_success_url(self):
return self.extension.get_manage_url() return self.extension.get_manage_url()

View File

@ -1,5 +1,5 @@
from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.auth.mixins import UserPassesTestMixin
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404
from extensions.models import Extension from extensions.models import Extension
from files.models import File from files.models import File
@ -60,27 +60,3 @@ class DraftVersionMixin:
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
self.version = self.extension.versions.first() self.version = self.extension.versions.first()
return super().dispatch(*args, **kwargs) return super().dispatch(*args, **kwargs)
class DraftMixin:
"""If the extension is incomplete, returns the FinalizeDraftView"""
def dispatch(self, request, *args, **kwargs):
if (
'slug' in kwargs
and Extension.objects.filter(
slug=kwargs['slug'], status=Extension.STATUSES.APPROVED
).first()
):
return super().dispatch(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

@ -4,7 +4,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction from django.db import transaction
from django.views.generic.edit import CreateView from django.views.generic.edit import CreateView
from .mixins import DraftMixin
from extensions.models import Version, Extension from extensions.models import Version, Extension
from files.forms import FileForm from files.forms import FileForm
from files.models import File from files.models import File
@ -12,11 +11,19 @@ from files.models import File
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class UploadFileView(LoginRequiredMixin, DraftMixin, CreateView): class UploadFileView(LoginRequiredMixin, CreateView):
model = File model = File
template_name = 'extensions/submit.html' template_name = 'extensions/submit.html'
form_class = FileForm form_class = FileForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
drafts = Extension.objects.authored_by(user_id=self.request.user.pk).filter(
status=Extension.STATUSES.INCOMPLETE
)
context['drafts'] = drafts
return context
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs['request'] = self.request kwargs['request'] = self.request

View File

@ -1,4 +1,5 @@
import os import os
import os.path
import shutil import shutil
import tempfile import tempfile
import unittest import unittest
@ -12,7 +13,7 @@ import files.models
import files.tasks 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/') @override_settings(MEDIA_ROOT='/tmp/')
class FileScanTest(TestCase): class FileScanTest(TestCase):
def setUp(self): def setUp(self):

View File

@ -54,8 +54,7 @@ class FileMIMETypeValidator:
class ExtensionIDManifestValidator: class ExtensionIDManifestValidator:
""" """Make sure the extension id is valid:
Make sure the extension id is valid:
* Extension id consists of Unicode letters, numbers or underscores. * Extension id consists of Unicode letters, numbers or underscores.
* Neither hyphens nor spaces are supported. * Neither hyphens nor spaces are supported.
* Each extension id most be unique across all extensions. * Each extension id most be unique across all extensions.
@ -307,8 +306,9 @@ class PermissionsValidator:
is_error = True is_error = True
else: else:
for permission in value: for permission in value:
if VersionPermission.get_by_slug(permission): try:
continue VersionPermission.get_by_slug(permission)
except VersionPermission.DoesNotExist:
is_error = True is_error = True
logger.info(f'Permission unavailable: {permission}') logger.info(f'Permission unavailable: {permission}')

View File

@ -1,11 +1,10 @@
"""Send user notifications as emails, at most once delivery.""" """Send user notifications as emails, at most once delivery."""
import logging import logging
from django.conf import settings
from django.core.mail import send_mail
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from emails.util import construct_and_send_email
from notifications.models import Notification from notifications.models import Notification
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -36,12 +35,6 @@ class Command(BaseCommand):
def send_notification_email(notification): def send_notification_email(notification):
# TODO construct a proper phrase, depending on the verb, template_name = notification.template_name
# possibly share a template with NotificationsView context = notification.get_template_context()
subject, message = notification.format_email() construct_and_send_email(template_name, context, recipient_list=[notification.recipient.email])
send_mail(
subject,
message,
settings.DEFAULT_FROM_EMAIL,
[notification.recipient.email],
)

View File

@ -1,11 +1,15 @@
import logging
from actstream.models import Action from actstream.models import Action
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.template import loader, TemplateDoesNotExist
from constants.activity import Verb from constants.activity import Verb
from utils import absolutify from utils import absolutify
User = get_user_model() User = get_user_model()
logger = logging.getLogger(__name__)
class Notification(models.Model): class Notification(models.Model):
@ -30,23 +34,62 @@ class Notification(models.Model):
] ]
unique_together = ['recipient', 'action'] unique_together = ['recipient', 'action']
def format_email(self): @property
def original_template_name(self) -> str:
"""Template name constructed from action type, target and so on.
If we want to override email template for this specific notification,
this is the name of the template that should be created.
"""
action = self.action action = self.action
subject = f'New Activity: {action.actor} {action.verb} {action.target}' action_object = action.action_object
url = self.get_absolute_url() target_name = action.target.__class__.__name__.lower()
mesage = f'{action.actor} {action.verb} {action.target}: {url}' action_object_name = action_object.__class__.__name__.lower() if action_object else ''
return (subject, mesage) return f"new_activity_{action_object_name}_{target_name}_{action.verb.replace(' ', '_')}"
@property
def template_name(self) -> str:
"""Template name to be used for constructing notification email."""
default_name = 'new_activity'
name = self.original_template_name
args = {'name': name, 'default_name': default_name}
try:
loader.get_template(f'emails/{name}.html')
except TemplateDoesNotExist:
logger.warning('Template %(name)s does not exist, using %(default_name)s', args)
return default_name
return name
def get_template_context(self):
"""Return template context to be used in the notification email."""
action = self.action
action_object = action.action_object
quoted_message = getattr(action_object, 'message', getattr(action_object, 'text', ''))
context = {
'Verb': Verb,
'action': action,
'action_object': action_object,
'notification': self,
'quoted_message': quoted_message,
'target': action.target,
'url': self.get_absolute_url(),
'user': self.recipient,
}
return context
def get_absolute_url(self): def get_absolute_url(self):
if self.action.verb == Verb.RATED_EXTENSION: 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 [ elif self.action.verb in [
Verb.APPROVED, Verb.APPROVED,
Verb.COMMENTED, Verb.COMMENTED,
Verb.REQUESTED_CHANGES, Verb.REQUESTED_CHANGES,
Verb.REQUESTED_REVIEW, 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: elif self.action.action_object is not None:
url = self.action.action_object.get_absolute_url() url = self.action.action_object.get_absolute_url()
else: else:

View File

@ -4,45 +4,64 @@
{% block page_title %}{% blocktranslate %}Notifications{% endblocktranslate %}{% endblock page_title %} {% block page_title %}{% blocktranslate %}Notifications{% endblocktranslate %}{% endblock page_title %}
{% block content %} {% block content %}
<h1> <h1>{% trans 'Notifications' %}</h1>
{% trans 'Notifications' %}
{% if notification_list %}
<div class="notifications">
{% if user|unread_notification_count %} {% if user|unread_notification_count %}
<form class="d-inline" action="{% url 'notifications:notifications-mark-read-all' %}" method="post"> <form action="{% url 'notifications:notifications-mark-read-all' %}" method="post">
{% csrf_token %} {% csrf_token %}
<button class="btn btn-sm" type="submit">{% trans 'Mark all as read' %}</button> <button class="btn mb-3" type="submit"><i class="i-eye"></i> {% trans 'Mark all as read' %}</button>
</form> </form>
{% endif %} {% endif %}
</h1> <div class="box">
{% if notification_list %} <table class="notifications-list">
<tbody>
{% for notification in notification_list %} {% for notification in notification_list %}
<div class="row mb-2 {% if notification.read_at%}text-muted{% endif %}"> <tr class="notifications-item {% if notification.read_at%}is-read{% endif %}">
<div class="col"> <td class="notifications-item-time">
{{ notification.action.timestamp | naturaltime_compact }} {{ notification.action.timestamp | naturaltime_compact }}
</td>
<a href="{% url 'extensions:by-author' user_id=notification.action.actor.pk %}"> <td class="notifications-item-content">
{{ notification.action.actor }} {# TODO: @back-end add link to action target ID (so that link works as an anchor link) #}
</a> <a href="{{ notification.get_absolute_url }}"><span class="me-2">{{ notification.action.actor }} {{ notification.action.verb }} {{ notification.action.target }}</span><span class="notifications-item-dot"></span></a>
</td>
{{ notification.action.verb }} <td class="notifications-item-nav">
<div class="dropdown">
<a href="{{ notification.action.target.get_absolute_url }}">{{ notification.action.target }}</a> <button class="btn btn-link dropdown-toggle js-dropdown-toggle active" data-toggle-menu-id="js-notifications-item-nav-{{ notification.id }}">
<i class="i-more-vertical"></i>
<a href="{{ notification.get_absolute_url }}"><button class="btn btn-sm">{% trans 'View' %}</button></a> </button>
<ul class="dropdown-menu dropdown-menu-right js-dropdown-menu" id="js-notifications-item-nav-{{ notification.id }}">
{% if not notification.read_at %} <li class="nav-item-mark-as-read">
<form class="d-inline" action="{% url 'notifications:notifications-mark-read' pk=notification.pk %}" method="post"> <form action="{% url 'notifications:notifications-mark-read' pk=notification.pk %}" method="post">
{% csrf_token %} {% csrf_token %}
<button class="btn btn-sm" type="submit">{% trans 'Mark as read' %}</button> <button class="dropdown-item" title="Mark as read" type="submit"><i class="i-eye"></i> Mark as read </button>
</form> </form>
{% endif %} </li>
{# TODO: add feature 'Mark as unread' (optional) #}
</div> {% comment %}
<li class="nav-item-mark-as-unread">
<form>
<button class="dropdown-item" title="Mark as read" type="submit"><i class="i-eye-off"></i> Mark as unread </button>
</form>
</li>
{% endcomment %}
<li>
<a class="dropdown-item" href="{% url 'extensions:by-author' user_id=notification.action.actor.pk %}"><i class="i-user"></i> View user</a>
</li>
</ul>
</div> </div>
</td>
</tr>
{% endfor %} {% endfor %}
{% else %} </tbody>
<p> </table>
</div>
</div>
{% else %}
<p>
{% trans 'You have no notifications' %} {% trans 'You have no notifications' %}
</p> </p>
{% endif %} {% endif %}
{% endblock content %} {% endblock content %}

View File

@ -1,7 +1,10 @@
from pathlib import Path from pathlib import Path
from django.core import mail
from django.core.management import call_command
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from common.tests.factories.extensions import create_approved_version, create_version from common.tests.factories.extensions import create_approved_version, create_version
from common.tests.factories.files import FileFactory from common.tests.factories.files import FileFactory
@ -17,7 +20,9 @@ class TestTasks(TestCase):
fixtures = ['dev', 'licenses'] fixtures = ['dev', 'licenses']
def test_ratings(self): def test_ratings(self):
extension = create_approved_version(ratings=[]).extension extension = create_approved_version(
ratings=[], file__user__confirmed_email_at=timezone.now()
).extension
author = extension.authors.first() author = extension.authors.first()
notification_nr = Notification.objects.filter(recipient=author).count() notification_nr = Notification.objects.filter(recipient=author).count()
some_user = UserFactory() some_user = UserFactory()
@ -29,14 +34,35 @@ class TestTasks(TestCase):
new_notification_nr = Notification.objects.filter(recipient=author).count() new_notification_nr = Notification.objects.filter(recipient=author).count()
self.assertEqual(new_notification_nr, notification_nr + 1) self.assertEqual(new_notification_nr, notification_nr + 1)
# Call the command that sends notification emails
call_command('send_notification_emails')
# Test that one message has been sent.
self.assertEqual(len(mail.outbox), 1)
email = mail.outbox[0]
self.assertEqual(email.subject, f'Add-on rated: "{extension.name}"')
self.assertEqual(email.to, [author.email])
email_text = email.body
self.maxDiff = None
expected_text = expected_rated_text.format(**locals())
self.assertEqual(email_text, expected_text)
# Check that most important action and relevant URL are in the HTML version
email_html = email.alternatives[0][0]
self.assertIn(' rated ', email_html)
self.assertIn(
f'https://extensions.local:8111/add-ons/{extension.slug}/reviews/', email_html
)
def test_abuse(self): def test_abuse(self):
extension = create_approved_version(ratings=[]).extension extension = create_approved_version(ratings=[]).extension
moderator = create_moderator() moderator = create_moderator(confirmed_email_at=timezone.now())
notification_nr = Notification.objects.filter(recipient=moderator).count() notification_nr = Notification.objects.filter(recipient=moderator).count()
some_user = UserFactory() some_user = UserFactory()
self.client.force_login(some_user) self.client.force_login(some_user)
url = extension.get_report_url() url = extension.get_report_url()
self.client.post( response = self.client.post(
url, url,
{ {
'message': 'test message', 'message': 'test message',
@ -44,11 +70,31 @@ class TestTasks(TestCase):
'version': '', 'version': '',
}, },
) )
self.assertEqual(response.status_code, 302)
report_url = response['Location']
new_notification_nr = Notification.objects.filter(recipient=moderator).count() new_notification_nr = Notification.objects.filter(recipient=moderator).count()
self.assertEqual(new_notification_nr, notification_nr + 1) self.assertEqual(new_notification_nr, notification_nr + 1)
call_command('send_notification_emails')
# Test that one message has been sent.
self.assertEqual(len(mail.outbox), 1)
email = mail.outbox[0]
self.assertEqual(email.subject, f'Add-on reported: "{extension.name}"')
self.assertEqual(email.to, [moderator.email])
email_text = email.body
self.maxDiff = None
expected_text = expected_abuse_report_text.format(**locals())
self.assertEqual(email_text, expected_text)
# Check that most important action and relevant URL are in the HTML version
email_html = email.alternatives[0][0]
self.assertIn(report_url, email_html)
self.assertIn(' reported ', email_html)
def test_new_extension_submitted(self): def test_new_extension_submitted(self):
moderator = create_moderator() moderator = create_moderator(confirmed_email_at=timezone.now())
notification_nr = Notification.objects.filter(recipient=moderator).count() notification_nr = Notification.objects.filter(recipient=moderator).count()
some_user = UserFactory() some_user = UserFactory()
file_data = { file_data = {
@ -117,10 +163,29 @@ class TestTasks(TestCase):
new_notification_nr = Notification.objects.filter(recipient=moderator).count() new_notification_nr = Notification.objects.filter(recipient=moderator).count()
self.assertEqual(new_notification_nr, notification_nr + 1) self.assertEqual(new_notification_nr, notification_nr + 1)
call_command('send_notification_emails')
# Test that one message has been sent.
self.assertEqual(len(mail.outbox), 1)
email = mail.outbox[0]
extension = file.version.extension
self.assertEqual(email.subject, 'Add-on review requested: "Edit Breakdown"')
self.assertEqual(email.to, [moderator.email])
email_text = email.body
self.maxDiff = None
expected_text = expected_review_requested_text.format(**locals())
self.assertEqual(email_text, expected_text)
# Check that most important action and relevant URL are in the HTML version
email_html = email.alternatives[0][0]
self.assertIn(' requested review of ', email_html)
self.assertIn(f'https://extensions.local:8111/approval-queue/{extension.slug}/', email_html)
def test_approval_queue_activity(self): def test_approval_queue_activity(self):
extension = create_approved_version(ratings=[]).extension extension = create_approved_version(ratings=[]).extension
author = extension.authors.first() author = extension.authors.first()
moderator = create_moderator() moderator = create_moderator(confirmed_email_at=timezone.now())
some_user = UserFactory() some_user = UserFactory()
notification_nrs = {} notification_nrs = {}
for user in [author, moderator, some_user]: for user in [author, moderator, some_user]:
@ -136,7 +201,88 @@ class TestTasks(TestCase):
self.assertEqual(new_notification_nrs[moderator.pk], notification_nrs[moderator.pk] + 1) self.assertEqual(new_notification_nrs[moderator.pk], notification_nrs[moderator.pk] + 1)
self.assertEqual(new_notification_nrs[some_user.pk], notification_nrs[some_user.pk] + 1) self.assertEqual(new_notification_nrs[some_user.pk], notification_nrs[some_user.pk] + 1)
call_command('send_notification_emails')
# Test that one message has been sent (only moderator has email confirmed here).
self.assertEqual(len(mail.outbox), 1)
email = mail.outbox[0]
self.assertEqual(email.subject, f'New comment on Add-on "{extension.name}"')
email_text = email.body
expected_text = expected_new_comment_text.format(**locals())
self.maxDiff = None
self.assertEqual(email_text, expected_text)
# Check that most important action and relevant URL are in the HTML version
email_html = email.alternatives[0][0]
self.assertIn(' commented on ', email_html)
self.assertIn(f'https://extensions.local:8111/approval-queue/{extension.slug}/', email_html)
def _leave_a_comment(self, user, extension, text): def _leave_a_comment(self, user, extension, text):
self.client.force_login(user) self.client.force_login(user)
url = reverse('reviewers:approval-comment', args=[extension.slug]) url = reverse('reviewers:approval-comment', args=[extension.slug])
self.client.post(url, {'type': ApprovalActivity.ActivityType.COMMENT, 'message': text}) self.client.post(url, {'type': ApprovalActivity.ActivityType.COMMENT, 'message': text})
# TODO: test for notifications about a reported rating
# TODO: test for notifications about extension approved by moderators
expected_abuse_report_text = """Add-on reported: "{extension.name}"
{some_user.full_name} reported Add-on "{extension.name}"
:
test message
https://extensions.local:8111{report_url}
Read all notifications at https://extensions.local:8111/notifications/
Unsubscribe by adjusting your preferences at https://extensions.local:8111/settings/profile/
https://extensions.local:8111/
"""
expected_new_comment_text = """New comment on Add-on "{extension.name}"
{some_user.full_name} commented on Add-on "{extension.name}"
:
this is bad
https://extensions.local:8111/approval-queue/{extension.slug}/
Read all notifications at https://extensions.local:8111/notifications/
Unsubscribe by adjusting your preferences at https://extensions.local:8111/settings/profile/
https://extensions.local:8111/
"""
expected_rated_text = """Add-on rated: "{extension.name}"
{some_user.full_name} rated extension Add-on "{extension.name}"
:
rating text
https://extensions.local:8111/add-ons/{extension.slug}/reviews/
Read all notifications at https://extensions.local:8111/notifications/
Unsubscribe by adjusting your preferences at https://extensions.local:8111/settings/profile/
https://extensions.local:8111/
"""
expected_review_requested_text = """Add-on review requested: "Edit Breakdown"
{some_user.full_name} requested review of Add-on "Edit Breakdown"
:
Extension is ready for initial review
https://extensions.local:8111/approval-queue/edit-breakdown/
Read all notifications at https://extensions.local:8111/notifications/
Unsubscribe by adjusting your preferences at https://extensions.local:8111/settings/profile/
https://extensions.local:8111/
"""

View File

@ -20,7 +20,7 @@ and will most likely fail due to differences in configuration paths and so on.
To avoid adding more dependencies to the project itself, `ansible` uses its own `virtualenv`. To avoid adding more dependencies to the project itself, `ansible` uses its own `virtualenv`.
To set it up use the following commands: To set it up use the following commands:
virtualenv .venv -p python python3.10 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt

View File

@ -14,7 +14,7 @@
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-8">
<div class="box p-3"> <div class="box p-3">
{% include "common/components/field.html" with field=form.text focus=True %} {% include "common/components/field.html" with field=form.text focus=True placeholder="Enter the text here..." %}
{% if form.non_field_errors %} {% if form.non_field_errors %}
<div class="invalid-feedback"> <div class="invalid-feedback">

View File

@ -70,24 +70,6 @@
{% block extension_galleria %} {% block extension_galleria %}
{% include "extensions/components/galleria.html" with extension=extension %} {% include "extensions/components/galleria.html" with extension=extension %}
{% if pending_previews %}
<section class="my-3 box p-3">
<h3>Previews Pending Approval</h3>
<div class="row">
{% for preview in pending_previews %}
{% with thumbnail_1080p_url=preview.file.thumbnail_1080p_url %}
<div class="col-md-3">
<a href="{{ preview.file.source.url }}" class="d-block mb-2" title="{{ preview.caption }}" target="_blank">
<img class="img-fluid rounded" src="{{ thumbnail_1080p_url }}" alt="{{ preview.caption }}">
</a>
{% include "common/components/status.html" with object=preview.file class="d-block" %}
</div>
{% endwith %}
{% endfor %}
</div>
</section>
{% endif %}
{% endblock extension_galleria %} {% endblock extension_galleria %}
@ -96,9 +78,9 @@
<hr class="my-4"> <hr class="my-4">
<h2>Activity</h2> <h2>Activity</h2>
{% if extension.review_activity.all %} {% if review_activity %}
<ul class="activity-list"> <ul class="activity-list">
{% for activity in extension.review_activity.all %} {% for activity in review_activity %}
<li id="activity-{{ activity.id }}"> <li id="activity-{{ activity.id }}">
{% if activity.type in status_change_types %} {% if activity.type in status_change_types %}
@ -163,13 +145,11 @@
{% csrf_token %} {% csrf_token %}
{% with form=comment_form|add_form_classes %} {% with form=comment_form|add_form_classes %}
{% include "common/components/field.html" with field=form.message %} {% include "common/components/field.html" with field=form.message placeholder="Enter the text here..." %}
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="btn-row ms-3 w-100 justify-content-end"> <div class="btn-row ms-3 w-100 justify-content-end">
{% if is_maintainer or request.user.is_moderator %}
{% include "common/components/field.html" with field=form.type %} {% include "common/components/field.html" with field=form.type %}
{% endif %}
<button type="submit" id="activity-submit" class="btn btn-primary"> <button type="submit" id="activity-submit" class="btn btn-primary">
<span>{% trans "Comment" %}</span> <span>{% trans "Comment" %}</span>

View File

@ -1,3 +1,4 @@
from collections import defaultdict
import logging import logging
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@ -6,17 +7,17 @@ from django.views.generic import DetailView, FormView
from django.shortcuts import reverse from django.shortcuts import reverse
import django.forms import django.forms
from files.models import File
from extensions.models import Extension from extensions.models import Extension
from reviewers.forms import CommentForm from reviewers.forms import CommentForm
from reviewers.models import ApprovalActivity from reviewers.models import ApprovalActivity
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# the ordering of this list determines the order of rows in approval queue listing
STATUS_CHANGE_TYPES = [ STATUS_CHANGE_TYPES = [
ApprovalActivity.ActivityType.APPROVED,
ApprovalActivity.ActivityType.AWAITING_CHANGES,
ApprovalActivity.ActivityType.AWAITING_REVIEW, ApprovalActivity.ActivityType.AWAITING_REVIEW,
ApprovalActivity.ActivityType.AWAITING_CHANGES,
ApprovalActivity.ActivityType.APPROVED,
] ]
@ -37,26 +38,35 @@ class ApprovalQueueView(ListView):
.all() .all()
) )
by_extension = {} by_extension = {}
result = [] by_date_created = []
for item in qs: for item in qs:
extension = item.extension extension = item.extension
stats = by_extension.get(extension, None) stats = by_extension.get(extension, None)
if not stats: if not stats:
# this check guarantees that we add a record only once per extension, # this check guarantees that we add a record only once per extension,
# and iterating over qs we get result also ordered by item.date_created # and iterating over qs we get by_date_created also ordered by item.date_created
stats = { stats = {
'count': 0, 'count': 0,
'extension': extension, 'extension': extension,
'last_activity': None, 'last_activity': None,
'last_type_display': None, 'last_type_display': None,
'type': None,
} }
by_extension[extension] = stats by_extension[extension] = stats
result.append(stats) by_date_created.append(stats)
stats['count'] += 1 stats['count'] += 1
if not stats.get('last_activity', None): if not stats.get('last_activity', None):
stats['last_activity'] = item stats['last_activity'] = item
if not stats.get('last_type_display', None) and item.type in STATUS_CHANGE_TYPES: if not stats.get('last_type_display', None) and item.type in STATUS_CHANGE_TYPES:
stats['last_type_display'] = item.get_type_display stats['last_type_display'] = item.get_type_display()
stats['type'] = item.type
groupped_by_type = defaultdict(list)
for stats in by_date_created:
type = stats['type'] or ApprovalActivity.ActivityType.AWAITING_REVIEW
groupped_by_type[type].append(stats)
result = []
for type in STATUS_CHANGE_TYPES:
result.extend(groupped_by_type[type])
return result return result
template_name = 'reviewers/extensions_review_list.html' template_name = 'reviewers/extensions_review_list.html'
@ -67,15 +77,30 @@ class ExtensionsApprovalDetailView(DetailView):
template_name = 'reviewers/extensions_review_detail.html' 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): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
ctx['pending_previews'] = self.object.preview_set.exclude( ctx['review_activity'] = (
file__status=File.STATUSES.APPROVED self.object.review_activity.select_related('user').order_by('date_created').all()
) )
ctx['status_change_types'] = STATUS_CHANGE_TYPES ctx['status_change_types'] = STATUS_CHANGE_TYPES
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
form = ctx['comment_form'] = CommentForm() initial = {}
if 'report_compatibility_issue' in self.request.GET:
version = self.request.GET.get('version')
initial['message'] = (
f'Compatibility range for version {version} is outdated\n'
'Platform: ...\n'
'Version of Blender where the add-on is **no longer** working: ?????'
)
form = ctx['comment_form'] = CommentForm(initial=initial)
# anyone can comment # anyone can comment
filtered_activity_types = {ApprovalActivity.ActivityType.COMMENT} filtered_activity_types = {ApprovalActivity.ActivityType.COMMENT}
user = self.request.user user = self.request.user

View File

@ -1,3 +1,4 @@
from html.parser import HTMLParser
from typing import Optional from typing import Optional
from urllib.parse import urljoin from urllib.parse import urljoin
import datetime import datetime
@ -20,6 +21,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import validate_ipv46_address from django.core.validators import validate_ipv46_address
from django.http import HttpRequest from django.http import HttpRequest
from django.http.response import HttpResponseRedirectBase from django.http.response import HttpResponseRedirectBase
from django.urls import reverse
from django.utils.encoding import force_bytes, force_str from django.utils.encoding import force_bytes, force_str
from django.utils.http import _urlparse from django.utils.http import _urlparse
import django.utils.text import django.utils.text
@ -189,3 +191,50 @@ def absolutify(url: str, request=None) -> str:
proto = 'http' if settings.DEBUG else 'https' proto = 'http' if settings.DEBUG else 'https'
domain = get_current_site(request).domain domain = get_current_site(request).domain
return urljoin(f'{proto}://{domain}', url) return urljoin(f'{proto}://{domain}', url)
def absolute_url(
view_name: str, args: Optional[tuple] = None, kwargs: Optional[dict] = None
) -> str:
"""Same as django.urls.reverse() but returned as an absolute URL."""
relative_url = reverse(view_name, args=args, kwargs=kwargs)
return absolutify(relative_url)
class HTMLFilter(HTMLParser):
skip_text_of = ('a', 'style')
text = ''
skip_tag_text = False
def handle_starttag(self, tag, attrs):
if tag in self.skip_text_of:
self.skip_tag_text = True
for name, value in attrs:
if name == 'href':
self.skip_tag_text = True
self.text += value
if tag in ('quote', 'q'):
self.text += ''
def handle_endtag(self, tag):
if tag in self.skip_text_of:
self.skip_tag_text = False
if tag in ('quote', 'q'):
self.text += '\n\n'
def handle_data(self, data):
if self.skip_tag_text:
return
self.text += data
def html_to_text(data: str) -> str:
f = HTMLFilter()
f.feed(data)
lines = [_.lstrip(' \t') for _ in f.text.split('\n')]
skip_empty = 0
for line in lines:
if not re.match(r'^\s*$', line):
break
skip_empty += 1
return '\n'.join(lines[skip_empty:])