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__)
class ReportForm(forms.ModelForm):
class ReportExtensionForm(forms.ModelForm):
class Meta:
model = abuse.models.AbuseReport
fields = ('reason', 'version', 'message')
@ -35,3 +35,9 @@ class ReportForm(forms.ModelForm):
return compare.version(self.cleaned_data[field])
except ValidationError as e:
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(
('UNTRIAGED', 1, 'Untriaged'),
('VALID', 2, 'Valid'),
('SUSPICIOUS', 3, 'Suspicious'),
('CONFIRMED', 2, 'Confirmed'),
('RESOLVED', 3, 'Resolved'),
)
# NULL if the reporter is anonymous.

View File

@ -18,7 +18,7 @@ urlpatterns = [
),
path(
'<slug:slug>/<str:version>/<int:rating>/report/',
views.ReportReviewView.as_view(),
views.ReportRatingView.as_view(),
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.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 abuse.models import AbuseReport
from ratings.models import Rating
@ -40,7 +40,7 @@ class ReportExtensionView(
CreateView,
):
model = AbuseReport
form_class = ReportForm
form_class = ReportExtensionForm
def get(self, request, *args, **kwargs):
extension = get_object_or_404(Extension.objects.listed, slug=self.kwargs['slug'])
@ -69,14 +69,14 @@ class ReportExtensionView(
return self.object.get_absolute_url()
class ReportReviewView(
class ReportRatingView(
LoginRequiredMixin,
extensions.views.mixins.ListedExtensionMixin,
UserPassesTestMixin,
CreateView,
):
model = AbuseReport
form_class = ReportForm
form_class = ReportRatingForm
def test_func(self) -> bool:
# 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 btnBack() {
(function() {
// 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');
btnBack.forEach(function(item) {
@ -8,10 +30,10 @@ function btnBack() {
window.history.back();
});
});
}
}
// Create finction commentForm
function commentForm() {
// Create finction commentForm
function commentForm() {
const commentForm = document.querySelector('.js-comment-form');
if (!commentForm) {
return;
@ -27,7 +49,7 @@ function commentForm() {
let value = e.target.value;
let verb = 'Comment';
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
if (value == 'AWC') {
@ -38,14 +60,16 @@ function commentForm() {
} else if (value == 'APR') {
verb = 'Approve!';
activitySubmitButton.classList.add('btn-success');
} else {
activitySubmitButton.classList.add('btn-primary');
}
activitySubmitButton.querySelector('span').textContent = verb;
});
}
}
// Create function copyInstallUrl
function copyInstallUrl() {
// Create function copyInstallUrl
function copyInstallUrl() {
function init() {
// Create variables
const btnInstall = document.querySelector('.js-btn-install');
@ -86,14 +110,16 @@ function copyInstallUrl() {
}
init();
}
// Create function init
function init() {
}
// Create function init
function init() {
agreeWithTerms();
btnBack();
commentForm();
copyInstallUrl();
}
}
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', function() {
init();
});
});
}())

View File

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

View File

@ -27,7 +27,8 @@
.hero-overlay
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
--hero-min-height: 21.0rem
@ -79,13 +80,6 @@
.ext-detail-tagline
+margin(2, bottom)
.ext-detail-description
+padding(4)
+style-rich-text
pre
+margin(3, bottom)
.ext-detail-info
dd
color: var(--color-text)
@ -269,8 +263,6 @@
+margin(3, left)
details
padding: 0
&[open]
.show-on-collapse
display: none
@ -327,6 +319,8 @@
a
color: var(--color-text)
// TODO: @web-assets check arbitrary style table link display specificity
display: inline !important
+padding(1, y)
padding-inline: 0 !important
@ -375,3 +369,7 @@
@extend .dropdown-divider
+margin(0, top)
.dropdown-item
&a
+padding(3, x)

View File

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

View File

