Implement Web Assets' theme system and selection, and add 'light' theme #118
@ -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')
|
||||
|
18
abuse/migrations/0007_alter_abusereport_status.py
Normal file
18
abuse/migrations/0007_alter_abusereport_status.py
Normal 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),
|
||||
),
|
||||
]
|
@ -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.
|
||||
|
@ -18,7 +18,7 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
'<slug:slug>/<str:version>/<int:rating>/report/',
|
||||
views.ReportReviewView.as_view(),
|
||||
views.ReportRatingView.as_view(),
|
||||
name='report-ratings',
|
||||
),
|
||||
],
|
||||
|
@ -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
|
@ -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();
|
||||
});
|
||||
});
|
||||
}())
|
||||
|
4
common/static/common/styles/_button.sass
Normal file
4
common/static/common/styles/_button.sass
Normal file
@ -0,0 +1,4 @@
|
||||
button,
|
||||
.btn
|
||||
&[type=submit]
|
||||
transition: opacity var(--transition-speed)
|
@ -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)
|
||||
|
@ -1,4 +1,7 @@
|
||||
/* Aliases to use existing icons as permission slugs. */
|
||||
.i-permission-clipboard
|
||||
@extend .i-copy
|
||||
|
||||
.i-permission-files
|
||||
@extend .i-folder
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
13
common/static/common/styles/_notifications.sass
Normal file
13
common/static/common/styles/_notifications.sass
Normal 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
|
@ -37,6 +37,9 @@
|
||||
.show
|
||||
opacity: 1
|
||||
|
||||
.style-rich-text
|
||||
+style-rich-text
|
||||
|
||||
.text-accent
|
||||
color: var(--color-accent)
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
8
common/tests/factories/reviewers.py
Normal file
8
common/tests/factories/reviewers.py
Normal file
@ -0,0 +1,8 @@
|
||||
from factory.django import DjangoModelFactory
|
||||
|
||||
from reviewers.models import ApprovalActivity
|
||||
|
||||
|
||||
class ApprovalActivityFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
model = ApprovalActivity
|
@ -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
|
||||
|
@ -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')
|
||||
}
|
||||
|
@ -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):
|
||||
|
@ -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 %}
|
||||
|
@ -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
|
||||
|
21
emails/templates/emails/components/new_activity_action
Normal file
21
emails/templates/emails/components/new_activity_action
Normal 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 %}
|
@ -1,4 +1,4 @@
|
||||
{% extends "emails/email_base.html" %}
|
||||
{% extends "emails/base.html" %}
|
||||
|
||||
{% block body %}
|
||||
{{ email.html_message|safe }}
|
||||
|
@ -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>
|
24
emails/templates/emails/new_activity.html
Normal file
24
emails/templates/emails/new_activity.html
Normal 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 %}
|
21
emails/templates/emails/new_activity_subject.txt
Normal file
21
emails/templates/emails/new_activity_subject.txt
Normal 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 %}
|
@ -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']
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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),
|
||||
),
|
||||
]
|
@ -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):
|
||||
|
@ -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 %}
|
||||
—
|
||||
<input name="blender_version_max" class="form-control-sm"
|
||||
<span class="me-2">—</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 %}
|
||||
|
@ -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 }}">
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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' %}
|
||||
|
@ -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 %}
|
@ -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">
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
Binary file not shown.
@ -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())
|
||||
|
@ -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())
|
||||
|
@ -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()
|
||||
|
@ -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())
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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}')
|
||||
|
||||
|
@ -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])
|
||||
|
@ -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:
|
||||
|
@ -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 %}
|
||||
|
@ -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/
|
||||
"""
|
||||
|
@ -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
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
49
utils.py
49
utils.py
@ -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:])
|
||||
|
Loading…
Reference in New Issue
Block a user