@ -11,7 +11,12 @@
.form-control
&[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. */
.was-validated .form-control:invalid,

View File

@ -9,6 +9,11 @@
--nav-global-spacer-sm: var(--spacer-2)
--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
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
opacity: 1
.style-rich-text
+style-rich-text
.text-accent
color: var(--color-accent)

View File

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

View File

@ -33,7 +33,8 @@
<body class="has-global-bar">
{% switch "is_alpha" %}
<div class="site-announcement-alpha">
This is a test instance. Add-ons and other data might be deleted at any time. <a class="text-underline" href="https://devtalk.blender.org/tag/extensions" target="_blank">Learn more</a>
This platform is currently in alpha.
<a class="text-underline" href="https://projects.blender.org/infrastructure/extensions-website/issues" target="_blank">Please report any issues you may find</a>, thanks! <a class="text-underline" href="https://devtalk.blender.org/tag/extensions" target="_blank">Learn more</a>
</div>
{% else %}
{% switch "is_beta" %}
@ -125,7 +126,7 @@
</li>
{% block nav-upload %}
<li class="me-2">
<li>
<a href="{% url 'extensions:submit' %}" class="btn btn-primary">
<i class="i-upload"></i>
<span>Upload Extension</span>
@ -138,11 +139,13 @@
</li>
{% if user.is_authenticated %}
<a href="{% url 'notifications:notifications' %}">
<i class="i-bell {% if user|unread_notification_count %}text-primary{% endif %}"></i>
<li>
<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>
</li>
<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-chevron-down"></i>
</button>
@ -205,7 +208,10 @@
</ul>
</li>
{% 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 %}
<li>

View File

@ -1,5 +1,7 @@
{% load common %}
{% 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 %}
{% comment %} Checkboxes {% endcomment %}
@ -43,4 +45,5 @@
<div class="invalid-feedback">{{ field.errors }}</div>
{% endif %}
{% endwith %}
{% endwith %}
{% endspaceless %}

View File

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

View File

@ -2,6 +2,7 @@ from urllib.parse import urlparse
import json
import logging
from django.forms.boundfield import BoundField
from django.template import Library, loader
from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe
@ -166,3 +167,32 @@ def replace(value, old_char_new_char):
@register.filter(name='unread_notification_count')
def unread_notification_count(user):
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 factory.django import DjangoModelFactory
from faker import Faker
import actstream.models
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 = []
fake = Faker()
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 Meta:
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')
def create_moderator():
user = UserFactory()
def create_moderator(**kwargs):
user = UserFactory(**kwargs)
moderators = Group.objects.get(name='moderators')
user.groups.add(moderators)
return user

View File

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

View File

@ -9,7 +9,7 @@ from django.utils.safestring import mark_safe
import django.core.mail
from emails.models import Email
from emails.util import construct_email, get_template_context
from emails.util import construct_email
logger = logging.getLogger(__name__)
User = get_user_model()
@ -92,16 +92,15 @@ class EmailPreviewAdmin(NoAddDeleteMixin, EmailAdmin):
def _get_email_sent_message(self, obj):
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):
"""Construct the Email on th fly from known subscription email templates."""
context = {
'user': User(),
**get_template_context(),
}
mail_name = object_id
def get_object(self, request, object_id, from_field=None, fake_context=None):
"""Construct the Email on the fly from known email templates."""
if not fake_context:
fake_context = self._get_emails_with_fake_context(request)
context = fake_context[object_id]
mail_name = context.get('template', object_id)
email_body_html, email_body_txt, subject = construct_email(mail_name, context)
return EmailPreview(
id=mail_name,
id=object_id,
subject=subject,
from_email=settings.DEFAULT_FROM_EMAIL,
reply_to=settings.DEFAULT_REPLY_TO_EMAIL,
@ -109,10 +108,26 @@ class EmailPreviewAdmin(NoAddDeleteMixin, EmailAdmin):
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):
emails = []
for mail_name in ('feedback',):
emails.append(self.get_object(request, object_id=mail_name))
fake_context = self._get_emails_with_fake_context(request)
for mail_name in fake_context:
emails.append(self.get_object(request, object_id=mail_name, fake_context=fake_context))
return emails
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 #}
<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 %}
<p>Dear {% firstof user.full_name user.email %},</p>
{% block content %}{% endblock content %}
<p>
--<br />
Kind regards,<br />
Blender Extensions Team
{% block body %}{% block content %}{% endblock content %}{% 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 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 %}
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 %}
{{ 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."""
from typing import List, Optional, Tuple, Dict, Any
from typing import List, Tuple, Dict, Any
import logging
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.urls import reverse
from django.template import loader, TemplateDoesNotExist
from utils import absolute_url, html_to_text
logger = logging.getLogger(__name__)
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:
"""Return True if the email address is a no-reply address."""
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]:
"""Return additional context for use in an email template."""
return {
'site_url': _get_site_url(),
# 'profile_url': absolute_url('profile_update'),
'site_url': absolute_url('extensions:home'),
'profile_url': absolute_url('users:my-profile'),
'notifications_url': absolute_url('notifications:notifications'),
'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]:
"""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)
"""
context.update(**get_template_context())
base_path = 'emails'
subj_tmpl, html_tmpl, txt_tmpl = (
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()
email_body_html = loader.render_to_string(html_tmpl, context)
try:
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']

View File

@ -202,8 +202,13 @@ class TagAdmin(admin.ModelAdmin):
return ()
class VersionPermissionAdmin(admin.ModelAdmin):
list_display = ('name', 'slug')
admin.site.register(models.Extension, ExtensionAdmin)
admin.site.register(models.Version, VersionAdmin)
admin.site.register(models.Maintainer, MaintainerAdmin)
admin.site.register(models.License, LicenseAdmin)
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.utils.translation import gettext_lazy as _
import django.core.exceptions
from files.validators import FileMIMETypeValidator
from constants.base import ALLOWED_PREVIEW_MIMETYPES
import extensions.models
import files.models
import reviewers.models
logger = logging.getLogger(__name__)
@ -41,7 +43,8 @@ class AddPreviewFileForm(forms.ModelForm):
class Meta:
model = files.models.File
fields = ('caption', 'source')
fields = ('caption', 'source', 'original_hash', 'hash')
widgets = {'original_hash': forms.HiddenInput(), 'hash': forms.HiddenInput()}
source = forms.FileField(
allow_empty_file=False,
@ -64,6 +67,27 @@ class AddPreviewFileForm(forms.ModelForm):
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
super().__init__(*args, **kwargs)
def clean_original_hash(self, *args, **kwargs):
"""Calculate original hash of the uploaded file."""
if 'source' not in self.cleaned_data:
return
source = self.cleaned_data['source']
return files.models.File.generate_hash(source)
def clean_hash(self, *args, **kwargs):
return self.cleaned_data['original_hash']
def add_error(self, field, error):
"""Add hidden `original_hash`/`hash` errors to the visible `source` field instead."""
if isinstance(error, django.core.exceptions.ValidationError):
if getattr(error, 'error_dict', None):
hash_error = error.error_dict.pop('hash', None)
if hash_error:
error.error_dict['source'] = hash_error
# `original_hash` is treated identically to `hash`, so its errors can be discarded
error.error_dict.pop('original_hash', None)
super().add_error(field, error)
def save(self, *args, **kwargs):
"""Save Preview from the cleaned form data."""
# Fill in missing fields from request and the source file
@ -81,6 +105,8 @@ class AddPreviewFileForm(forms.ModelForm):
class AddPreviewModelFormSet(forms.BaseModelFormSet):
msg_duplicate_file = _('Please select another file instead of the duplicate')
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
self.extension = kwargs.pop('extension')
@ -94,6 +120,14 @@ class AddPreviewModelFormSet(forms.BaseModelFormSet):
form_kwargs['extension'] = self.extension
return form_kwargs
def get_unique_error_message(self, unique_check):
"""Replace duplicate `original_hash`/`hash` message with a more meaningful one."""
if len(unique_check) == 1:
field = unique_check[0]
if field in ('original_hash', 'hash'):
return self.msg_duplicate_file
return super().get_unique_error_message(unique_check)
AddPreviewFormSet = forms.modelformset_factory(
files.models.File,
@ -104,6 +138,16 @@ AddPreviewFormSet = forms.modelformset_factory(
class ExtensionUpdateForm(forms.ModelForm):
# Messages for auto-generated activity
msg_converted_to_draft = _('Converted to Draft')
msg_awaiting_review = _('Ready for review')
# Messages for additional validation
msg_cannot_convert_to_draft = _(
'An extension can be converted to draft only while it is Awating Review'
)
msg_need_previews = _('Please add at least one preview.')
class Meta:
model = extensions.models.Extension
fields = (
@ -111,6 +155,79 @@ class ExtensionUpdateForm(forms.ModelForm):
'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 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,
FILE_STATUS_CHOICES,
)
from constants.licenses import ALL_LICENSES
from constants.version_permissions import ALL_VERSION_PERMISSIONS
import common.help_texts
import extensions.fields
@ -90,22 +88,13 @@ class License(CreatedModifiedMixin, models.Model):
blank=False,
null=False,
help_text='Should be taken from https://spdx.org/licenses/',
unique=True,
)
url = models.URLField(blank=False, null=False)
def __str__(self) -> str:
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
def get_by_slug(cls, slug: str):
return cls.objects.filter(slug__startswith=slug).first()
@ -386,28 +375,16 @@ class VersionPermission(CreatedModifiedMixin, models.Model):
blank=False,
null=False,
help_text='Permissions add-ons are expected to need.',
unique=True,
)
help = models.CharField(max_length=128, null=False, blank=False, unique=True)
def __str__(self) -> str:
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
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):
@ -537,12 +514,7 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
return
for permission_name in _permissions:
permission = VersionPermission.get_by_name(permission_name)
# Just ignore versions that are incompatible.
if not permission:
continue
permission = VersionPermission.get_by_slug(permission_name)
self.permissions.add(permission)
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:".,-" }}/"
title="{{ version.blender_version_min }}">Blender {{ version.blender_version_min|version_without_patch }}</a>
{% if is_editable %}
&mdash;
<input name="blender_version_max" class="form-control-sm"
<span class="me-2">&mdash;</span>
<input name="blender_version_max" class="form-control"
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]+)?$"
title="{% trans 'Blender version, e.g. 4.1.0' %}"
/>
@ -25,4 +25,5 @@
{% else %}
{% trans 'and newer' %}
{% 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 %}

View File

@ -1,5 +1,5 @@
{% 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-content">
<a href="{{ extension.get_absolute_url }}">

View File

@ -76,7 +76,9 @@
<div class="dl-row">
<div class="dl-col">
<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>

View File

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

View File

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

View File

@ -17,7 +17,7 @@
</div>
<div class="details flex-grow-1">
<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 class="align-items-center d-flex js-input-img-helper justify-content-between">
{% 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">
<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>
{% include "common/components/field.html" with field=form.support %}
{% include "common/components/field.html" with field=form.support placeholder="https://example.com" %}
</div>
</section>
<section class="mt-4">
<h2>{% trans 'Previews' %}</h2>
<div class="previews-upload">
{{ edit_preview_formset.management_form }}
{{ edit_preview_formset.non_form_errors }}
{% if edit_preview_formset.forms|length %}
{# View or delete existing preview files #}
<div class="previews-list js-previews-drag-container">
{% for newform in edit_preview_formset %}
{% with inlineform=newform|add_form_classes %}
{% with file=inlineform.instance.file %}
<div class="previews-list-item" data-preview-position="{{ inlineform.instance.position }}">
<div class="js-preview-drag drag-widget is-draggable">
<i class="i-menu"></i>
<div class="previews-list-item-thumbnail">
<div class="previews-list-item-thumbnail-img" style="background-image: url('{% if file.is_image %}{{ file.source.url }}{% elif file.is_video and file.thumbnail %}{{ file.thumbnail.url }}{% endif %}');" title="Preview"></div>
</div>
</div>
<div class="details">
<div>
{% include "common/components/field.html" with field=inlineform.id %}
{% include "common/components/field.html" with field=inlineform.caption %}
{% include "common/components/field.html" with field=inlineform.position %}
</div>
<ul>
<li>
<a href="{{ inlineform.instance.file.source.url }}" target="_blank">
<small>Source File</small>
</a>
</li>
<li class="ms-auto">
{% include "common/components/field.html" with field=inlineform.DELETE %}
</li>
<li>
{{ inlineform.non_form_errors }}
</li>
</ul>
</div>
</div>
{% endwith %}
{% endwith %}
{% endfor %}
</div>
<hr class="my-4"/>
{% endif %}
{% include "extensions/manage/components/edit_previews.html" %}
{% include "extensions/manage/components/add_previews.html" %}
</div>
</section>
@ -97,13 +55,22 @@
<section class="card p-3 mt-3">
<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>
<span>
{% trans 'Save Changes' %}
</span>
</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>
<a href="{{ extension.get_new_version_url }}" class="btn">

View File

@ -25,7 +25,7 @@
<section class="card p-3">
<div class="row">
<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>
{% 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>
</p>
<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>
<ul>
<li><strong>You are the creator or maintainer</strong> of this extension.</li>
@ -56,11 +66,11 @@
</div>
<div class="row">
<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 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>
<span>
{% if extension %}

View File

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

View File

@ -23,11 +23,12 @@ EXPECTED_EXTENSION_DATA = {
'name': 'Edit Breakdown',
'version': '0.1.0',
'blender_version_min': '4.2.0',
'blender_version_max': '4.3.0',
'type': 'add-on',
'schema_version': "1.0.0",
},
'file_hash': 'sha256:4f3664940fc41641c7136a909270a024bbcfb2f8523a06a0d22f85c459b0b1ae',
'size_bytes': 53959,
'file_hash': 'sha256:28313858b9be34e6ecd15a63e28f626fb914dbdcc74c6d21c6536c9fad9de426',
'size_bytes': 53969,
'tags': ['Sequencer'],
'version_str': '0.1.0',
'slug': 'edit-breakdown',
@ -84,6 +85,24 @@ EXPECTED_VALIDATION_ERRORS = {
'invalid-manifest-toml.zip': {'source': ['Could not parse the manifest file.']},
'invalid-theme-multiple-xmls.zip': {'source': ['A theme should have exactly one XML file.']},
}
POST_DATA = {
'preview_set-TOTAL_FORMS': ['0'],
'preview_set-INITIAL_FORMS': ['0'],
'preview_set-MIN_NUM_FORMS': ['0'],
'preview_set-MAX_NUM_FORMS': ['1000'],
'preview_set-0-id': [''],
# 'preview_set-0-extension': [str(extension.pk)],
'preview_set-1-id': [''],
# 'preview_set-1-extension': [str(extension.pk)],
'form-TOTAL_FORMS': ['0'],
'form-INITIAL_FORMS': ['0'],
'form-MIN_NUM_FORMS': ['0'],
'form-MAX_NUM_FORMS': ['1000'],
'form-0-id': '',
# 'form-0-caption': ['First Preview Caption Text'],
'form-1-id': '',
# 'form-1-caption': ['Second Preview Caption Text'],
}
class SubmitFileTest(TestCase):
@ -279,20 +298,19 @@ class SubmitFinaliseTest(TestCase):
def test_post_finalise_addon_validation_errors(self):
self.client.force_login(self.file.user)
response = self.client.post(self.file.get_submit_url(), {})
data = {**POST_DATA, 'submit_draft': ''}
response = self.client.post(self.file.get_submit_url(), data)
self.assertEqual(response.status_code, 200)
self.assertDictEqual(
_get_all_form_errors(response),
{
'form': {},
'extension_form': {
'description': ['This field is required.'],
},
'add_preview_formset': [],
'form': [{}, None],
'extension_form': [{'description': ['This field is required.']}, None],
'add_preview_formset': [[], ['Please add at least one preview.']],
'edit_preview_formset': [[], []],
},
)
self.assertFalse('TODO: It should include preview as required')
def test_post_finalise_addon_creates_addon_with_version_awaiting_review(self):
self.assertEqual(File.objects.count(), 1)
@ -317,6 +335,15 @@ class SubmitFinaliseTest(TestCase):
'form-0-caption': ['First Preview Caption Text'],
'form-1-id': '',
'form-1-caption': ['Second Preview Caption Text'],
# Edit form for existing previews
'preview_set-TOTAL_FORMS': ['0'],
'preview_set-INITIAL_FORMS': ['0'],
'preview_set-MIN_NUM_FORMS': ['0'],
'preview_set-MAX_NUM_FORMS': ['1000'],
'preview_set-0-id': [''],
# 'preview_set-0-extension': [str(extension.pk)],
'preview_set-1-id': [''],
# 'preview_set-1-extension': [str(extension.pk)],
# Submit for Approval.
'submit_draft': '',
}
@ -460,3 +487,15 @@ class NewVersionTest(TestCase):
).count(),
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 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 extensions.models import Extension
from files.models import File
from reviewers.models import ApprovalActivity
TEST_FILES_DIR = Path(__file__).resolve().parent / 'files'
POST_DATA = {
@ -199,6 +202,45 @@ class UpdateTest(TestCase):
response = self.client.post(url, {**data, **files})
self.assertEqual(response.status_code, 200)
self.maxDiff = None
self.assertEqual(
[
response.context['add_preview_formset'].forms[0].errors,
response.context['add_preview_formset'].forms[1].errors,
response.context['add_preview_formset'].non_form_errors(),
],
[
{},
{'__all__': ['Please correct the duplicate values below.']},
[
'Please select another file instead of the duplicate',
'Please select another file instead of the duplicate',
],
],
)
def test_post_upload_validation_error_image_already_exists(self):
FileFactory(
original_hash='sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
hash='sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
source='file/original_image_source.jpg',
)
extension = create_approved_version().extension
data = {
**POST_DATA,
'form-TOTAL_FORMS': ['1'],
}
file_name1 = 'test_preview_image_0001.png'
url = extension.get_manage_url()
user = extension.authors.first()
self.client.force_login(user)
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1:
files = {'form-0-source': fp1}
response = self.client.post(url, {**data, **files})
self.assertEqual(response.status_code, 200)
self.maxDiff = None
self.assertEqual(
response.context['add_preview_formset'].forms[0].errors,
{'source': ['File with this Hash already exists.']},
@ -280,3 +322,28 @@ class UpdateTest(TestCase):
response.context['add_preview_formset'].forms[0].errors,
{'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."""
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.db import transaction
from django.shortcuts import get_object_or_404, reverse
from django.utils.translation import gettext_lazy as _
from django.shortcuts import get_object_or_404, redirect, reverse
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
@ -14,11 +12,8 @@ from .mixins import (
OwnsFileMixin,
MaintainedExtensionMixin,
DraftVersionMixin,
DraftMixin,
)
from extensions.forms import (
EditPreviewFormSet,
AddPreviewFormSet,
ExtensionDeleteForm,
ExtensionUpdateForm,
VersionForm,
@ -27,7 +22,6 @@ from extensions.forms import (
from extensions.models import Extension, Version
from files.forms import FileForm
from files.models import File
from reviewers.models import ApprovalActivity
from stats.models import ExtensionView
import ratings.models
@ -104,7 +98,6 @@ class UpdateExtensionView(
LoginRequiredMixin,
MaintainedExtensionMixin,
SuccessMessageMixin,
DraftMixin,
UpdateView,
):
model = Extension
@ -112,59 +105,32 @@ class UpdateExtensionView(
form_class = ExtensionUpdateForm
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):
self.object.refresh_from_db()
return self.object.get_manage_url()
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
edit_preview_formset = kwargs.pop('edit_preview_formset', None)
add_preview_formset = kwargs.pop('add_preview_formset', None)
if not (edit_preview_formset and add_preview_formset):
if self.request.POST:
edit_preview_formset = EditPreviewFormSet(
self.request.POST, self.request.FILES, instance=self.object
)
add_preview_formset = AddPreviewFormSet(
self.request.POST,
self.request.FILES,
extension=self.object,
request=self.request,
)
else:
edit_preview_formset = EditPreviewFormSet(instance=self.object)
add_preview_formset = AddPreviewFormSet(extension=self.object, request=self.request)
context['edit_preview_formset'] = edit_preview_formset
context['add_preview_formset'] = add_preview_formset
context['edit_preview_formset'] = context['form'].edit_preview_formset
context['add_preview_formset'] = context['form'].add_preview_formset
return context
def form_invalid(self, form, edit_preview_formset=None, add_preview_formset=None):
return self.render_to_response(
self.get_context_data(
form=form,
edit_preview_formset=edit_preview_formset,
add_preview_formset=add_preview_formset,
)
)
@transaction.atomic
def form_valid(self, form):
edit_preview_formset = EditPreviewFormSet(
self.request.POST, self.request.FILES, instance=self.object
)
add_preview_formset = AddPreviewFormSet(
self.request.POST, self.request.FILES, extension=self.object, request=self.request
)
if edit_preview_formset.is_valid() and add_preview_formset.is_valid():
try:
edit_preview_formset.save()
add_preview_formset.save()
response = super().form_valid(form)
return response
except forms.ValidationError as e:
if 'hash' in e.error_dict:
add_preview_formset.forms[0].add_error('source', e.error_dict['hash'])
return self.form_invalid(form, edit_preview_formset, add_preview_formset)
def form_valid(self, *args, **kwargs):
return super().form_valid(*args, **kwargs)
class DeleteExtensionView(
@ -296,14 +262,6 @@ class NewVersionFinalizeView(LoginRequiredMixin, OwnsFileMixin, CreateView):
template_name = 'extensions/new_version_finalise.html'
form_class = VersionForm
def form_valid(self, form):
# Automatically approve new versions, for now.
# We rely on approval of the first submission, for now.
self.file.status = self.file.STATUSES.APPROVED
self.file.save()
response = super().form_valid(form)
return response
def _get_extension(self) -> 'Extension':
return get_object_or_404(Extension, slug=self.kwargs['slug'])
@ -359,7 +317,6 @@ class DraftExtensionView(
):
template_name = 'extensions/draft_finalise.html'
form_class = VersionForm
msg_awaiting_review = _('Extension is ready for initial review')
@property
def success_message(self) -> str:
@ -383,60 +340,38 @@ class DraftExtensionView(
initial.update(**self.version.file.parsed_version_fields)
return initial
def get_context_data(self, form=None, extension_form=None, add_preview_formset=None, **kwargs):
def get_context_data(self, form=None, extension_form=None, **kwargs):
"""Add all the additional forms to the context."""
context = super().get_context_data(**kwargs)
if not (add_preview_formset and extension_form):
extension_form = ExtensionUpdateForm(instance=self.extension)
add_preview_formset = AddPreviewFormSet(extension=self.extension, request=self.request)
if not extension_form:
extension_form = ExtensionUpdateForm(instance=self.extension, request=self.request)
context['extension_form'] = extension_form
context['add_preview_formset'] = add_preview_formset
context['edit_preview_formset'] = extension_form.edit_preview_formset
context['add_preview_formset'] = extension_form.add_preview_formset
return context
def post(self, request, *args, **kwargs):
"""Handle bound forms and valid/invalid logic with the extra forms."""
form = self.get_form()
extension_form = ExtensionUpdateForm(
self.request.POST, self.request.FILES, instance=self.extension
self.request.POST, self.request.FILES, instance=self.extension, request=self.request
)
add_preview_formset = AddPreviewFormSet(
self.request.POST, self.request.FILES, extension=self.extension, request=self.request
)
if form.is_valid() and extension_form.is_valid() and add_preview_formset.is_valid():
return self.form_valid(form, extension_form, add_preview_formset)
return self.form_invalid(form, extension_form, add_preview_formset)
if form.is_valid() and extension_form.is_valid():
return self.form_valid(form, extension_form)
return self.form_invalid(form, extension_form)
@transaction.atomic
def form_valid(self, form, extension_form, add_preview_formset):
def form_valid(self, form, extension_form):
"""Save all the forms in correct order.
Extension must be saved first.
"""
try:
# Send the extension and version to the review
if 'submit_draft' in self.request.POST:
extension_form.instance.status = extension_form.instance.STATUSES.AWAITING_REVIEW
extension_form.save()
add_preview_formset.save()
form.save()
if 'submit_draft' in self.request.POST:
# TODO allow to submit a custom message via the form
ApprovalActivity(
user=self.request.user,
extension=extension_form.instance,
type=ApprovalActivity.ActivityType.AWAITING_REVIEW,
message=self.msg_awaiting_review,
).save()
return super().form_valid(form)
except forms.ValidationError as e:
if 'hash' in e.error_dict:
add_preview_formset.forms[0].add_error('source', e.error_dict['hash'])
return self.form_invalid(form, extension_form, add_preview_formset)
def form_invalid(self, form, extension_form, add_preview_formset):
return self.render_to_response(
self.get_context_data(form, extension_form, add_preview_formset)
)
def form_invalid(self, form, extension_form):
return self.render_to_response(self.get_context_data(form, extension_form))
def get_success_url(self):
return self.extension.get_manage_url()

View File

@ -1,5 +1,5 @@
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 files.models import File
@ -60,27 +60,3 @@ class DraftVersionMixin:
def dispatch(self, *args, **kwargs):
self.version = self.extension.versions.first()
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.views.generic.edit import CreateView
from .mixins import DraftMixin
from extensions.models import Version, Extension
from files.forms import FileForm
from files.models import File
@ -12,11 +11,19 @@ from files.models import File
logger = logging.getLogger(__name__)
class UploadFileView(LoginRequiredMixin, DraftMixin, CreateView):
class UploadFileView(LoginRequiredMixin, CreateView):
model = File
template_name = 'extensions/submit.html'
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):
kwargs = super().get_form_kwargs()
kwargs['request'] = self.request

View File

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

View File

@ -54,8 +54,7 @@ class FileMIMETypeValidator:
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.
* Neither hyphens nor spaces are supported.
* Each extension id most be unique across all extensions.
@ -307,8 +306,9 @@ class PermissionsValidator:
is_error = True
else:
for permission in value:
if VersionPermission.get_by_slug(permission):
continue
try:
VersionPermission.get_by_slug(permission)
except VersionPermission.DoesNotExist:
is_error = True
logger.info(f'Permission unavailable: {permission}')

View File

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

View File

@ -1,11 +1,15 @@
import logging
from actstream.models import Action
from django.contrib.auth import get_user_model
from django.db import models
from django.template import loader, TemplateDoesNotExist
from constants.activity import Verb
from utils import absolutify
User = get_user_model()
logger = logging.getLogger(__name__)
class Notification(models.Model):
@ -30,23 +34,62 @@ class Notification(models.Model):
]
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
subject = f'New Activity: {action.actor} {action.verb} {action.target}'
url = self.get_absolute_url()
mesage = f'{action.actor} {action.verb} {action.target}: {url}'
return (subject, mesage)
action_object = action.action_object
target_name = action.target.__class__.__name__.lower()
action_object_name = action_object.__class__.__name__.lower() if action_object else ''
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):
if self.action.verb == Verb.RATED_EXTENSION:
url = self.action.target.get_ratings_url()
fragment = f'#review-{self.action.action_object.pk}'
url = self.action.target.get_ratings_url() + fragment
elif self.action.verb in [
Verb.APPROVED,
Verb.COMMENTED,
Verb.REQUESTED_CHANGES,
Verb.REQUESTED_REVIEW,
]:
url = self.action.target.get_review_url()
fragment = f'#activity-{self.action.action_object.pk}'
url = self.action.target.get_review_url() + fragment
elif self.action.action_object is not None:
url = self.action.action_object.get_absolute_url()
else:

View File

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

View File

@ -1,7 +1,10 @@
from pathlib import Path
from django.core import mail
from django.core.management import call_command
from django.test import TestCase
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.files import FileFactory
@ -17,7 +20,9 @@ class TestTasks(TestCase):
fixtures = ['dev', 'licenses']
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()
notification_nr = Notification.objects.filter(recipient=author).count()
some_user = UserFactory()
@ -29,14 +34,35 @@ class TestTasks(TestCase):
new_notification_nr = Notification.objects.filter(recipient=author).count()
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):
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()
some_user = UserFactory()
self.client.force_login(some_user)
url = extension.get_report_url()
self.client.post(
response = self.client.post(
url,
{
'message': 'test message',
@ -44,11 +70,31 @@ class TestTasks(TestCase):
'version': '',
},
)
self.assertEqual(response.status_code, 302)
report_url = response['Location']
new_notification_nr = Notification.objects.filter(recipient=moderator).count()
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):
moderator = create_moderator()
moderator = create_moderator(confirmed_email_at=timezone.now())
notification_nr = Notification.objects.filter(recipient=moderator).count()
some_user = UserFactory()
file_data = {
@ -117,10 +163,29 @@ class TestTasks(TestCase):
new_notification_nr = Notification.objects.filter(recipient=moderator).count()
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):
extension = create_approved_version(ratings=[]).extension
author = extension.authors.first()
moderator = create_moderator()
moderator = create_moderator(confirmed_email_at=timezone.now())
some_user = UserFactory()
notification_nrs = {}
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[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):
self.client.force_login(user)
url = reverse('reviewers:approval-comment', args=[extension.slug])
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 set it up use the following commands:
virtualenv .venv -p python
python3.10 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

View File

@ -14,7 +14,7 @@
<div class="row">
<div class="col-md-8">
<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 %}
<div class="invalid-feedback">

View File

@ -70,24 +70,6 @@
{% block extension_galleria %}
{% 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 %}
@ -96,9 +78,9 @@
<hr class="my-4">
<h2>Activity</h2>
{% if extension.review_activity.all %}
{% if review_activity %}
<ul class="activity-list">
{% for activity in extension.review_activity.all %}
{% for activity in review_activity %}
<li id="activity-{{ activity.id }}">
{% if activity.type in status_change_types %}
@ -163,13 +145,11 @@
{% csrf_token %}
{% 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="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 %}
{% endif %}
<button type="submit" id="activity-submit" class="btn btn-primary">
<span>{% trans "Comment" %}</span>

View File

@ -1,3 +1,4 @@
from collections import defaultdict
import logging
from django.contrib.auth.mixins import LoginRequiredMixin
@ -6,17 +7,17 @@ from django.views.generic import DetailView, FormView
from django.shortcuts import reverse
import django.forms
from files.models import File
from extensions.models import Extension
from reviewers.forms import CommentForm
from reviewers.models import ApprovalActivity
log = logging.getLogger(__name__)
# the ordering of this list determines the order of rows in approval queue listing
STATUS_CHANGE_TYPES = [
ApprovalActivity.ActivityType.APPROVED,
ApprovalActivity.ActivityType.AWAITING_CHANGES,
ApprovalActivity.ActivityType.AWAITING_REVIEW,
ApprovalActivity.ActivityType.AWAITING_CHANGES,
ApprovalActivity.ActivityType.APPROVED,
]
@ -37,26 +38,35 @@ class ApprovalQueueView(ListView):
.all()
)
by_extension = {}
result = []
by_date_created = []
for item in qs:
extension = item.extension
stats = by_extension.get(extension, None)
if not stats:
# 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 = {
'count': 0,
'extension': extension,
'last_activity': None,
'last_type_display': None,
'type': None,
}
by_extension[extension] = stats
result.append(stats)
by_date_created.append(stats)
stats['count'] += 1
if not stats.get('last_activity', None):
stats['last_activity'] = item
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
template_name = 'reviewers/extensions_review_list.html'
@ -67,15 +77,30 @@ class ExtensionsApprovalDetailView(DetailView):
template_name = 'reviewers/extensions_review_detail.html'
def get_queryset(self):
return self.model.objects.prefetch_related(
'authors',
'previews',
'versions',
).all()
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['pending_previews'] = self.object.preview_set.exclude(
file__status=File.STATUSES.APPROVED
ctx['review_activity'] = (
self.object.review_activity.select_related('user').order_by('date_created').all()
)
ctx['status_change_types'] = STATUS_CHANGE_TYPES
if self.request.user.is_authenticated:
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
filtered_activity_types = {ApprovalActivity.ActivityType.COMMENT}
user = self.request.user

View File

@ -1,3 +1,4 @@
from html.parser import HTMLParser
from typing import Optional
from urllib.parse import urljoin
import datetime
@ -20,6 +21,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import validate_ipv46_address
from django.http import HttpRequest
from django.http.response import HttpResponseRedirectBase
from django.urls import reverse
from django.utils.encoding import force_bytes, force_str
from django.utils.http import _urlparse
import django.utils.text
@ -189,3 +191,50 @@ def absolutify(url: str, request=None) -> str:
proto = 'http' if settings.DEBUG else 'https'
domain = get_current_site(request).domain
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:])