Implement Web Assets' theme system and selection, and add 'light' theme #118
@ -10,7 +10,7 @@ import abuse.models
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ReportForm(forms.ModelForm):
|
class ReportExtensionForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = abuse.models.AbuseReport
|
model = abuse.models.AbuseReport
|
||||||
fields = ('reason', 'version', 'message')
|
fields = ('reason', 'version', 'message')
|
||||||
@ -35,3 +35,9 @@ class ReportForm(forms.ModelForm):
|
|||||||
return compare.version(self.cleaned_data[field])
|
return compare.version(self.cleaned_data[field])
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
self.add_error(field, forms.ValidationError([e.message], code='invalid'))
|
self.add_error(field, forms.ValidationError([e.message], code='invalid'))
|
||||||
|
|
||||||
|
|
||||||
|
class ReportRatingForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = abuse.models.AbuseReport
|
||||||
|
fields = ('reason', 'message')
|
||||||
|
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(
|
STATUSES = Choices(
|
||||||
('UNTRIAGED', 1, 'Untriaged'),
|
('UNTRIAGED', 1, 'Untriaged'),
|
||||||
('VALID', 2, 'Valid'),
|
('CONFIRMED', 2, 'Confirmed'),
|
||||||
('SUSPICIOUS', 3, 'Suspicious'),
|
('RESOLVED', 3, 'Resolved'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# NULL if the reporter is anonymous.
|
# NULL if the reporter is anonymous.
|
||||||
|
@ -18,7 +18,7 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
'<slug:slug>/<str:version>/<int:rating>/report/',
|
'<slug:slug>/<str:version>/<int:rating>/report/',
|
||||||
views.ReportReviewView.as_view(),
|
views.ReportRatingView.as_view(),
|
||||||
name='report-ratings',
|
name='report-ratings',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -7,7 +7,7 @@ from django.views.generic.list import ListView
|
|||||||
from django.views.generic.edit import CreateView
|
from django.views.generic.edit import CreateView
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
|
||||||
from .forms import ReportForm
|
from .forms import ReportExtensionForm, ReportRatingForm
|
||||||
from constants.base import ABUSE_TYPE_EXTENSION, ABUSE_TYPE_RATING
|
from constants.base import ABUSE_TYPE_EXTENSION, ABUSE_TYPE_RATING
|
||||||
from abuse.models import AbuseReport
|
from abuse.models import AbuseReport
|
||||||
from ratings.models import Rating
|
from ratings.models import Rating
|
||||||
@ -40,7 +40,7 @@ class ReportExtensionView(
|
|||||||
CreateView,
|
CreateView,
|
||||||
):
|
):
|
||||||
model = AbuseReport
|
model = AbuseReport
|
||||||
form_class = ReportForm
|
form_class = ReportExtensionForm
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
extension = get_object_or_404(Extension.objects.listed, slug=self.kwargs['slug'])
|
extension = get_object_or_404(Extension.objects.listed, slug=self.kwargs['slug'])
|
||||||
@ -69,14 +69,14 @@ class ReportExtensionView(
|
|||||||
return self.object.get_absolute_url()
|
return self.object.get_absolute_url()
|
||||||
|
|
||||||
|
|
||||||
class ReportReviewView(
|
class ReportRatingView(
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
extensions.views.mixins.ListedExtensionMixin,
|
extensions.views.mixins.ListedExtensionMixin,
|
||||||
UserPassesTestMixin,
|
UserPassesTestMixin,
|
||||||
CreateView,
|
CreateView,
|
||||||
):
|
):
|
||||||
model = AbuseReport
|
model = AbuseReport
|
||||||
form_class = ReportForm
|
form_class = ReportRatingForm
|
||||||
|
|
||||||
def test_func(self) -> bool:
|
def test_func(self) -> bool:
|
||||||
# TODO: best to redirect to existing report or show a friendly message
|
# TODO: best to redirect to existing report or show a friendly message
|
||||||
|
@ -1 +1 @@
|
|||||||
Subproject commit af61a962e1a30898279b4efdbb07a2dcb230a257
|
Subproject commit 1126f102d8542ffb76af0269854048f276d9e50b
|
@ -1,99 +1,125 @@
|
|||||||
// Create function btnBack
|
(function() {
|
||||||
function btnBack() {
|
// Create function agreeWithTerms
|
||||||
const btnBack = document.querySelectorAll('.js-btn-back');
|
function agreeWithTerms() {
|
||||||
|
const agreeWithTermsInput = document.querySelector('.js-agree-with-terms-input');
|
||||||
|
|
||||||
btnBack.forEach(function(item) {
|
if (!agreeWithTermsInput) {
|
||||||
item.addEventListener('click', function(e) {
|
// Stop function execution if agreeWithTermsInput is not present
|
||||||
e.preventDefault();
|
|
||||||
window.history.back();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create finction commentForm
|
|
||||||
function commentForm() {
|
|
||||||
const commentForm = document.querySelector('.js-comment-form');
|
|
||||||
if (!commentForm) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commentFormSelect = commentForm.querySelector('select');
|
|
||||||
if (!commentFormSelect) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create event comment form select change
|
|
||||||
commentFormSelect.addEventListener('change', function(e) {
|
|
||||||
let value = e.target.value;
|
|
||||||
let verb = 'Comment';
|
|
||||||
const activitySubmitButton = document.getElementById('activity-submit');
|
|
||||||
activitySubmitButton.classList.remove('btn-success', 'btn-warning');
|
|
||||||
|
|
||||||
// Hide or show comment form msg on change
|
|
||||||
if (value == 'AWC') {
|
|
||||||
verb = 'Set as Awaiting Changes';
|
|
||||||
activitySubmitButton.classList.add('btn-warning');
|
|
||||||
} else if (value == 'AWR') {
|
|
||||||
verb = 'Set as Awaiting Review';
|
|
||||||
} else if (value == 'APR') {
|
|
||||||
verb = 'Approve!';
|
|
||||||
activitySubmitButton.classList.add('btn-success');
|
|
||||||
}
|
|
||||||
|
|
||||||
activitySubmitButton.querySelector('span').textContent = verb;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create function copyInstallUrl
|
|
||||||
function copyInstallUrl() {
|
|
||||||
function init() {
|
|
||||||
// Create variables
|
|
||||||
const btnInstall = document.querySelector('.js-btn-install');
|
|
||||||
const btnInstallAction = document.querySelector('.js-btn-install-action');
|
|
||||||
const btnInstallGroup = document.querySelector('.js-btn-install-group');
|
|
||||||
const btnInstallDrag = document.querySelector('.js-btn-install-drag');
|
|
||||||
const btnInstallDragGroup = document.querySelector('.js-btn-install-drag-group');
|
|
||||||
|
|
||||||
if (btnInstall == null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get data install URL
|
agreeWithTermsInput.addEventListener('change', function(e) {
|
||||||
const btnInstallUrl = btnInstall.getAttribute('data-install-url');
|
const agreeWithTermsBtnSubmit = document.querySelector('.js-agree-with-terms-btn-submit');
|
||||||
|
|
||||||
btnInstall.addEventListener('click', function() {
|
// Check if checkbox is checked
|
||||||
// Hide btnInstallGroup
|
if (e.target.checked == true) {
|
||||||
btnInstallGroup.classList.add('d-none');
|
agreeWithTermsBtnSubmit.removeAttribute('disabled');
|
||||||
|
} else {
|
||||||
// Show btnInstallAction
|
agreeWithTermsBtnSubmit.setAttribute('disabled', true);
|
||||||
btnInstallAction.classList.add('show');
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Drag btnInstallUrl
|
|
||||||
btnInstallDrag.addEventListener('dragstart', function(e) {
|
|
||||||
// Set data install URL to be transferred during drag
|
|
||||||
e.dataTransfer.setData('text/plain', btnInstallUrl);
|
|
||||||
|
|
||||||
// Set drag area active
|
|
||||||
btnInstallDragGroup.classList.add('opacity-50');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Undrag btnInstallUrl
|
|
||||||
btnInstallDrag.addEventListener('dragend', function() {
|
|
||||||
// Set drag area inactive
|
|
||||||
btnInstallDragGroup.classList.remove('opacity-50');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
// Create function btnBack
|
||||||
}
|
function btnBack() {
|
||||||
// Create function init
|
const btnBack = document.querySelectorAll('.js-btn-back');
|
||||||
function init() {
|
|
||||||
btnBack();
|
|
||||||
commentForm();
|
|
||||||
copyInstallUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
btnBack.forEach(function(item) {
|
||||||
init();
|
item.addEventListener('click', function(e) {
|
||||||
});
|
e.preventDefault();
|
||||||
|
window.history.back();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create finction commentForm
|
||||||
|
function commentForm() {
|
||||||
|
const commentForm = document.querySelector('.js-comment-form');
|
||||||
|
if (!commentForm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentFormSelect = commentForm.querySelector('select');
|
||||||
|
if (!commentFormSelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create event comment form select change
|
||||||
|
commentFormSelect.addEventListener('change', function(e) {
|
||||||
|
let value = e.target.value;
|
||||||
|
let verb = 'Comment';
|
||||||
|
const activitySubmitButton = document.getElementById('activity-submit');
|
||||||
|
activitySubmitButton.classList.remove('btn-primary', 'btn-success', 'btn-warning');
|
||||||
|
|
||||||
|
// Hide or show comment form msg on change
|
||||||
|
if (value == 'AWC') {
|
||||||
|
verb = 'Set as Awaiting Changes';
|
||||||
|
activitySubmitButton.classList.add('btn-warning');
|
||||||
|
} else if (value == 'AWR') {
|
||||||
|
verb = 'Set as Awaiting Review';
|
||||||
|
} 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() {
|
||||||
|
function init() {
|
||||||
|
// Create variables
|
||||||
|
const btnInstall = document.querySelector('.js-btn-install');
|
||||||
|
const btnInstallAction = document.querySelector('.js-btn-install-action');
|
||||||
|
const btnInstallGroup = document.querySelector('.js-btn-install-group');
|
||||||
|
const btnInstallDrag = document.querySelector('.js-btn-install-drag');
|
||||||
|
const btnInstallDragGroup = document.querySelector('.js-btn-install-drag-group');
|
||||||
|
|
||||||
|
if (btnInstall == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get data install URL
|
||||||
|
const btnInstallUrl = btnInstall.getAttribute('data-install-url');
|
||||||
|
|
||||||
|
btnInstall.addEventListener('click', function() {
|
||||||
|
// Hide btnInstallGroup
|
||||||
|
btnInstallGroup.classList.add('d-none');
|
||||||
|
|
||||||
|
// Show btnInstallAction
|
||||||
|
btnInstallAction.classList.add('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag btnInstallUrl
|
||||||
|
btnInstallDrag.addEventListener('dragstart', function(e) {
|
||||||
|
// Set data install URL to be transferred during drag
|
||||||
|
e.dataTransfer.setData('text/plain', btnInstallUrl);
|
||||||
|
|
||||||
|
// Set drag area active
|
||||||
|
btnInstallDragGroup.classList.add('opacity-50');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Undrag btnInstallUrl
|
||||||
|
btnInstallDrag.addEventListener('dragend', function() {
|
||||||
|
// Set drag area inactive
|
||||||
|
btnInstallDragGroup.classList.remove('opacity-50');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
// Create function init
|
||||||
|
function init() {
|
||||||
|
agreeWithTerms();
|
||||||
|
btnBack();
|
||||||
|
commentForm();
|
||||||
|
copyInstallUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
.hero-overlay
|
||||||
background-color: transparent
|
background-color: transparent
|
||||||
background-image: linear-gradient(0deg, hsl(213, 10%, 12%), hsla(213, 10%, 14%, 0)) // --color-bg theme dark
|
background-image: linear-gradient(0deg, var(--color-bg), hsla(213, 10%, 14%, 0))
|
||||||
|
|
||||||
&.extension-review
|
&.extension-review
|
||||||
--hero-min-height: 21.0rem
|
--hero-min-height: 21.0rem
|
||||||
|
|
||||||
@ -79,13 +80,6 @@
|
|||||||
.ext-detail-tagline
|
.ext-detail-tagline
|
||||||
+margin(2, bottom)
|
+margin(2, bottom)
|
||||||
|
|
||||||
.ext-detail-description
|
|
||||||
+padding(4)
|
|
||||||
+style-rich-text
|
|
||||||
|
|
||||||
pre
|
|
||||||
+margin(3, bottom)
|
|
||||||
|
|
||||||
.ext-detail-info
|
.ext-detail-info
|
||||||
dd
|
dd
|
||||||
color: var(--color-text)
|
color: var(--color-text)
|
||||||
@ -269,8 +263,6 @@
|
|||||||
+margin(3, left)
|
+margin(3, left)
|
||||||
|
|
||||||
details
|
details
|
||||||
padding: 0
|
|
||||||
|
|
||||||
&[open]
|
&[open]
|
||||||
.show-on-collapse
|
.show-on-collapse
|
||||||
display: none
|
display: none
|
||||||
@ -327,6 +319,8 @@
|
|||||||
|
|
||||||
a
|
a
|
||||||
color: var(--color-text)
|
color: var(--color-text)
|
||||||
|
// TODO: @web-assets check arbitrary style table link display specificity
|
||||||
|
display: inline !important
|
||||||
+padding(1, y)
|
+padding(1, y)
|
||||||
padding-inline: 0 !important
|
padding-inline: 0 !important
|
||||||
|
|
||||||
@ -375,3 +369,7 @@
|
|||||||
@extend .dropdown-divider
|
@extend .dropdown-divider
|
||||||
|
|
||||||
+margin(0, top)
|
+margin(0, top)
|
||||||
|
|
||||||
|
.dropdown-item
|
||||||
|
&a
|
||||||
|
+padding(3, x)
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
/* Aliases to use existing icons as permission slugs. */
|
/* Aliases to use existing icons as permission slugs. */
|
||||||
|
.i-permission-clipboard
|
||||||
|
@extend .i-copy
|
||||||
|
|
||||||
.i-permission-files
|
.i-permission-files
|
||||||
@extend .i-folder
|
@extend .i-folder
|
||||||
|
|
||||||
|
@ -11,7 +11,12 @@
|
|||||||
|
|
||||||
.form-control
|
.form-control
|
||||||
&[type="file"]
|
&[type="file"]
|
||||||
height: calc(var(--spacer) * 2.5)
|
// TODO: @web-assets improve component style
|
||||||
|
height: calc(var(--spacer) * 3.5)
|
||||||
|
|
||||||
|
.invalid-feedback
|
||||||
|
ul
|
||||||
|
+padding(3, left)
|
||||||
|
|
||||||
/* Override Tagger's styling. */
|
/* Override Tagger's styling. */
|
||||||
.was-validated .form-control:invalid,
|
.was-validated .form-control:invalid,
|
||||||
|
@ -9,6 +9,11 @@
|
|||||||
--nav-global-spacer-sm: var(--spacer-2)
|
--nav-global-spacer-sm: var(--spacer-2)
|
||||||
--nav-global-spacer-xs: var(--spacer-1)
|
--nav-global-spacer-xs: var(--spacer-1)
|
||||||
|
|
||||||
|
.btn
|
||||||
|
&:hover
|
||||||
|
background-color: var(--nav-global-color-button-bg-hover)
|
||||||
|
color: var(--nav-global-color-text-hover) !important
|
||||||
|
|
||||||
.btn-primary
|
.btn-primary
|
||||||
color: var(--color-accent) !important
|
color: var(--color-accent) !important
|
||||||
|
|
||||||
|
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
|
.show
|
||||||
opacity: 1
|
opacity: 1
|
||||||
|
|
||||||
|
.style-rich-text
|
||||||
|
+style-rich-text
|
||||||
|
|
||||||
.text-accent
|
.text-accent
|
||||||
color: var(--color-accent)
|
color: var(--color-accent)
|
||||||
|
|
||||||
|
@ -19,6 +19,7 @@ $container-width: map-get($container-max-widths, 'xl')
|
|||||||
@import '_alert.sass'
|
@import '_alert.sass'
|
||||||
@import '_badge.sass'
|
@import '_badge.sass'
|
||||||
@import '_box.sass'
|
@import '_box.sass'
|
||||||
|
@import '_button.sass'
|
||||||
@import '_cards.sass'
|
@import '_cards.sass'
|
||||||
@import '_code.sass'
|
@import '_code.sass'
|
||||||
@import '_comments.sass'
|
@import '_comments.sass'
|
||||||
|
@ -33,7 +33,8 @@
|
|||||||
<body class="has-global-bar">
|
<body class="has-global-bar">
|
||||||
{% switch "is_alpha" %}
|
{% switch "is_alpha" %}
|
||||||
<div class="site-announcement-alpha">
|
<div class="site-announcement-alpha">
|
||||||
This is a test instance. Add-ons and other data might be deleted at any time. <a class="text-underline" href="https://devtalk.blender.org/tag/extensions" target="_blank">Learn more</a>
|
This platform is currently in alpha.
|
||||||
|
<a class="text-underline" href="https://projects.blender.org/infrastructure/extensions-website/issues" target="_blank">Please report any issues you may find</a>, thanks! <a class="text-underline" href="https://devtalk.blender.org/tag/extensions" target="_blank">Learn more</a>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% switch "is_beta" %}
|
{% switch "is_beta" %}
|
||||||
@ -125,7 +126,7 @@
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
{% block nav-upload %}
|
{% block nav-upload %}
|
||||||
<li class="me-2">
|
<li>
|
||||||
<a href="{% url 'extensions:submit' %}" class="btn btn-primary">
|
<a href="{% url 'extensions:submit' %}" class="btn btn-primary">
|
||||||
<i class="i-upload"></i>
|
<i class="i-upload"></i>
|
||||||
<span>Upload Extension</span>
|
<span>Upload Extension</span>
|
||||||
@ -138,11 +139,13 @@
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<a href="{% url 'notifications:notifications' %}">
|
<li>
|
||||||
<i class="i-bell {% if user|unread_notification_count %}text-primary{% endif %}"></i>
|
<a class="btn btn-link px-2" href="{% url 'notifications:notifications' %}">
|
||||||
</a>
|
<i class="i-bell {% if user|unread_notification_count %}text-accent{% endif %}"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<button id="navbarDropdown" aria-expanded="false" aria-haspopup="true" data-toggle-menu-id="nav-account-dropdown" role="button" class="nav-link dropdown-toggle js-dropdown-toggle">
|
<button id="navbarDropdown" aria-expanded="false" aria-haspopup="true" data-toggle-menu-id="nav-account-dropdown" role="button" class="nav-link dropdown-toggle js-dropdown-toggle pe-3 px-2">
|
||||||
<i class="i-user"></i>
|
<i class="i-user"></i>
|
||||||
<i class="i-chevron-down"></i>
|
<i class="i-chevron-down"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -205,7 +208,10 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{% elif page_id != 'login' and page_id != 'register' %}
|
{% elif page_id != 'login' and page_id != 'register' %}
|
||||||
{% include "common/components/nav_item.html" with name="oauth:login" title="Sign in" %}
|
<a href="{% url 'oauth:login' %}" class="btn btn-link">
|
||||||
|
<i class="i-log-in"></i>
|
||||||
|
<span>{% trans "Sign in" %}</span>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
{% load common %}
|
||||||
{% spaceless %}
|
{% spaceless %}
|
||||||
{% with type=field.field.widget.input_type %}
|
{% with type=field.field.widget.input_type classes=classes|default:"" placeholder=placeholder|default:"" %}
|
||||||
|
{% with field=field|remove_cols_rows|add_classes:classes|set_placeholder:placeholder %}
|
||||||
{% firstof label field.label as label %}
|
{% firstof label field.label as label %}
|
||||||
|
|
||||||
{% comment %} Checkboxes {% endcomment %}
|
{% comment %} Checkboxes {% endcomment %}
|
||||||
@ -43,4 +45,5 @@
|
|||||||
<div class="invalid-feedback">{{ field.errors }}</div>
|
<div class="invalid-feedback">{{ field.errors }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
{% endwith %}
|
||||||
{% endspaceless %}
|
{% endspaceless %}
|
||||||
|
@ -29,17 +29,17 @@
|
|||||||
<span>{{ status }}</span>
|
<span>{{ status }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% elif 'untriaged' in status.lower %}
|
{% elif 'confirmed' in status.lower %}
|
||||||
<div class="badge badge-danger {{ class }}">
|
<div class="badge badge-danger {{ class }}">
|
||||||
<span>{{ status }}</span>
|
<span>{{ status }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% elif 'suspicious' in status.lower %}
|
{% elif 'untriaged' in status.lower %}
|
||||||
<div class="badge badge-warning {{ class }}">
|
<div class="badge badge-warning {{ class }}">
|
||||||
<span>{{ status }}</span>
|
<span>{{ status }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% elif 'valid' in status.lower %}
|
{% elif 'resolved' in status.lower %}
|
||||||
<div class="badge badge-success {{ class }}">
|
<div class="badge badge-success {{ class }}">
|
||||||
<span>{{ status }}</span>
|
<span>{{ status }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,6 +2,7 @@ from urllib.parse import urlparse
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.forms.boundfield import BoundField
|
||||||
from django.template import Library, loader
|
from django.template import Library, loader
|
||||||
from django.template.defaultfilters import stringfilter
|
from django.template.defaultfilters import stringfilter
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
@ -166,3 +167,32 @@ def replace(value, old_char_new_char):
|
|||||||
@register.filter(name='unread_notification_count')
|
@register.filter(name='unread_notification_count')
|
||||||
def unread_notification_count(user):
|
def unread_notification_count(user):
|
||||||
return Notification.objects.filter(recipient=user, read_at__isnull=True).count()
|
return Notification.objects.filter(recipient=user, read_at__isnull=True).count()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(name='add_classes')
|
||||||
|
def add_classes(bound_field: BoundField, classes: str):
|
||||||
|
"""Add classes to the `class` attribute of the given form field's widget.
|
||||||
|
|
||||||
|
Expects a string of space-separated classes.
|
||||||
|
"""
|
||||||
|
class_value = bound_field.field.widget.attrs.get('class', '')
|
||||||
|
bound_field.field.widget.attrs['class'] = class_value + f' {classes}'
|
||||||
|
return bound_field
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(name='set_placeholder')
|
||||||
|
def set_placeholder(bound_field: BoundField, placeholder: str):
|
||||||
|
"""Set `placeholder` attribute of the given form field's widget."""
|
||||||
|
bound_field.field.widget.attrs['placeholder'] = placeholder
|
||||||
|
return bound_field
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter(name='remove_cols_rows')
|
||||||
|
def remove_cols_rows(bound_field: BoundField):
|
||||||
|
"""Removes cols and rows attributes from the form field's widget.
|
||||||
|
|
||||||
|
We'd rather go the CSS route when it comes to styling textareas.
|
||||||
|
"""
|
||||||
|
bound_field.field.widget.attrs.pop('cols', None)
|
||||||
|
bound_field.field.widget.attrs.pop('rows', None)
|
||||||
|
return bound_field
|
||||||
|
@ -1,8 +1,19 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from factory.django import DjangoModelFactory
|
from factory.django import DjangoModelFactory
|
||||||
|
from faker import Faker
|
||||||
|
import actstream.models
|
||||||
import factory
|
import factory
|
||||||
|
|
||||||
|
from common.tests.factories.extensions import ExtensionFactory, RatingFactory
|
||||||
|
from common.tests.factories.reviewers import ApprovalActivityFactory
|
||||||
|
from common.tests.factories.users import UserFactory
|
||||||
|
from constants.activity import Verb
|
||||||
|
import notifications.models
|
||||||
|
import reviewers.models
|
||||||
|
|
||||||
|
|
||||||
RELATION_ALLOWED_MODELS = []
|
RELATION_ALLOWED_MODELS = []
|
||||||
|
fake = Faker()
|
||||||
|
|
||||||
|
|
||||||
def generic_foreign_key_id_for_type_factory(generic_relation_type_field):
|
def generic_foreign_key_id_for_type_factory(generic_relation_type_field):
|
||||||
@ -17,3 +28,58 @@ def generic_foreign_key_id_for_type_factory(generic_relation_type_field):
|
|||||||
class ContentTypeFactory(DjangoModelFactory):
|
class ContentTypeFactory(DjangoModelFactory):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ContentType
|
model = ContentType
|
||||||
|
|
||||||
|
|
||||||
|
class ActionFactory(DjangoModelFactory):
|
||||||
|
class Meta:
|
||||||
|
model = actstream.models.Action
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationFactory(DjangoModelFactory):
|
||||||
|
class Meta:
|
||||||
|
model = notifications.models.Notification
|
||||||
|
|
||||||
|
action = factory.SubFactory(ActionFactory)
|
||||||
|
|
||||||
|
|
||||||
|
def construct_fake_notifications() -> list['NotificationFactory']:
|
||||||
|
"""Construct notifications of known types without persisting them in the DB."""
|
||||||
|
fake_extension = ExtensionFactory.build(slug='test')
|
||||||
|
verb_to_action_object = {
|
||||||
|
Verb.APPROVED: ApprovalActivityFactory.build(
|
||||||
|
extension=fake_extension,
|
||||||
|
type=reviewers.models.ApprovalActivity.ActivityType.APPROVED,
|
||||||
|
message=fake.paragraph(nb_sentences=1),
|
||||||
|
),
|
||||||
|
Verb.COMMENTED: ApprovalActivityFactory.build(
|
||||||
|
extension=fake_extension,
|
||||||
|
type=reviewers.models.ApprovalActivity.ActivityType.COMMENT,
|
||||||
|
message=fake.paragraph(nb_sentences=1),
|
||||||
|
),
|
||||||
|
Verb.RATED_EXTENSION: RatingFactory.build(
|
||||||
|
text=fake.paragraph(nb_sentences=2),
|
||||||
|
),
|
||||||
|
Verb.REPORTED_EXTENSION: None, # TODO: fake action_object
|
||||||
|
Verb.REPORTED_RATING: None, # TODO: fake action_object
|
||||||
|
Verb.REQUESTED_CHANGES: ApprovalActivityFactory.build(
|
||||||
|
extension=fake_extension,
|
||||||
|
type=reviewers.models.ApprovalActivity.ActivityType.AWAITING_CHANGES,
|
||||||
|
message=fake.paragraph(nb_sentences=1),
|
||||||
|
),
|
||||||
|
Verb.REQUESTED_REVIEW: ApprovalActivityFactory.build(
|
||||||
|
extension=fake_extension,
|
||||||
|
type=reviewers.models.ApprovalActivity.ActivityType.AWAITING_REVIEW,
|
||||||
|
message=fake.paragraph(nb_sentences=1),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
fake_notifications = [
|
||||||
|
NotificationFactory.build(
|
||||||
|
recipient=UserFactory.build(),
|
||||||
|
action__actor=UserFactory.build(),
|
||||||
|
action__target=fake_extension,
|
||||||
|
action__verb=verb,
|
||||||
|
action__action_object=action_object,
|
||||||
|
)
|
||||||
|
for verb, action_object in verb_to_action_object.items()
|
||||||
|
]
|
||||||
|
return fake_notifications
|
||||||
|
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')
|
oauth_info = factory.RelatedFactory(OAuthUserInfoFactory, factory_related_name='user')
|
||||||
|
|
||||||
|
|
||||||
def create_moderator():
|
def create_moderator(**kwargs):
|
||||||
user = UserFactory()
|
user = UserFactory(**kwargs)
|
||||||
moderators = Group.objects.get(name='moderators')
|
moderators = Group.objects.get(name='moderators')
|
||||||
user.groups.add(moderators)
|
user.groups.add(moderators)
|
||||||
return user
|
return user
|
||||||
|
@ -61,10 +61,9 @@ def _extract_urls(urlpatterns, parents):
|
|||||||
|
|
||||||
|
|
||||||
def extract_urls(urlpatterns=None):
|
def extract_urls(urlpatterns=None):
|
||||||
"""
|
"""Extract URLEntry objects from the given iterable of Django URL pattern objects.
|
||||||
Extract URLEntry objects from the given iterable
|
|
||||||
of Django URL pattern objects. If no iterable is given,
|
If no iterable is given, the patterns exposed by the root resolver are used, i.e.
|
||||||
the patterns exposed by the root resolver are used, i.e.
|
|
||||||
all of the URLs routed in the project.
|
all of the URLs routed in the project.
|
||||||
:param urlpatterns: Iterable of URLPattern objects
|
:param urlpatterns: Iterable of URLPattern objects
|
||||||
:return: Generator of `URLEntry` objects.
|
:return: Generator of `URLEntry` objects.
|
||||||
@ -78,7 +77,10 @@ def extract_urls(urlpatterns=None):
|
|||||||
def _get_all_form_errors(response):
|
def _get_all_form_errors(response):
|
||||||
return (
|
return (
|
||||||
{
|
{
|
||||||
key: response.context[key].errors
|
key: [
|
||||||
|
response.context[key].errors,
|
||||||
|
getattr(response.context[key], 'non_form_errors', lambda: None)(),
|
||||||
|
]
|
||||||
for key in response.context.keys()
|
for key in response.context.keys()
|
||||||
if key.endswith('_formset') or key == 'form' or key.endswith('_form')
|
if key.endswith('_formset') or key == 'form' or key.endswith('_form')
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ from django.utils.safestring import mark_safe
|
|||||||
import django.core.mail
|
import django.core.mail
|
||||||
|
|
||||||
from emails.models import Email
|
from emails.models import Email
|
||||||
from emails.util import construct_email, get_template_context
|
from emails.util import construct_email
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@ -92,16 +92,15 @@ class EmailPreviewAdmin(NoAddDeleteMixin, EmailAdmin):
|
|||||||
def _get_email_sent_message(self, obj):
|
def _get_email_sent_message(self, obj):
|
||||||
return f'Sent a test email "{obj.subject}" to {obj.to} from {obj.from_email}'
|
return f'Sent a test email "{obj.subject}" to {obj.to} from {obj.from_email}'
|
||||||
|
|
||||||
def get_object(self, request, object_id, from_field=None):
|
def get_object(self, request, object_id, from_field=None, fake_context=None):
|
||||||
"""Construct the Email on th fly from known subscription email templates."""
|
"""Construct the Email on the fly from known email templates."""
|
||||||
context = {
|
if not fake_context:
|
||||||
'user': User(),
|
fake_context = self._get_emails_with_fake_context(request)
|
||||||
**get_template_context(),
|
context = fake_context[object_id]
|
||||||
}
|
mail_name = context.get('template', object_id)
|
||||||
mail_name = object_id
|
|
||||||
email_body_html, email_body_txt, subject = construct_email(mail_name, context)
|
email_body_html, email_body_txt, subject = construct_email(mail_name, context)
|
||||||
return EmailPreview(
|
return EmailPreview(
|
||||||
id=mail_name,
|
id=object_id,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
reply_to=settings.DEFAULT_REPLY_TO_EMAIL,
|
reply_to=settings.DEFAULT_REPLY_TO_EMAIL,
|
||||||
@ -109,10 +108,26 @@ class EmailPreviewAdmin(NoAddDeleteMixin, EmailAdmin):
|
|||||||
message=email_body_txt,
|
message=email_body_txt,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_emails_with_fake_context(self, request):
|
||||||
|
email_with_fake_context = {'feedback': {}}
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
from common.tests.factories.notifications import construct_fake_notifications
|
||||||
|
|
||||||
|
fake_notifications = construct_fake_notifications()
|
||||||
|
for fake_notification in fake_notifications:
|
||||||
|
mail_name = fake_notification.original_template_name
|
||||||
|
email_with_fake_context[mail_name] = {
|
||||||
|
'template': fake_notification.template_name,
|
||||||
|
**fake_notification.get_template_context(),
|
||||||
|
}
|
||||||
|
return email_with_fake_context
|
||||||
|
|
||||||
def _get_emails_list(self, request):
|
def _get_emails_list(self, request):
|
||||||
emails = []
|
emails = []
|
||||||
for mail_name in ('feedback',):
|
fake_context = self._get_emails_with_fake_context(request)
|
||||||
emails.append(self.get_object(request, object_id=mail_name))
|
for mail_name in fake_context:
|
||||||
|
emails.append(self.get_object(request, object_id=mail_name, fake_context=fake_context))
|
||||||
return emails
|
return emails
|
||||||
|
|
||||||
def _changeform_view(self, request, object_id, form_url, extra_context):
|
def _changeform_view(self, request, object_id, form_url, extra_context):
|
||||||
|
@ -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="
|
||||||
{# have a title instead of the logo with the remote image #}
|
width: 100%;
|
||||||
<div style="text-align: center; font-weight: bold;">{{ subject }}</div>
|
padding-top: 15px;
|
||||||
{% endblock header_logo %}
|
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 %}
|
||||||
|
<div style="clear:both"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="body" style="
|
||||||
|
width: 80%;
|
||||||
|
max-width: 800px;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
margin-top: 20px; margin-bottom: 20px;
|
||||||
|
margin-left: auto; margin-right: auto;
|
||||||
|
padding-top: 10px; padding-bottom: 10px;
|
||||||
|
padding-left: 20px; padding-right: 20px;
|
||||||
|
border: 1px solid #E9ECEF;
|
||||||
|
border-right: 1px solid #E9ECEF;
|
||||||
|
border-bottom: 1px solid #E9ECEF;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: left;">
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}{% block content %}{% endblock content %}{% endblock body %}
|
||||||
<p>Dear {% firstof user.full_name user.email %},</p>
|
|
||||||
{% block content %}{% endblock content %}
|
</div>
|
||||||
<p>
|
<div class="footer" style="
|
||||||
--<br />
|
width: 100%;
|
||||||
Kind regards,<br />
|
color: #4C4D52;
|
||||||
Blender Extensions Team
|
font-family: 'Lucida Sans Unicode', 'Lucida Grande', sans-serif;
|
||||||
</p>
|
font-size: 0.7em;
|
||||||
{% endblock body %}
|
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>
|
||||||
|
{% endspaceless %}
|
||||||
|
@ -1,9 +1,3 @@
|
|||||||
Dear {% firstof user.full_name user.email %},
|
|
||||||
{% block content %}{% endblock content %}
|
{% block content %}{% endblock content %}
|
||||||
|
|
||||||
Manage your profile: {{ profile_url }}
|
Manage your profile: {{ profile_url }}
|
||||||
|
|
||||||
--
|
|
||||||
Kind regards,
|
|
||||||
|
|
||||||
Blender Extensions Team
|
|
||||||
|
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 %}
|
{% block body %}
|
||||||
{{ email.html_message|safe }}
|
{{ 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."""
|
"""Utilities for rendering email templates."""
|
||||||
from typing import List, Optional, Tuple, Dict, Any
|
from typing import List, Tuple, Dict, Any
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
|
||||||
from django.template import loader
|
|
||||||
from django.core.mail import get_connection, EmailMultiAlternatives
|
from django.core.mail import get_connection, EmailMultiAlternatives
|
||||||
from django.urls import reverse
|
from django.template import loader, TemplateDoesNotExist
|
||||||
|
|
||||||
|
from utils import absolute_url, html_to_text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
def _get_site_url():
|
|
||||||
domain = get_current_site(None).domain
|
|
||||||
return f'https://{domain}'
|
|
||||||
|
|
||||||
|
|
||||||
def absolute_url(
|
|
||||||
view_name: str, args: Optional[tuple] = None, kwargs: Optional[dict] = None
|
|
||||||
) -> str:
|
|
||||||
"""Same as django.urls.reverse() but then as absolute URL.
|
|
||||||
|
|
||||||
For simplicity this assumes HTTPS is used.
|
|
||||||
"""
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
relative_url = reverse(view_name, args=args, kwargs=kwargs)
|
|
||||||
return urljoin(_get_site_url(), relative_url)
|
|
||||||
|
|
||||||
|
|
||||||
def is_noreply(email: str) -> bool:
|
def is_noreply(email: str) -> bool:
|
||||||
"""Return True if the email address is a no-reply address."""
|
"""Return True if the email address is a no-reply address."""
|
||||||
return email.startswith('noreply@') or email.startswith('no-reply@')
|
return email.startswith('noreply@') or email.startswith('no-reply@')
|
||||||
@ -38,8 +20,9 @@ def is_noreply(email: str) -> bool:
|
|||||||
def get_template_context() -> Dict[str, str]:
|
def get_template_context() -> Dict[str, str]:
|
||||||
"""Return additional context for use in an email template."""
|
"""Return additional context for use in an email template."""
|
||||||
return {
|
return {
|
||||||
'site_url': _get_site_url(),
|
'site_url': absolute_url('extensions:home'),
|
||||||
# 'profile_url': absolute_url('profile_update'),
|
'profile_url': absolute_url('users:my-profile'),
|
||||||
|
'notifications_url': absolute_url('notifications:notifications'),
|
||||||
'DEFAULT_REPLY_TO_EMAIL': settings.DEFAULT_REPLY_TO_EMAIL,
|
'DEFAULT_REPLY_TO_EMAIL': settings.DEFAULT_REPLY_TO_EMAIL,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,8 +30,11 @@ def get_template_context() -> Dict[str, str]:
|
|||||||
def construct_email(email_name: str, context: Dict[str, Any]) -> Tuple[str, str, str]:
|
def construct_email(email_name: str, context: Dict[str, Any]) -> Tuple[str, str, str]:
|
||||||
"""Construct an email message.
|
"""Construct an email message.
|
||||||
|
|
||||||
|
If plain text template is not found, text version will be generated from the HTML of the email.
|
||||||
|
|
||||||
:return: tuple (html, text, subject)
|
:return: tuple (html, text, subject)
|
||||||
"""
|
"""
|
||||||
|
context.update(**get_template_context())
|
||||||
base_path = 'emails'
|
base_path = 'emails'
|
||||||
subj_tmpl, html_tmpl, txt_tmpl = (
|
subj_tmpl, html_tmpl, txt_tmpl = (
|
||||||
f'{base_path}/{email_name}_subject.txt',
|
f'{base_path}/{email_name}_subject.txt',
|
||||||
@ -60,7 +46,11 @@ def construct_email(email_name: str, context: Dict[str, Any]) -> Tuple[str, str,
|
|||||||
context['subject'] = subject.strip()
|
context['subject'] = subject.strip()
|
||||||
|
|
||||||
email_body_html = loader.render_to_string(html_tmpl, context)
|
email_body_html = loader.render_to_string(html_tmpl, context)
|
||||||
email_body_txt = loader.render_to_string(txt_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']
|
return email_body_html, email_body_txt, context['subject']
|
||||||
|
|
||||||
|
|
||||||
|
@ -202,8 +202,13 @@ class TagAdmin(admin.ModelAdmin):
|
|||||||
return ()
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
class VersionPermissionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'slug')
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(models.Extension, ExtensionAdmin)
|
admin.site.register(models.Extension, ExtensionAdmin)
|
||||||
admin.site.register(models.Version, VersionAdmin)
|
admin.site.register(models.Version, VersionAdmin)
|
||||||
admin.site.register(models.Maintainer, MaintainerAdmin)
|
admin.site.register(models.Maintainer, MaintainerAdmin)
|
||||||
admin.site.register(models.License, LicenseAdmin)
|
admin.site.register(models.License, LicenseAdmin)
|
||||||
admin.site.register(models.Tag, TagAdmin)
|
admin.site.register(models.Tag, TagAdmin)
|
||||||
|
admin.site.register(models.VersionPermission, VersionPermissionAdmin)
|
||||||
|
@ -2,12 +2,14 @@ import logging
|
|||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
import django.core.exceptions
|
||||||
|
|
||||||
from files.validators import FileMIMETypeValidator
|
from files.validators import FileMIMETypeValidator
|
||||||
from constants.base import ALLOWED_PREVIEW_MIMETYPES
|
from constants.base import ALLOWED_PREVIEW_MIMETYPES
|
||||||
|
|
||||||
import extensions.models
|
import extensions.models
|
||||||
import files.models
|
import files.models
|
||||||
|
import reviewers.models
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -41,7 +43,8 @@ class AddPreviewFileForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = files.models.File
|
model = files.models.File
|
||||||
fields = ('caption', 'source')
|
fields = ('caption', 'source', 'original_hash', 'hash')
|
||||||
|
widgets = {'original_hash': forms.HiddenInput(), 'hash': forms.HiddenInput()}
|
||||||
|
|
||||||
source = forms.FileField(
|
source = forms.FileField(
|
||||||
allow_empty_file=False,
|
allow_empty_file=False,
|
||||||
@ -64,6 +67,27 @@ class AddPreviewFileForm(forms.ModelForm):
|
|||||||
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
|
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean_original_hash(self, *args, **kwargs):
|
||||||
|
"""Calculate original hash of the uploaded file."""
|
||||||
|
if 'source' not in self.cleaned_data:
|
||||||
|
return
|
||||||
|
source = self.cleaned_data['source']
|
||||||
|
return files.models.File.generate_hash(source)
|
||||||
|
|
||||||
|
def clean_hash(self, *args, **kwargs):
|
||||||
|
return self.cleaned_data['original_hash']
|
||||||
|
|
||||||
|
def add_error(self, field, error):
|
||||||
|
"""Add hidden `original_hash`/`hash` errors to the visible `source` field instead."""
|
||||||
|
if isinstance(error, django.core.exceptions.ValidationError):
|
||||||
|
if getattr(error, 'error_dict', None):
|
||||||
|
hash_error = error.error_dict.pop('hash', None)
|
||||||
|
if hash_error:
|
||||||
|
error.error_dict['source'] = hash_error
|
||||||
|
# `original_hash` is treated identically to `hash`, so its errors can be discarded
|
||||||
|
error.error_dict.pop('original_hash', None)
|
||||||
|
super().add_error(field, error)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Save Preview from the cleaned form data."""
|
"""Save Preview from the cleaned form data."""
|
||||||
# Fill in missing fields from request and the source file
|
# Fill in missing fields from request and the source file
|
||||||
@ -81,6 +105,8 @@ class AddPreviewFileForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class AddPreviewModelFormSet(forms.BaseModelFormSet):
|
class AddPreviewModelFormSet(forms.BaseModelFormSet):
|
||||||
|
msg_duplicate_file = _('Please select another file instead of the duplicate')
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.request = kwargs.pop('request')
|
self.request = kwargs.pop('request')
|
||||||
self.extension = kwargs.pop('extension')
|
self.extension = kwargs.pop('extension')
|
||||||
@ -94,6 +120,14 @@ class AddPreviewModelFormSet(forms.BaseModelFormSet):
|
|||||||
form_kwargs['extension'] = self.extension
|
form_kwargs['extension'] = self.extension
|
||||||
return form_kwargs
|
return form_kwargs
|
||||||
|
|
||||||
|
def get_unique_error_message(self, unique_check):
|
||||||
|
"""Replace duplicate `original_hash`/`hash` message with a more meaningful one."""
|
||||||
|
if len(unique_check) == 1:
|
||||||
|
field = unique_check[0]
|
||||||
|
if field in ('original_hash', 'hash'):
|
||||||
|
return self.msg_duplicate_file
|
||||||
|
return super().get_unique_error_message(unique_check)
|
||||||
|
|
||||||
|
|
||||||
AddPreviewFormSet = forms.modelformset_factory(
|
AddPreviewFormSet = forms.modelformset_factory(
|
||||||
files.models.File,
|
files.models.File,
|
||||||
@ -104,6 +138,16 @@ AddPreviewFormSet = forms.modelformset_factory(
|
|||||||
|
|
||||||
|
|
||||||
class ExtensionUpdateForm(forms.ModelForm):
|
class ExtensionUpdateForm(forms.ModelForm):
|
||||||
|
# Messages for auto-generated activity
|
||||||
|
msg_converted_to_draft = _('Converted to Draft')
|
||||||
|
msg_awaiting_review = _('Ready for review')
|
||||||
|
|
||||||
|
# Messages for additional validation
|
||||||
|
msg_cannot_convert_to_draft = _(
|
||||||
|
'An extension can be converted to draft only while it is Awating Review'
|
||||||
|
)
|
||||||
|
msg_need_previews = _('Please add at least one preview.')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = extensions.models.Extension
|
model = extensions.models.Extension
|
||||||
fields = (
|
fields = (
|
||||||
@ -111,6 +155,79 @@ class ExtensionUpdateForm(forms.ModelForm):
|
|||||||
'support',
|
'support',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Pass the request and initialise all the nested form(set)s."""
|
||||||
|
self.request = kwargs.pop('request')
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if self.request.POST:
|
||||||
|
edit_preview_formset = EditPreviewFormSet(
|
||||||
|
self.request.POST, self.request.FILES, instance=self.instance
|
||||||
|
)
|
||||||
|
add_preview_formset = AddPreviewFormSet(
|
||||||
|
self.request.POST,
|
||||||
|
self.request.FILES,
|
||||||
|
extension=self.instance,
|
||||||
|
request=self.request,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
edit_preview_formset = EditPreviewFormSet(instance=self.instance)
|
||||||
|
add_preview_formset = AddPreviewFormSet(extension=self.instance, request=self.request)
|
||||||
|
self.edit_preview_formset = edit_preview_formset
|
||||||
|
self.add_preview_formset = add_preview_formset
|
||||||
|
self.add_preview_formset.error_messages['too_few_forms'] = self.msg_need_previews
|
||||||
|
|
||||||
|
def is_valid(self, *args, **kwargs) -> bool:
|
||||||
|
"""Validate all nested forms and form(set)s first."""
|
||||||
|
# Require at least one preview image when requesting a review
|
||||||
|
if 'submit_draft' in self.data:
|
||||||
|
if not self.instance.previews.exists():
|
||||||
|
self.add_preview_formset.min_num = 1
|
||||||
|
self.add_preview_formset.validate_min = True
|
||||||
|
|
||||||
|
is_valid_flags = [
|
||||||
|
self.edit_preview_formset.is_valid(),
|
||||||
|
self.add_preview_formset.is_valid(),
|
||||||
|
super().is_valid(*args, **kwargs),
|
||||||
|
]
|
||||||
|
return all(is_valid_flags)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Perform additional validation and status changes."""
|
||||||
|
super().clean()
|
||||||
|
# Convert to draft, if possible
|
||||||
|
if 'convert_to_draft' in self.data:
|
||||||
|
if self.instance.status != self.instance.STATUSES.AWAITING_REVIEW:
|
||||||
|
self.add_error(None, self.msg_cannot_convert_to_draft)
|
||||||
|
else:
|
||||||
|
self.instance.status = self.instance.STATUSES.INCOMPLETE
|
||||||
|
self.instance.converted_to_draft = True
|
||||||
|
|
||||||
|
# Send the extension and version to the review, if possible
|
||||||
|
if 'submit_draft' in self.data:
|
||||||
|
self.instance.status = self.instance.STATUSES.AWAITING_REVIEW
|
||||||
|
self.instance.sent_to_review = True
|
||||||
|
return self.cleaned_data
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Save the nested form(set)s, then the main form."""
|
||||||
|
self.edit_preview_formset.save()
|
||||||
|
self.add_preview_formset.save()
|
||||||
|
if getattr(self.instance, 'converted_to_draft', False):
|
||||||
|
reviewers.models.ApprovalActivity(
|
||||||
|
user=self.request.user,
|
||||||
|
extension=self.instance,
|
||||||
|
type=reviewers.models.ApprovalActivity.ActivityType.AWAITING_CHANGES,
|
||||||
|
message=self.msg_converted_to_draft,
|
||||||
|
).save()
|
||||||
|
if getattr(self.instance, 'sent_to_review', False):
|
||||||
|
reviewers.models.ApprovalActivity(
|
||||||
|
user=self.request.user,
|
||||||
|
extension=self.instance,
|
||||||
|
type=reviewers.models.ApprovalActivity.ActivityType.AWAITING_REVIEW,
|
||||||
|
message=self.msg_awaiting_review,
|
||||||
|
).save()
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class ExtensionDeleteForm(forms.ModelForm):
|
class ExtensionDeleteForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -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,
|
EXTENSION_TYPE_SLUGS,
|
||||||
FILE_STATUS_CHOICES,
|
FILE_STATUS_CHOICES,
|
||||||
)
|
)
|
||||||
from constants.licenses import ALL_LICENSES
|
|
||||||
from constants.version_permissions import ALL_VERSION_PERMISSIONS
|
|
||||||
import common.help_texts
|
import common.help_texts
|
||||||
import extensions.fields
|
import extensions.fields
|
||||||
|
|
||||||
@ -90,22 +88,13 @@ class License(CreatedModifiedMixin, models.Model):
|
|||||||
blank=False,
|
blank=False,
|
||||||
null=False,
|
null=False,
|
||||||
help_text='Should be taken from https://spdx.org/licenses/',
|
help_text='Should be taken from https://spdx.org/licenses/',
|
||||||
|
unique=True,
|
||||||
)
|
)
|
||||||
url = models.URLField(blank=False, null=False)
|
url = models.URLField(blank=False, null=False)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f'{self.name}'
|
return f'{self.name}'
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def generate(cls):
|
|
||||||
"""Generate License records from constants."""
|
|
||||||
licenses = [cls(id=li.id, name=li.name, slug=li.slug, url=li.url) for li in ALL_LICENSES]
|
|
||||||
cls.objects.bulk_create(licenses)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_by_name(cls, name: str):
|
|
||||||
return cls.objects.filter(name__startswith=name).first()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_by_slug(cls, slug: str):
|
def get_by_slug(cls, slug: str):
|
||||||
return cls.objects.filter(slug__startswith=slug).first()
|
return cls.objects.filter(slug__startswith=slug).first()
|
||||||
@ -386,28 +375,16 @@ class VersionPermission(CreatedModifiedMixin, models.Model):
|
|||||||
blank=False,
|
blank=False,
|
||||||
null=False,
|
null=False,
|
||||||
help_text='Permissions add-ons are expected to need.',
|
help_text='Permissions add-ons are expected to need.',
|
||||||
|
unique=True,
|
||||||
)
|
)
|
||||||
help = models.CharField(max_length=128, null=False, blank=False, unique=True)
|
help = models.CharField(max_length=128, null=False, blank=False, unique=True)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f'{self.name}'
|
return f'{self.name}'
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def generate(cls):
|
|
||||||
"""Generate Permission records from constants."""
|
|
||||||
permissions = [
|
|
||||||
cls(id=li.id, name=li.name, slug=li.slug, help=li.help)
|
|
||||||
for li in ALL_VERSION_PERMISSIONS
|
|
||||||
]
|
|
||||||
cls.objects.bulk_create(permissions)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_by_name(cls, name: str):
|
|
||||||
return cls.objects.filter(name__startswith=name).first()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_by_slug(cls, slug: str):
|
def get_by_slug(cls, slug: str):
|
||||||
return cls.objects.filter(slug__startswith=slug).first()
|
return cls.objects.get(slug=slug)
|
||||||
|
|
||||||
|
|
||||||
class Tag(CreatedModifiedMixin, models.Model):
|
class Tag(CreatedModifiedMixin, models.Model):
|
||||||
@ -537,12 +514,7 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
|
|||||||
return
|
return
|
||||||
|
|
||||||
for permission_name in _permissions:
|
for permission_name in _permissions:
|
||||||
permission = VersionPermission.get_by_name(permission_name)
|
permission = VersionPermission.get_by_slug(permission_name)
|
||||||
|
|
||||||
# Just ignore versions that are incompatible.
|
|
||||||
if not permission:
|
|
||||||
continue
|
|
||||||
|
|
||||||
self.permissions.add(permission)
|
self.permissions.add(permission)
|
||||||
|
|
||||||
def set_initial_licenses(self, _licenses):
|
def set_initial_licenses(self, _licenses):
|
||||||
|
@ -4,10 +4,10 @@
|
|||||||
href="https://www.blender.org/download/releases/{{ version.blender_version_min|version_without_patch|replace:".,-" }}/"
|
href="https://www.blender.org/download/releases/{{ version.blender_version_min|version_without_patch|replace:".,-" }}/"
|
||||||
title="{{ version.blender_version_min }}">Blender {{ version.blender_version_min|version_without_patch }}</a>
|
title="{{ version.blender_version_min }}">Blender {{ version.blender_version_min|version_without_patch }}</a>
|
||||||
{% if is_editable %}
|
{% if is_editable %}
|
||||||
—
|
<span class="me-2">—</span>
|
||||||
<input name="blender_version_max" class="form-control-sm"
|
<input name="blender_version_max" class="form-control"
|
||||||
value="{{version.blender_version_max|default_if_none:''}}"
|
value="{{version.blender_version_max|default_if_none:''}}"
|
||||||
placeholder="{% trans 'maximum Blender version' %}"
|
placeholder="{% trans 'max. Blender version' %}"
|
||||||
pattern="^([0-9]+\.[0-9]+\.[0-9]+)?$"
|
pattern="^([0-9]+\.[0-9]+\.[0-9]+)?$"
|
||||||
title="{% trans 'Blender version, e.g. 4.1.0' %}"
|
title="{% trans 'Blender version, e.g. 4.1.0' %}"
|
||||||
/>
|
/>
|
||||||
@ -25,4 +25,5 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{% trans 'and newer' %}
|
{% trans 'and newer' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<a href="{{ version.extension.get_review_url }}?report_compatibility_issue&version={{ version.version }}#id_message" title="{% trans 'Report compatibility issue' %}"><i class="i-flag"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{% load common filters %}
|
{% load common filters %}
|
||||||
{% with latest=extension.latest_version thumbnail_360p_url=extension.previews.listed.first.thumbnail_360p_url %}
|
{% with latest=extension.latest_version thumbnail_360p_url=extension.get_previews.0.thumbnail_360p_url %}
|
||||||
<div class="cards-item">
|
<div class="cards-item">
|
||||||
<div class="cards-item-content">
|
<div class="cards-item-content">
|
||||||
<a href="{{ extension.get_absolute_url }}">
|
<a href="{{ extension.get_absolute_url }}">
|
||||||
|
@ -76,7 +76,9 @@
|
|||||||
<div class="dl-row">
|
<div class="dl-row">
|
||||||
<div class="dl-col">
|
<div class="dl-col">
|
||||||
<dt>{% trans 'Compatibility' %}</dt>
|
<dt>{% trans 'Compatibility' %}</dt>
|
||||||
<dd>{% include "extensions/components/blender_version.html" with version=version is_editable=is_editable form=form %}</dd>
|
<dd class="align-items-center d-flex">
|
||||||
|
{% include "extensions/components/blender_version.html" with version=version is_editable=is_editable form=form %}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
{% block extension_description %}
|
{% block extension_description %}
|
||||||
{% if extension.description %}
|
{% if extension.description %}
|
||||||
<section id="about" class="mt-3">
|
<section id="about" class="mt-3">
|
||||||
<div class="box ext-detail-description">
|
<div class="box style-rich-text">
|
||||||
{{ extension.description|markdown }}
|
{{ extension.description|markdown }}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -42,7 +42,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="px-4">
|
<div>
|
||||||
{{ latest.release_notes|markdown }}
|
{{ latest.release_notes|markdown }}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
@ -45,7 +45,7 @@
|
|||||||
{# TODO: fix handling of tags #}
|
{# TODO: fix handling of tags #}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% include "common/components/field.html" %}
|
{% include "common/components/field.html" with placeholder="Enter the text here..." %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -55,7 +55,7 @@
|
|||||||
<section class="mt-4">
|
<section class="mt-4">
|
||||||
<h2>{% trans 'Initial Version' %}</h2>
|
<h2>{% trans 'Initial Version' %}</h2>
|
||||||
<div class="card p-3">
|
<div class="card p-3">
|
||||||
{% include "common/components/field.html" with field=form.release_notes %}
|
{% include "common/components/field.html" with field=form.release_notes placeholder="Add the release notes..." %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -69,7 +69,7 @@
|
|||||||
highlight what makes your {{ type }} special.
|
highlight what makes your {{ type }} special.
|
||||||
{% endblocktranslate %}
|
{% endblocktranslate %}
|
||||||
</p>
|
</p>
|
||||||
|
{% include "extensions/manage/components/edit_previews.html" %}
|
||||||
{% include "extensions/manage/components/add_previews.html" %}
|
{% include "extensions/manage/components/add_previews.html" %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="details flex-grow-1">
|
<div class="details flex-grow-1">
|
||||||
<div class="js-input-img-caption-helper mb-2">
|
<div class="js-input-img-caption-helper mb-2">
|
||||||
{% include "common/components/field.html" with field=inlineform.caption label='Caption' %}
|
{% include "common/components/field.html" with field=inlineform.caption label='Caption' placeholder="Describe the preview" %}
|
||||||
</div>
|
</div>
|
||||||
<div class="align-items-center d-flex js-input-img-helper justify-content-between">
|
<div class="align-items-center d-flex js-input-img-helper justify-content-between">
|
||||||
{% include "common/components/field.html" with field=inlineform.source label='File' %}
|
{% include "common/components/field.html" with field=inlineform.source label='File' %}
|
||||||
|
@ -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">
|
<section class="card p-3">
|
||||||
<div>
|
<div>
|
||||||
{% include "common/components/field.html" with field=form.description label="Description" %}
|
{% include "common/components/field.html" with field=form.description label="Description" placeholder="Describe the extension..." %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{% include "common/components/field.html" with field=form.support %}
|
{% include "common/components/field.html" with field=form.support placeholder="https://example.com" %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-4">
|
<section class="mt-4">
|
||||||
<h2>{% trans 'Previews' %}</h2>
|
<h2>{% trans 'Previews' %}</h2>
|
||||||
<div class="previews-upload">
|
<div class="previews-upload">
|
||||||
{{ edit_preview_formset.management_form }}
|
{% include "extensions/manage/components/edit_previews.html" %}
|
||||||
{{ edit_preview_formset.non_form_errors }}
|
|
||||||
{% if edit_preview_formset.forms|length %}
|
|
||||||
{# View or delete existing preview files #}
|
|
||||||
<div class="previews-list js-previews-drag-container">
|
|
||||||
{% for newform in edit_preview_formset %}
|
|
||||||
{% with inlineform=newform|add_form_classes %}
|
|
||||||
{% with file=inlineform.instance.file %}
|
|
||||||
<div class="previews-list-item" data-preview-position="{{ inlineform.instance.position }}">
|
|
||||||
<div class="js-preview-drag drag-widget is-draggable">
|
|
||||||
<i class="i-menu"></i>
|
|
||||||
<div class="previews-list-item-thumbnail">
|
|
||||||
<div class="previews-list-item-thumbnail-img" style="background-image: url('{% if file.is_image %}{{ file.source.url }}{% elif file.is_video and file.thumbnail %}{{ file.thumbnail.url }}{% endif %}');" title="Preview"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="details">
|
|
||||||
<div>
|
|
||||||
{% include "common/components/field.html" with field=inlineform.id %}
|
|
||||||
{% include "common/components/field.html" with field=inlineform.caption %}
|
|
||||||
{% include "common/components/field.html" with field=inlineform.position %}
|
|
||||||
</div>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="{{ inlineform.instance.file.source.url }}" target="_blank">
|
|
||||||
<small>Source File</small>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="ms-auto">
|
|
||||||
{% include "common/components/field.html" with field=inlineform.DELETE %}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
{{ inlineform.non_form_errors }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endwith %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<hr class="my-4"/>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% include "extensions/manage/components/add_previews.html" %}
|
{% include "extensions/manage/components/add_previews.html" %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -97,13 +55,22 @@
|
|||||||
|
|
||||||
<section class="card p-3 mt-3">
|
<section class="card p-3 mt-3">
|
||||||
<div class="btn-col">
|
<div class="btn-col">
|
||||||
<button id="btn-save" type="submit" class="btn btn-primary">
|
<button id="btn-save" type="submit" class="btn btn-primary" name="save">
|
||||||
<i class="i-check"></i>
|
<i class="i-check"></i>
|
||||||
<span>
|
<span>
|
||||||
{% trans 'Save Changes' %}
|
{% trans 'Save Changes' %}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{% if extension.status == extension.STATUSES.AWAITING_REVIEW %}
|
||||||
|
<button id="btn-convert-to-draft" type="submit" class="btn" name="convert_to_draft">
|
||||||
|
<i class="i-refresh"></i>
|
||||||
|
<span>
|
||||||
|
{% trans 'Convert to Draft' %}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<a href="{{ extension.get_new_version_url }}" class="btn">
|
<a href="{{ extension.get_new_version_url }}" class="btn">
|
||||||
|
@ -25,7 +25,7 @@
|
|||||||
<section class="card p-3">
|
<section class="card p-3">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% include "common/components/field.html" with field=form.release_notes %}
|
{% include "common/components/field.html" with field=form.release_notes placeholder="Add the release notes..." %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if form.non_field_errors or form.file.errors %}
|
{% if form.non_field_errors or form.file.errors %}
|
||||||
|
@ -27,6 +27,16 @@
|
|||||||
<a href="{{ extension.get_absolute_url }}"><strong>{{ extension.name }}</strong></a>
|
<a href="{{ extension.get_absolute_url }}"><strong>{{ extension.name }}</strong></a>
|
||||||
</p>
|
</p>
|
||||||
<hr>
|
<hr>
|
||||||
|
{% if not extension and drafts %}
|
||||||
|
<div>
|
||||||
|
<span class="text-warning">You have unfinished drafts:</span>
|
||||||
|
<ul>
|
||||||
|
{% for d in drafts %}
|
||||||
|
<li><a href="{{ d.get_draft_url }}">{{ d }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<p>Please make sure that:</p>
|
<p>Please make sure that:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>You are the creator or maintainer</strong> of this extension.</li>
|
<li><strong>You are the creator or maintainer</strong> of this extension.</li>
|
||||||
@ -56,11 +66,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col mx-4 mt-4">
|
<div class="col mx-4 mt-4">
|
||||||
{% include "common/components/field.html" with field=form.agreed_with_terms %}
|
{% include "common/components/field.html" with field=form.agreed_with_terms classes="js-agree-with-terms-input" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<button type="submit" class="btn btn-block btn-primary px-5 py-2">
|
<button type="submit" class="btn btn-block btn-primary js-agree-with-terms-btn-submit px-5 py-2" disabled>
|
||||||
<i class="i-upload"></i>
|
<i class="i-upload"></i>
|
||||||
<span>
|
<span>
|
||||||
{% if extension %}
|
{% if extension %}
|
||||||
|
@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<div class="px-4">
|
<div>
|
||||||
{% if version.release_notes %}
|
{% if version.release_notes %}
|
||||||
<h3 class="mb-3">Changelog</h3>
|
<h3 class="mb-3">Changelog</h3>
|
||||||
{{ version.release_notes|markdown }}
|
{{ version.release_notes|markdown }}
|
||||||
@ -55,7 +55,7 @@
|
|||||||
<section class="ext-detail-info box-outline mb-3">
|
<section class="ext-detail-info box-outline mb-3">
|
||||||
<div class="dl-row">
|
<div class="dl-row">
|
||||||
<div class="dl-col">
|
<div class="dl-col">
|
||||||
<dt>Compatibility</dt>
|
<dt>{% trans 'Compatibility' %}</dt>
|
||||||
<dd>{% include "extensions/components/blender_version.html" with version=version %}</dd>
|
<dd>{% include "extensions/components/blender_version.html" with version=version %}</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Binary file not shown.
@ -23,11 +23,12 @@ EXPECTED_EXTENSION_DATA = {
|
|||||||
'name': 'Edit Breakdown',
|
'name': 'Edit Breakdown',
|
||||||
'version': '0.1.0',
|
'version': '0.1.0',
|
||||||
'blender_version_min': '4.2.0',
|
'blender_version_min': '4.2.0',
|
||||||
|
'blender_version_max': '4.3.0',
|
||||||
'type': 'add-on',
|
'type': 'add-on',
|
||||||
'schema_version': "1.0.0",
|
'schema_version': "1.0.0",
|
||||||
},
|
},
|
||||||
'file_hash': 'sha256:4f3664940fc41641c7136a909270a024bbcfb2f8523a06a0d22f85c459b0b1ae',
|
'file_hash': 'sha256:28313858b9be34e6ecd15a63e28f626fb914dbdcc74c6d21c6536c9fad9de426',
|
||||||
'size_bytes': 53959,
|
'size_bytes': 53969,
|
||||||
'tags': ['Sequencer'],
|
'tags': ['Sequencer'],
|
||||||
'version_str': '0.1.0',
|
'version_str': '0.1.0',
|
||||||
'slug': 'edit-breakdown',
|
'slug': 'edit-breakdown',
|
||||||
@ -84,6 +85,24 @@ EXPECTED_VALIDATION_ERRORS = {
|
|||||||
'invalid-manifest-toml.zip': {'source': ['Could not parse the manifest file.']},
|
'invalid-manifest-toml.zip': {'source': ['Could not parse the manifest file.']},
|
||||||
'invalid-theme-multiple-xmls.zip': {'source': ['A theme should have exactly one XML file.']},
|
'invalid-theme-multiple-xmls.zip': {'source': ['A theme should have exactly one XML file.']},
|
||||||
}
|
}
|
||||||
|
POST_DATA = {
|
||||||
|
'preview_set-TOTAL_FORMS': ['0'],
|
||||||
|
'preview_set-INITIAL_FORMS': ['0'],
|
||||||
|
'preview_set-MIN_NUM_FORMS': ['0'],
|
||||||
|
'preview_set-MAX_NUM_FORMS': ['1000'],
|
||||||
|
'preview_set-0-id': [''],
|
||||||
|
# 'preview_set-0-extension': [str(extension.pk)],
|
||||||
|
'preview_set-1-id': [''],
|
||||||
|
# 'preview_set-1-extension': [str(extension.pk)],
|
||||||
|
'form-TOTAL_FORMS': ['0'],
|
||||||
|
'form-INITIAL_FORMS': ['0'],
|
||||||
|
'form-MIN_NUM_FORMS': ['0'],
|
||||||
|
'form-MAX_NUM_FORMS': ['1000'],
|
||||||
|
'form-0-id': '',
|
||||||
|
# 'form-0-caption': ['First Preview Caption Text'],
|
||||||
|
'form-1-id': '',
|
||||||
|
# 'form-1-caption': ['Second Preview Caption Text'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SubmitFileTest(TestCase):
|
class SubmitFileTest(TestCase):
|
||||||
@ -279,20 +298,19 @@ class SubmitFinaliseTest(TestCase):
|
|||||||
|
|
||||||
def test_post_finalise_addon_validation_errors(self):
|
def test_post_finalise_addon_validation_errors(self):
|
||||||
self.client.force_login(self.file.user)
|
self.client.force_login(self.file.user)
|
||||||
response = self.client.post(self.file.get_submit_url(), {})
|
data = {**POST_DATA, 'submit_draft': ''}
|
||||||
|
response = self.client.post(self.file.get_submit_url(), data)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertDictEqual(
|
self.assertDictEqual(
|
||||||
_get_all_form_errors(response),
|
_get_all_form_errors(response),
|
||||||
{
|
{
|
||||||
'form': {},
|
'form': [{}, None],
|
||||||
'extension_form': {
|
'extension_form': [{'description': ['This field is required.']}, None],
|
||||||
'description': ['This field is required.'],
|
'add_preview_formset': [[], ['Please add at least one preview.']],
|
||||||
},
|
'edit_preview_formset': [[], []],
|
||||||
'add_preview_formset': [],
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertFalse('TODO: It should include preview as required')
|
|
||||||
|
|
||||||
def test_post_finalise_addon_creates_addon_with_version_awaiting_review(self):
|
def test_post_finalise_addon_creates_addon_with_version_awaiting_review(self):
|
||||||
self.assertEqual(File.objects.count(), 1)
|
self.assertEqual(File.objects.count(), 1)
|
||||||
@ -317,6 +335,15 @@ class SubmitFinaliseTest(TestCase):
|
|||||||
'form-0-caption': ['First Preview Caption Text'],
|
'form-0-caption': ['First Preview Caption Text'],
|
||||||
'form-1-id': '',
|
'form-1-id': '',
|
||||||
'form-1-caption': ['Second Preview Caption Text'],
|
'form-1-caption': ['Second Preview Caption Text'],
|
||||||
|
# Edit form for existing previews
|
||||||
|
'preview_set-TOTAL_FORMS': ['0'],
|
||||||
|
'preview_set-INITIAL_FORMS': ['0'],
|
||||||
|
'preview_set-MIN_NUM_FORMS': ['0'],
|
||||||
|
'preview_set-MAX_NUM_FORMS': ['1000'],
|
||||||
|
'preview_set-0-id': [''],
|
||||||
|
# 'preview_set-0-extension': [str(extension.pk)],
|
||||||
|
'preview_set-1-id': [''],
|
||||||
|
# 'preview_set-1-extension': [str(extension.pk)],
|
||||||
# Submit for Approval.
|
# Submit for Approval.
|
||||||
'submit_draft': '',
|
'submit_draft': '',
|
||||||
}
|
}
|
||||||
@ -460,3 +487,15 @@ class NewVersionTest(TestCase):
|
|||||||
).count(),
|
).count(),
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DraftsWarningTest(TestCase):
|
||||||
|
fixtures = ['licenses']
|
||||||
|
|
||||||
|
def test_page_contains_warning(self):
|
||||||
|
version = create_version(extension__extension_id='draft_warning')
|
||||||
|
extension = version.extension
|
||||||
|
self.assertEqual(extension.status, Extension.STATUSES.INCOMPLETE)
|
||||||
|
self.client.force_login(extension.authors.all()[0])
|
||||||
|
response = self.client.get(reverse_lazy('extensions:submit'))
|
||||||
|
self.assertContains(response, extension.get_draft_url())
|
||||||
|
@ -2,9 +2,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from common.tests.factories.extensions import create_approved_version
|
from common.tests.factories.extensions import create_approved_version, create_version
|
||||||
|
from common.tests.factories.files import FileFactory
|
||||||
from common.tests.utils import _get_all_form_errors
|
from common.tests.utils import _get_all_form_errors
|
||||||
|
from extensions.models import Extension
|
||||||
from files.models import File
|
from files.models import File
|
||||||
|
from reviewers.models import ApprovalActivity
|
||||||
|
|
||||||
TEST_FILES_DIR = Path(__file__).resolve().parent / 'files'
|
TEST_FILES_DIR = Path(__file__).resolve().parent / 'files'
|
||||||
POST_DATA = {
|
POST_DATA = {
|
||||||
@ -199,6 +202,45 @@ class UpdateTest(TestCase):
|
|||||||
response = self.client.post(url, {**data, **files})
|
response = self.client.post(url, {**data, **files})
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.maxDiff = None
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
response.context['add_preview_formset'].forms[0].errors,
|
||||||
|
response.context['add_preview_formset'].forms[1].errors,
|
||||||
|
response.context['add_preview_formset'].non_form_errors(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{},
|
||||||
|
{'__all__': ['Please correct the duplicate values below.']},
|
||||||
|
[
|
||||||
|
'Please select another file instead of the duplicate',
|
||||||
|
'Please select another file instead of the duplicate',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_post_upload_validation_error_image_already_exists(self):
|
||||||
|
FileFactory(
|
||||||
|
original_hash='sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
|
||||||
|
hash='sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
|
||||||
|
source='file/original_image_source.jpg',
|
||||||
|
)
|
||||||
|
extension = create_approved_version().extension
|
||||||
|
|
||||||
|
data = {
|
||||||
|
**POST_DATA,
|
||||||
|
'form-TOTAL_FORMS': ['1'],
|
||||||
|
}
|
||||||
|
file_name1 = 'test_preview_image_0001.png'
|
||||||
|
url = extension.get_manage_url()
|
||||||
|
user = extension.authors.first()
|
||||||
|
self.client.force_login(user)
|
||||||
|
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1:
|
||||||
|
files = {'form-0-source': fp1}
|
||||||
|
response = self.client.post(url, {**data, **files})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.maxDiff = None
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
response.context['add_preview_formset'].forms[0].errors,
|
response.context['add_preview_formset'].forms[0].errors,
|
||||||
{'source': ['File with this Hash already exists.']},
|
{'source': ['File with this Hash already exists.']},
|
||||||
@ -280,3 +322,28 @@ class UpdateTest(TestCase):
|
|||||||
response.context['add_preview_formset'].forms[0].errors,
|
response.context['add_preview_formset'].forms[0].errors,
|
||||||
{'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']},
|
{'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_convert_to_draft(self):
|
||||||
|
version = create_version(extension__status=Extension.STATUSES.AWAITING_REVIEW)
|
||||||
|
extension = version.extension
|
||||||
|
url = extension.get_manage_url()
|
||||||
|
user = extension.authors.first()
|
||||||
|
self.client.force_login(user)
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertContains(response, 'convert_to_draft')
|
||||||
|
response2 = self.client.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
**POST_DATA,
|
||||||
|
'convert_to_draft': '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response2.status_code, 302)
|
||||||
|
extension.refresh_from_db()
|
||||||
|
self.assertEqual(extension.status, extension.STATUSES.INCOMPLETE)
|
||||||
|
self.assertEqual(
|
||||||
|
extension.review_activity.last().type, ApprovalActivity.ActivityType.AWAITING_CHANGES
|
||||||
|
)
|
||||||
|
response3 = self.client.get(url)
|
||||||
|
self.assertEqual(response3.status_code, 302)
|
||||||
|
self.assertEqual(response3['Location'], extension.get_draft_url())
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
"""Contains views allowing developers to manage their add-ons."""
|
"""Contains views allowing developers to manage their add-ons."""
|
||||||
from django import forms
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.shortcuts import get_object_or_404, reverse
|
from django.shortcuts import get_object_or_404, redirect, reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
|
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
|
||||||
|
|
||||||
@ -14,11 +12,8 @@ from .mixins import (
|
|||||||
OwnsFileMixin,
|
OwnsFileMixin,
|
||||||
MaintainedExtensionMixin,
|
MaintainedExtensionMixin,
|
||||||
DraftVersionMixin,
|
DraftVersionMixin,
|
||||||
DraftMixin,
|
|
||||||
)
|
)
|
||||||
from extensions.forms import (
|
from extensions.forms import (
|
||||||
EditPreviewFormSet,
|
|
||||||
AddPreviewFormSet,
|
|
||||||
ExtensionDeleteForm,
|
ExtensionDeleteForm,
|
||||||
ExtensionUpdateForm,
|
ExtensionUpdateForm,
|
||||||
VersionForm,
|
VersionForm,
|
||||||
@ -27,7 +22,6 @@ from extensions.forms import (
|
|||||||
from extensions.models import Extension, Version
|
from extensions.models import Extension, Version
|
||||||
from files.forms import FileForm
|
from files.forms import FileForm
|
||||||
from files.models import File
|
from files.models import File
|
||||||
from reviewers.models import ApprovalActivity
|
|
||||||
from stats.models import ExtensionView
|
from stats.models import ExtensionView
|
||||||
import ratings.models
|
import ratings.models
|
||||||
|
|
||||||
@ -104,7 +98,6 @@ class UpdateExtensionView(
|
|||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
MaintainedExtensionMixin,
|
MaintainedExtensionMixin,
|
||||||
SuccessMessageMixin,
|
SuccessMessageMixin,
|
||||||
DraftMixin,
|
|
||||||
UpdateView,
|
UpdateView,
|
||||||
):
|
):
|
||||||
model = Extension
|
model = Extension
|
||||||
@ -112,59 +105,32 @@ class UpdateExtensionView(
|
|||||||
form_class = ExtensionUpdateForm
|
form_class = ExtensionUpdateForm
|
||||||
success_message = "Updated successfully"
|
success_message = "Updated successfully"
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
"""Pass request object to the form."""
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['request'] = self.request
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
extension = self.extension
|
||||||
|
if extension.status == extension.STATUSES.INCOMPLETE:
|
||||||
|
return redirect('extensions:draft', slug=extension.slug, type_slug=extension.type_slug)
|
||||||
|
else:
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
self.object.refresh_from_db()
|
self.object.refresh_from_db()
|
||||||
return self.object.get_manage_url()
|
return self.object.get_manage_url()
|
||||||
|
|
||||||
def get_context_data(self, *args, **kwargs):
|
def get_context_data(self, *args, **kwargs):
|
||||||
context = super().get_context_data(*args, **kwargs)
|
context = super().get_context_data(*args, **kwargs)
|
||||||
edit_preview_formset = kwargs.pop('edit_preview_formset', None)
|
context['edit_preview_formset'] = context['form'].edit_preview_formset
|
||||||
add_preview_formset = kwargs.pop('add_preview_formset', None)
|
context['add_preview_formset'] = context['form'].add_preview_formset
|
||||||
if not (edit_preview_formset and add_preview_formset):
|
|
||||||
if self.request.POST:
|
|
||||||
edit_preview_formset = EditPreviewFormSet(
|
|
||||||
self.request.POST, self.request.FILES, instance=self.object
|
|
||||||
)
|
|
||||||
add_preview_formset = AddPreviewFormSet(
|
|
||||||
self.request.POST,
|
|
||||||
self.request.FILES,
|
|
||||||
extension=self.object,
|
|
||||||
request=self.request,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
edit_preview_formset = EditPreviewFormSet(instance=self.object)
|
|
||||||
add_preview_formset = AddPreviewFormSet(extension=self.object, request=self.request)
|
|
||||||
context['edit_preview_formset'] = edit_preview_formset
|
|
||||||
context['add_preview_formset'] = add_preview_formset
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def form_invalid(self, form, edit_preview_formset=None, add_preview_formset=None):
|
|
||||||
return self.render_to_response(
|
|
||||||
self.get_context_data(
|
|
||||||
form=form,
|
|
||||||
edit_preview_formset=edit_preview_formset,
|
|
||||||
add_preview_formset=add_preview_formset,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def form_valid(self, form):
|
def form_valid(self, *args, **kwargs):
|
||||||
edit_preview_formset = EditPreviewFormSet(
|
return super().form_valid(*args, **kwargs)
|
||||||
self.request.POST, self.request.FILES, instance=self.object
|
|
||||||
)
|
|
||||||
add_preview_formset = AddPreviewFormSet(
|
|
||||||
self.request.POST, self.request.FILES, extension=self.object, request=self.request
|
|
||||||
)
|
|
||||||
if edit_preview_formset.is_valid() and add_preview_formset.is_valid():
|
|
||||||
try:
|
|
||||||
edit_preview_formset.save()
|
|
||||||
add_preview_formset.save()
|
|
||||||
response = super().form_valid(form)
|
|
||||||
return response
|
|
||||||
except forms.ValidationError as e:
|
|
||||||
if 'hash' in e.error_dict:
|
|
||||||
add_preview_formset.forms[0].add_error('source', e.error_dict['hash'])
|
|
||||||
return self.form_invalid(form, edit_preview_formset, add_preview_formset)
|
|
||||||
|
|
||||||
|
|
||||||
class DeleteExtensionView(
|
class DeleteExtensionView(
|
||||||
@ -296,14 +262,6 @@ class NewVersionFinalizeView(LoginRequiredMixin, OwnsFileMixin, CreateView):
|
|||||||
template_name = 'extensions/new_version_finalise.html'
|
template_name = 'extensions/new_version_finalise.html'
|
||||||
form_class = VersionForm
|
form_class = VersionForm
|
||||||
|
|
||||||
def form_valid(self, form):
|
|
||||||
# Automatically approve new versions, for now.
|
|
||||||
# We rely on approval of the first submission, for now.
|
|
||||||
self.file.status = self.file.STATUSES.APPROVED
|
|
||||||
self.file.save()
|
|
||||||
response = super().form_valid(form)
|
|
||||||
return response
|
|
||||||
|
|
||||||
def _get_extension(self) -> 'Extension':
|
def _get_extension(self) -> 'Extension':
|
||||||
return get_object_or_404(Extension, slug=self.kwargs['slug'])
|
return get_object_or_404(Extension, slug=self.kwargs['slug'])
|
||||||
|
|
||||||
@ -359,7 +317,6 @@ class DraftExtensionView(
|
|||||||
):
|
):
|
||||||
template_name = 'extensions/draft_finalise.html'
|
template_name = 'extensions/draft_finalise.html'
|
||||||
form_class = VersionForm
|
form_class = VersionForm
|
||||||
msg_awaiting_review = _('Extension is ready for initial review')
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def success_message(self) -> str:
|
def success_message(self) -> str:
|
||||||
@ -383,60 +340,38 @@ class DraftExtensionView(
|
|||||||
initial.update(**self.version.file.parsed_version_fields)
|
initial.update(**self.version.file.parsed_version_fields)
|
||||||
return initial
|
return initial
|
||||||
|
|
||||||
def get_context_data(self, form=None, extension_form=None, add_preview_formset=None, **kwargs):
|
def get_context_data(self, form=None, extension_form=None, **kwargs):
|
||||||
"""Add all the additional forms to the context."""
|
"""Add all the additional forms to the context."""
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
if not (add_preview_formset and extension_form):
|
if not extension_form:
|
||||||
extension_form = ExtensionUpdateForm(instance=self.extension)
|
extension_form = ExtensionUpdateForm(instance=self.extension, request=self.request)
|
||||||
add_preview_formset = AddPreviewFormSet(extension=self.extension, request=self.request)
|
|
||||||
context['extension_form'] = extension_form
|
context['extension_form'] = extension_form
|
||||||
context['add_preview_formset'] = add_preview_formset
|
context['edit_preview_formset'] = extension_form.edit_preview_formset
|
||||||
|
context['add_preview_formset'] = extension_form.add_preview_formset
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""Handle bound forms and valid/invalid logic with the extra forms."""
|
"""Handle bound forms and valid/invalid logic with the extra forms."""
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
extension_form = ExtensionUpdateForm(
|
extension_form = ExtensionUpdateForm(
|
||||||
self.request.POST, self.request.FILES, instance=self.extension
|
self.request.POST, self.request.FILES, instance=self.extension, request=self.request
|
||||||
)
|
)
|
||||||
add_preview_formset = AddPreviewFormSet(
|
if form.is_valid() and extension_form.is_valid():
|
||||||
self.request.POST, self.request.FILES, extension=self.extension, request=self.request
|
return self.form_valid(form, extension_form)
|
||||||
)
|
return self.form_invalid(form, extension_form)
|
||||||
if form.is_valid() and extension_form.is_valid() and add_preview_formset.is_valid():
|
|
||||||
return self.form_valid(form, extension_form, add_preview_formset)
|
|
||||||
return self.form_invalid(form, extension_form, add_preview_formset)
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def form_valid(self, form, extension_form, add_preview_formset):
|
def form_valid(self, form, extension_form):
|
||||||
"""Save all the forms in correct order.
|
"""Save all the forms in correct order.
|
||||||
|
|
||||||
Extension must be saved first.
|
Extension must be saved first.
|
||||||
"""
|
"""
|
||||||
try:
|
extension_form.save()
|
||||||
# Send the extension and version to the review
|
form.save()
|
||||||
if 'submit_draft' in self.request.POST:
|
return super().form_valid(form)
|
||||||
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):
|
def form_invalid(self, form, extension_form):
|
||||||
return self.render_to_response(
|
return self.render_to_response(self.get_context_data(form, extension_form))
|
||||||
self.get_context_data(form, extension_form, add_preview_formset)
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return self.extension.get_manage_url()
|
return self.extension.get_manage_url()
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
from extensions.models import Extension
|
from extensions.models import Extension
|
||||||
from files.models import File
|
from files.models import File
|
||||||
@ -60,27 +60,3 @@ class DraftVersionMixin:
|
|||||||
def dispatch(self, *args, **kwargs):
|
def dispatch(self, *args, **kwargs):
|
||||||
self.version = self.extension.versions.first()
|
self.version = self.extension.versions.first()
|
||||||
return super().dispatch(*args, **kwargs)
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class DraftMixin:
|
|
||||||
"""If the extension is incomplete, returns the FinalizeDraftView"""
|
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
|
||||||
if (
|
|
||||||
'slug' in kwargs
|
|
||||||
and Extension.objects.filter(
|
|
||||||
slug=kwargs['slug'], status=Extension.STATUSES.APPROVED
|
|
||||||
).first()
|
|
||||||
):
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
|
||||||
|
|
||||||
extension = (
|
|
||||||
Extension.objects.listed_or_authored_by(user_id=self.request.user.pk)
|
|
||||||
.filter(status=Extension.STATUSES.INCOMPLETE)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not extension:
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
|
||||||
|
|
||||||
return redirect(extension.get_draft_url())
|
|
||||||
|
@ -4,7 +4,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.views.generic.edit import CreateView
|
from django.views.generic.edit import CreateView
|
||||||
|
|
||||||
from .mixins import DraftMixin
|
|
||||||
from extensions.models import Version, Extension
|
from extensions.models import Version, Extension
|
||||||
from files.forms import FileForm
|
from files.forms import FileForm
|
||||||
from files.models import File
|
from files.models import File
|
||||||
@ -12,11 +11,19 @@ from files.models import File
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class UploadFileView(LoginRequiredMixin, DraftMixin, CreateView):
|
class UploadFileView(LoginRequiredMixin, CreateView):
|
||||||
model = File
|
model = File
|
||||||
template_name = 'extensions/submit.html'
|
template_name = 'extensions/submit.html'
|
||||||
form_class = FileForm
|
form_class = FileForm
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
drafts = Extension.objects.authored_by(user_id=self.request.user.pk).filter(
|
||||||
|
status=Extension.STATUSES.INCOMPLETE
|
||||||
|
)
|
||||||
|
context['drafts'] = drafts
|
||||||
|
return context
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
kwargs = super().get_form_kwargs()
|
||||||
kwargs['request'] = self.request
|
kwargs['request'] = self.request
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import os.path
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@ -12,7 +13,7 @@ import files.models
|
|||||||
import files.tasks
|
import files.tasks
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipUnless(shutil.which('clamd'), 'requires clamd')
|
@unittest.skipUnless(os.path.exists('/var/run/clamav/clamd.ctl'), 'requires clamd running')
|
||||||
@override_settings(MEDIA_ROOT='/tmp/')
|
@override_settings(MEDIA_ROOT='/tmp/')
|
||||||
class FileScanTest(TestCase):
|
class FileScanTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
@ -54,12 +54,11 @@ class FileMIMETypeValidator:
|
|||||||
|
|
||||||
|
|
||||||
class ExtensionIDManifestValidator:
|
class ExtensionIDManifestValidator:
|
||||||
"""
|
"""Make sure the extension id is valid:
|
||||||
Make sure the extension id is valid:
|
* Extension id consists of Unicode letters, numbers or underscores.
|
||||||
* Extension id consists of Unicode letters, numbers or underscores.
|
* Neither hyphens nor spaces are supported.
|
||||||
* Neither hyphens nor spaces are supported.
|
* Each extension id most be unique across all extensions.
|
||||||
* Each extension id most be unique across all extensions.
|
* All versions of an extension must have the same extension id.
|
||||||
* All versions of an extension must have the same extension id.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, manifest, extension_to_be_updated):
|
def __init__(self, manifest, extension_to_be_updated):
|
||||||
@ -307,10 +306,11 @@ class PermissionsValidator:
|
|||||||
is_error = True
|
is_error = True
|
||||||
else:
|
else:
|
||||||
for permission in value:
|
for permission in value:
|
||||||
if VersionPermission.get_by_slug(permission):
|
try:
|
||||||
continue
|
VersionPermission.get_by_slug(permission)
|
||||||
is_error = True
|
except VersionPermission.DoesNotExist:
|
||||||
logger.info(f'Permission unavailable: {permission}')
|
is_error = True
|
||||||
|
logger.info(f'Permission unavailable: {permission}')
|
||||||
|
|
||||||
if not is_error:
|
if not is_error:
|
||||||
return
|
return
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
"""Send user notifications as emails, at most once delivery."""
|
"""Send user notifications as emails, at most once delivery."""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.mail import send_mail
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from emails.util import construct_and_send_email
|
||||||
from notifications.models import Notification
|
from notifications.models import Notification
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -36,12 +35,6 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
|
|
||||||
def send_notification_email(notification):
|
def send_notification_email(notification):
|
||||||
# TODO construct a proper phrase, depending on the verb,
|
template_name = notification.template_name
|
||||||
# possibly share a template with NotificationsView
|
context = notification.get_template_context()
|
||||||
subject, message = notification.format_email()
|
construct_and_send_email(template_name, context, recipient_list=[notification.recipient.email])
|
||||||
send_mail(
|
|
||||||
subject,
|
|
||||||
message,
|
|
||||||
settings.DEFAULT_FROM_EMAIL,
|
|
||||||
[notification.recipient.email],
|
|
||||||
)
|
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from actstream.models import Action
|
from actstream.models import Action
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.template import loader, TemplateDoesNotExist
|
||||||
|
|
||||||
from constants.activity import Verb
|
from constants.activity import Verb
|
||||||
from utils import absolutify
|
from utils import absolutify
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Notification(models.Model):
|
class Notification(models.Model):
|
||||||
@ -30,23 +34,62 @@ class Notification(models.Model):
|
|||||||
]
|
]
|
||||||
unique_together = ['recipient', 'action']
|
unique_together = ['recipient', 'action']
|
||||||
|
|
||||||
def format_email(self):
|
@property
|
||||||
|
def original_template_name(self) -> str:
|
||||||
|
"""Template name constructed from action type, target and so on.
|
||||||
|
|
||||||
|
If we want to override email template for this specific notification,
|
||||||
|
this is the name of the template that should be created.
|
||||||
|
"""
|
||||||
action = self.action
|
action = self.action
|
||||||
subject = f'New Activity: {action.actor} {action.verb} {action.target}'
|
action_object = action.action_object
|
||||||
url = self.get_absolute_url()
|
target_name = action.target.__class__.__name__.lower()
|
||||||
mesage = f'{action.actor} {action.verb} {action.target}: {url}'
|
action_object_name = action_object.__class__.__name__.lower() if action_object else ''
|
||||||
return (subject, mesage)
|
return f"new_activity_{action_object_name}_{target_name}_{action.verb.replace(' ', '_')}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def template_name(self) -> str:
|
||||||
|
"""Template name to be used for constructing notification email."""
|
||||||
|
default_name = 'new_activity'
|
||||||
|
name = self.original_template_name
|
||||||
|
args = {'name': name, 'default_name': default_name}
|
||||||
|
|
||||||
|
try:
|
||||||
|
loader.get_template(f'emails/{name}.html')
|
||||||
|
except TemplateDoesNotExist:
|
||||||
|
logger.warning('Template %(name)s does not exist, using %(default_name)s', args)
|
||||||
|
return default_name
|
||||||
|
return name
|
||||||
|
|
||||||
|
def get_template_context(self):
|
||||||
|
"""Return template context to be used in the notification email."""
|
||||||
|
action = self.action
|
||||||
|
action_object = action.action_object
|
||||||
|
quoted_message = getattr(action_object, 'message', getattr(action_object, 'text', ''))
|
||||||
|
context = {
|
||||||
|
'Verb': Verb,
|
||||||
|
'action': action,
|
||||||
|
'action_object': action_object,
|
||||||
|
'notification': self,
|
||||||
|
'quoted_message': quoted_message,
|
||||||
|
'target': action.target,
|
||||||
|
'url': self.get_absolute_url(),
|
||||||
|
'user': self.recipient,
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
if self.action.verb == Verb.RATED_EXTENSION:
|
if self.action.verb == Verb.RATED_EXTENSION:
|
||||||
url = self.action.target.get_ratings_url()
|
fragment = f'#review-{self.action.action_object.pk}'
|
||||||
|
url = self.action.target.get_ratings_url() + fragment
|
||||||
elif self.action.verb in [
|
elif self.action.verb in [
|
||||||
Verb.APPROVED,
|
Verb.APPROVED,
|
||||||
Verb.COMMENTED,
|
Verb.COMMENTED,
|
||||||
Verb.REQUESTED_CHANGES,
|
Verb.REQUESTED_CHANGES,
|
||||||
Verb.REQUESTED_REVIEW,
|
Verb.REQUESTED_REVIEW,
|
||||||
]:
|
]:
|
||||||
url = self.action.target.get_review_url()
|
fragment = f'#activity-{self.action.action_object.pk}'
|
||||||
|
url = self.action.target.get_review_url() + fragment
|
||||||
elif self.action.action_object is not None:
|
elif self.action.action_object is not None:
|
||||||
url = self.action.action_object.get_absolute_url()
|
url = self.action.action_object.get_absolute_url()
|
||||||
else:
|
else:
|
||||||
|
@ -4,45 +4,64 @@
|
|||||||
{% block page_title %}{% blocktranslate %}Notifications{% endblocktranslate %}{% endblock page_title %}
|
{% block page_title %}{% blocktranslate %}Notifications{% endblocktranslate %}{% endblock page_title %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>
|
<h1>{% trans 'Notifications' %}</h1>
|
||||||
{% trans 'Notifications' %}
|
|
||||||
{% if user|unread_notification_count %}
|
|
||||||
<form class="d-inline" action="{% url 'notifications:notifications-mark-read-all' %}" method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button class="btn btn-sm" type="submit">{% trans 'Mark all as read' %}</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</h1>
|
|
||||||
{% if notification_list %}
|
|
||||||
{% for notification in notification_list %}
|
|
||||||
<div class="row mb-2 {% if notification.read_at%}text-muted{% endif %}">
|
|
||||||
<div class="col">
|
|
||||||
|
|
||||||
{{ notification.action.timestamp | naturaltime_compact }}
|
|
||||||
|
|
||||||
<a href="{% url 'extensions:by-author' user_id=notification.action.actor.pk %}">
|
{% if notification_list %}
|
||||||
{{ notification.action.actor }}
|
<div class="notifications">
|
||||||
</a>
|
{% if user|unread_notification_count %}
|
||||||
|
<form action="{% url 'notifications:notifications-mark-read-all' %}" method="post">
|
||||||
{{ notification.action.verb }}
|
{% csrf_token %}
|
||||||
|
<button class="btn mb-3" type="submit"><i class="i-eye"></i> {% trans 'Mark all as read' %}</button>
|
||||||
<a href="{{ notification.action.target.get_absolute_url }}">{{ notification.action.target }}</a>
|
</form>
|
||||||
|
|
||||||
<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">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button class="btn btn-sm" type="submit">{% trans 'Mark as read' %}</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="box">
|
||||||
|
<table class="notifications-list">
|
||||||
|
<tbody>
|
||||||
|
{% for notification in notification_list %}
|
||||||
|
<tr class="notifications-item {% if notification.read_at%}is-read{% endif %}">
|
||||||
|
<td class="notifications-item-time">
|
||||||
|
{{ notification.action.timestamp | naturaltime_compact }}
|
||||||
|
</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="dropdown-item" title="Mark as read" type="submit"><i class="i-eye"></i> Mark as read </button>
|
||||||
|
</form>
|
||||||
|
</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 %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% else %}
|
||||||
{% else %}
|
<p>
|
||||||
<p>
|
{% trans 'You have no notifications' %}
|
||||||
{% trans 'You have no notifications' %}
|
</p>
|
||||||
</p>
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.core import mail
|
||||||
|
from django.core.management import call_command
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from common.tests.factories.extensions import create_approved_version, create_version
|
from common.tests.factories.extensions import create_approved_version, create_version
|
||||||
from common.tests.factories.files import FileFactory
|
from common.tests.factories.files import FileFactory
|
||||||
@ -17,7 +20,9 @@ class TestTasks(TestCase):
|
|||||||
fixtures = ['dev', 'licenses']
|
fixtures = ['dev', 'licenses']
|
||||||
|
|
||||||
def test_ratings(self):
|
def test_ratings(self):
|
||||||
extension = create_approved_version(ratings=[]).extension
|
extension = create_approved_version(
|
||||||
|
ratings=[], file__user__confirmed_email_at=timezone.now()
|
||||||
|
).extension
|
||||||
author = extension.authors.first()
|
author = extension.authors.first()
|
||||||
notification_nr = Notification.objects.filter(recipient=author).count()
|
notification_nr = Notification.objects.filter(recipient=author).count()
|
||||||
some_user = UserFactory()
|
some_user = UserFactory()
|
||||||
@ -29,14 +34,35 @@ class TestTasks(TestCase):
|
|||||||
new_notification_nr = Notification.objects.filter(recipient=author).count()
|
new_notification_nr = Notification.objects.filter(recipient=author).count()
|
||||||
self.assertEqual(new_notification_nr, notification_nr + 1)
|
self.assertEqual(new_notification_nr, notification_nr + 1)
|
||||||
|
|
||||||
|
# Call the command that sends notification emails
|
||||||
|
call_command('send_notification_emails')
|
||||||
|
|
||||||
|
# Test that one message has been sent.
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
|
||||||
|
email = mail.outbox[0]
|
||||||
|
self.assertEqual(email.subject, f'Add-on rated: "{extension.name}"')
|
||||||
|
self.assertEqual(email.to, [author.email])
|
||||||
|
email_text = email.body
|
||||||
|
self.maxDiff = None
|
||||||
|
expected_text = expected_rated_text.format(**locals())
|
||||||
|
self.assertEqual(email_text, expected_text)
|
||||||
|
|
||||||
|
# Check that most important action and relevant URL are in the HTML version
|
||||||
|
email_html = email.alternatives[0][0]
|
||||||
|
self.assertIn(' rated ', email_html)
|
||||||
|
self.assertIn(
|
||||||
|
f'https://extensions.local:8111/add-ons/{extension.slug}/reviews/', email_html
|
||||||
|
)
|
||||||
|
|
||||||
def test_abuse(self):
|
def test_abuse(self):
|
||||||
extension = create_approved_version(ratings=[]).extension
|
extension = create_approved_version(ratings=[]).extension
|
||||||
moderator = create_moderator()
|
moderator = create_moderator(confirmed_email_at=timezone.now())
|
||||||
notification_nr = Notification.objects.filter(recipient=moderator).count()
|
notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||||
some_user = UserFactory()
|
some_user = UserFactory()
|
||||||
self.client.force_login(some_user)
|
self.client.force_login(some_user)
|
||||||
url = extension.get_report_url()
|
url = extension.get_report_url()
|
||||||
self.client.post(
|
response = self.client.post(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'message': 'test message',
|
'message': 'test message',
|
||||||
@ -44,11 +70,31 @@ class TestTasks(TestCase):
|
|||||||
'version': '',
|
'version': '',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
report_url = response['Location']
|
||||||
new_notification_nr = Notification.objects.filter(recipient=moderator).count()
|
new_notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||||
self.assertEqual(new_notification_nr, notification_nr + 1)
|
self.assertEqual(new_notification_nr, notification_nr + 1)
|
||||||
|
|
||||||
|
call_command('send_notification_emails')
|
||||||
|
|
||||||
|
# Test that one message has been sent.
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
|
||||||
|
email = mail.outbox[0]
|
||||||
|
self.assertEqual(email.subject, f'Add-on reported: "{extension.name}"')
|
||||||
|
self.assertEqual(email.to, [moderator.email])
|
||||||
|
email_text = email.body
|
||||||
|
self.maxDiff = None
|
||||||
|
expected_text = expected_abuse_report_text.format(**locals())
|
||||||
|
self.assertEqual(email_text, expected_text)
|
||||||
|
|
||||||
|
# Check that most important action and relevant URL are in the HTML version
|
||||||
|
email_html = email.alternatives[0][0]
|
||||||
|
self.assertIn(report_url, email_html)
|
||||||
|
self.assertIn(' reported ', email_html)
|
||||||
|
|
||||||
def test_new_extension_submitted(self):
|
def test_new_extension_submitted(self):
|
||||||
moderator = create_moderator()
|
moderator = create_moderator(confirmed_email_at=timezone.now())
|
||||||
notification_nr = Notification.objects.filter(recipient=moderator).count()
|
notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||||
some_user = UserFactory()
|
some_user = UserFactory()
|
||||||
file_data = {
|
file_data = {
|
||||||
@ -117,10 +163,29 @@ class TestTasks(TestCase):
|
|||||||
new_notification_nr = Notification.objects.filter(recipient=moderator).count()
|
new_notification_nr = Notification.objects.filter(recipient=moderator).count()
|
||||||
self.assertEqual(new_notification_nr, notification_nr + 1)
|
self.assertEqual(new_notification_nr, notification_nr + 1)
|
||||||
|
|
||||||
|
call_command('send_notification_emails')
|
||||||
|
|
||||||
|
# Test that one message has been sent.
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
|
||||||
|
email = mail.outbox[0]
|
||||||
|
extension = file.version.extension
|
||||||
|
self.assertEqual(email.subject, 'Add-on review requested: "Edit Breakdown"')
|
||||||
|
self.assertEqual(email.to, [moderator.email])
|
||||||
|
email_text = email.body
|
||||||
|
self.maxDiff = None
|
||||||
|
expected_text = expected_review_requested_text.format(**locals())
|
||||||
|
self.assertEqual(email_text, expected_text)
|
||||||
|
|
||||||
|
# Check that most important action and relevant URL are in the HTML version
|
||||||
|
email_html = email.alternatives[0][0]
|
||||||
|
self.assertIn(' requested review of ', email_html)
|
||||||
|
self.assertIn(f'https://extensions.local:8111/approval-queue/{extension.slug}/', email_html)
|
||||||
|
|
||||||
def test_approval_queue_activity(self):
|
def test_approval_queue_activity(self):
|
||||||
extension = create_approved_version(ratings=[]).extension
|
extension = create_approved_version(ratings=[]).extension
|
||||||
author = extension.authors.first()
|
author = extension.authors.first()
|
||||||
moderator = create_moderator()
|
moderator = create_moderator(confirmed_email_at=timezone.now())
|
||||||
some_user = UserFactory()
|
some_user = UserFactory()
|
||||||
notification_nrs = {}
|
notification_nrs = {}
|
||||||
for user in [author, moderator, some_user]:
|
for user in [author, moderator, some_user]:
|
||||||
@ -136,7 +201,88 @@ class TestTasks(TestCase):
|
|||||||
self.assertEqual(new_notification_nrs[moderator.pk], notification_nrs[moderator.pk] + 1)
|
self.assertEqual(new_notification_nrs[moderator.pk], notification_nrs[moderator.pk] + 1)
|
||||||
self.assertEqual(new_notification_nrs[some_user.pk], notification_nrs[some_user.pk] + 1)
|
self.assertEqual(new_notification_nrs[some_user.pk], notification_nrs[some_user.pk] + 1)
|
||||||
|
|
||||||
|
call_command('send_notification_emails')
|
||||||
|
|
||||||
|
# Test that one message has been sent (only moderator has email confirmed here).
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
|
||||||
|
email = mail.outbox[0]
|
||||||
|
self.assertEqual(email.subject, f'New comment on Add-on "{extension.name}"')
|
||||||
|
email_text = email.body
|
||||||
|
expected_text = expected_new_comment_text.format(**locals())
|
||||||
|
self.maxDiff = None
|
||||||
|
self.assertEqual(email_text, expected_text)
|
||||||
|
|
||||||
|
# Check that most important action and relevant URL are in the HTML version
|
||||||
|
email_html = email.alternatives[0][0]
|
||||||
|
self.assertIn(' commented on ', email_html)
|
||||||
|
self.assertIn(f'https://extensions.local:8111/approval-queue/{extension.slug}/', email_html)
|
||||||
|
|
||||||
def _leave_a_comment(self, user, extension, text):
|
def _leave_a_comment(self, user, extension, text):
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
url = reverse('reviewers:approval-comment', args=[extension.slug])
|
url = reverse('reviewers:approval-comment', args=[extension.slug])
|
||||||
self.client.post(url, {'type': ApprovalActivity.ActivityType.COMMENT, 'message': text})
|
self.client.post(url, {'type': ApprovalActivity.ActivityType.COMMENT, 'message': text})
|
||||||
|
|
||||||
|
# TODO: test for notifications about a reported rating
|
||||||
|
# TODO: test for notifications about extension approved by moderators
|
||||||
|
|
||||||
|
|
||||||
|
expected_abuse_report_text = """Add-on reported: "{extension.name}"
|
||||||
|
{some_user.full_name} reported Add-on "{extension.name}"
|
||||||
|
:
|
||||||
|
|
||||||
|
“test message”
|
||||||
|
|
||||||
|
https://extensions.local:8111{report_url}
|
||||||
|
Read all notifications at https://extensions.local:8111/notifications/
|
||||||
|
|
||||||
|
|
||||||
|
Unsubscribe by adjusting your preferences at https://extensions.local:8111/settings/profile/
|
||||||
|
|
||||||
|
https://extensions.local:8111/
|
||||||
|
"""
|
||||||
|
|
||||||
|
expected_new_comment_text = """New comment on Add-on "{extension.name}"
|
||||||
|
{some_user.full_name} commented on Add-on "{extension.name}"
|
||||||
|
:
|
||||||
|
|
||||||
|
“this is bad”
|
||||||
|
|
||||||
|
https://extensions.local:8111/approval-queue/{extension.slug}/
|
||||||
|
Read all notifications at https://extensions.local:8111/notifications/
|
||||||
|
|
||||||
|
|
||||||
|
Unsubscribe by adjusting your preferences at https://extensions.local:8111/settings/profile/
|
||||||
|
|
||||||
|
https://extensions.local:8111/
|
||||||
|
"""
|
||||||
|
|
||||||
|
expected_rated_text = """Add-on rated: "{extension.name}"
|
||||||
|
{some_user.full_name} rated extension Add-on "{extension.name}"
|
||||||
|
:
|
||||||
|
|
||||||
|
“rating text”
|
||||||
|
|
||||||
|
https://extensions.local:8111/add-ons/{extension.slug}/reviews/
|
||||||
|
Read all notifications at https://extensions.local:8111/notifications/
|
||||||
|
|
||||||
|
|
||||||
|
Unsubscribe by adjusting your preferences at https://extensions.local:8111/settings/profile/
|
||||||
|
|
||||||
|
https://extensions.local:8111/
|
||||||
|
"""
|
||||||
|
|
||||||
|
expected_review_requested_text = """Add-on review requested: "Edit Breakdown"
|
||||||
|
{some_user.full_name} requested review of Add-on "Edit Breakdown"
|
||||||
|
:
|
||||||
|
|
||||||
|
“Extension is ready for initial review”
|
||||||
|
|
||||||
|
https://extensions.local:8111/approval-queue/edit-breakdown/
|
||||||
|
Read all notifications at https://extensions.local:8111/notifications/
|
||||||
|
|
||||||
|
|
||||||
|
Unsubscribe by adjusting your preferences at https://extensions.local:8111/settings/profile/
|
||||||
|
|
||||||
|
https://extensions.local:8111/
|
||||||
|
"""
|
||||||
|
@ -20,7 +20,7 @@ and will most likely fail due to differences in configuration paths and so on.
|
|||||||
To avoid adding more dependencies to the project itself, `ansible` uses its own `virtualenv`.
|
To avoid adding more dependencies to the project itself, `ansible` uses its own `virtualenv`.
|
||||||
To set it up use the following commands:
|
To set it up use the following commands:
|
||||||
|
|
||||||
virtualenv .venv -p python
|
python3.10 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="box p-3">
|
<div class="box p-3">
|
||||||
{% include "common/components/field.html" with field=form.text focus=True %}
|
{% include "common/components/field.html" with field=form.text focus=True placeholder="Enter the text here..." %}
|
||||||
|
|
||||||
{% if form.non_field_errors %}
|
{% if form.non_field_errors %}
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
|
@ -70,24 +70,6 @@
|
|||||||
|
|
||||||
{% block extension_galleria %}
|
{% block extension_galleria %}
|
||||||
{% include "extensions/components/galleria.html" with extension=extension %}
|
{% include "extensions/components/galleria.html" with extension=extension %}
|
||||||
|
|
||||||
{% if pending_previews %}
|
|
||||||
<section class="my-3 box p-3">
|
|
||||||
<h3>Previews Pending Approval</h3>
|
|
||||||
<div class="row">
|
|
||||||
{% for preview in pending_previews %}
|
|
||||||
{% with thumbnail_1080p_url=preview.file.thumbnail_1080p_url %}
|
|
||||||
<div class="col-md-3">
|
|
||||||
<a href="{{ preview.file.source.url }}" class="d-block mb-2" title="{{ preview.caption }}" target="_blank">
|
|
||||||
<img class="img-fluid rounded" src="{{ thumbnail_1080p_url }}" alt="{{ preview.caption }}">
|
|
||||||
</a>
|
|
||||||
{% include "common/components/status.html" with object=preview.file class="d-block" %}
|
|
||||||
</div>
|
|
||||||
{% endwith %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock extension_galleria %}
|
{% endblock extension_galleria %}
|
||||||
|
|
||||||
|
|
||||||
@ -96,9 +78,9 @@
|
|||||||
<hr class="my-4">
|
<hr class="my-4">
|
||||||
<h2>Activity</h2>
|
<h2>Activity</h2>
|
||||||
|
|
||||||
{% if extension.review_activity.all %}
|
{% if review_activity %}
|
||||||
<ul class="activity-list">
|
<ul class="activity-list">
|
||||||
{% for activity in extension.review_activity.all %}
|
{% for activity in review_activity %}
|
||||||
<li id="activity-{{ activity.id }}">
|
<li id="activity-{{ activity.id }}">
|
||||||
|
|
||||||
{% if activity.type in status_change_types %}
|
{% if activity.type in status_change_types %}
|
||||||
@ -163,13 +145,11 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% with form=comment_form|add_form_classes %}
|
{% with form=comment_form|add_form_classes %}
|
||||||
|
|
||||||
{% include "common/components/field.html" with field=form.message %}
|
{% include "common/components/field.html" with field=form.message placeholder="Enter the text here..." %}
|
||||||
|
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<div class="btn-row ms-3 w-100 justify-content-end">
|
<div class="btn-row ms-3 w-100 justify-content-end">
|
||||||
{% if is_maintainer or request.user.is_moderator %}
|
{% include "common/components/field.html" with field=form.type %}
|
||||||
{% include "common/components/field.html" with field=form.type %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<button type="submit" id="activity-submit" class="btn btn-primary">
|
<button type="submit" id="activity-submit" class="btn btn-primary">
|
||||||
<span>{% trans "Comment" %}</span>
|
<span>{% trans "Comment" %}</span>
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
from collections import defaultdict
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
@ -6,17 +7,17 @@ from django.views.generic import DetailView, FormView
|
|||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
import django.forms
|
import django.forms
|
||||||
|
|
||||||
from files.models import File
|
|
||||||
from extensions.models import Extension
|
from extensions.models import Extension
|
||||||
from reviewers.forms import CommentForm
|
from reviewers.forms import CommentForm
|
||||||
from reviewers.models import ApprovalActivity
|
from reviewers.models import ApprovalActivity
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# the ordering of this list determines the order of rows in approval queue listing
|
||||||
STATUS_CHANGE_TYPES = [
|
STATUS_CHANGE_TYPES = [
|
||||||
ApprovalActivity.ActivityType.APPROVED,
|
|
||||||
ApprovalActivity.ActivityType.AWAITING_CHANGES,
|
|
||||||
ApprovalActivity.ActivityType.AWAITING_REVIEW,
|
ApprovalActivity.ActivityType.AWAITING_REVIEW,
|
||||||
|
ApprovalActivity.ActivityType.AWAITING_CHANGES,
|
||||||
|
ApprovalActivity.ActivityType.APPROVED,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -37,26 +38,35 @@ class ApprovalQueueView(ListView):
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
by_extension = {}
|
by_extension = {}
|
||||||
result = []
|
by_date_created = []
|
||||||
for item in qs:
|
for item in qs:
|
||||||
extension = item.extension
|
extension = item.extension
|
||||||
stats = by_extension.get(extension, None)
|
stats = by_extension.get(extension, None)
|
||||||
if not stats:
|
if not stats:
|
||||||
# this check guarantees that we add a record only once per extension,
|
# this check guarantees that we add a record only once per extension,
|
||||||
# and iterating over qs we get result also ordered by item.date_created
|
# and iterating over qs we get by_date_created also ordered by item.date_created
|
||||||
stats = {
|
stats = {
|
||||||
'count': 0,
|
'count': 0,
|
||||||
'extension': extension,
|
'extension': extension,
|
||||||
'last_activity': None,
|
'last_activity': None,
|
||||||
'last_type_display': None,
|
'last_type_display': None,
|
||||||
|
'type': None,
|
||||||
}
|
}
|
||||||
by_extension[extension] = stats
|
by_extension[extension] = stats
|
||||||
result.append(stats)
|
by_date_created.append(stats)
|
||||||
stats['count'] += 1
|
stats['count'] += 1
|
||||||
if not stats.get('last_activity', None):
|
if not stats.get('last_activity', None):
|
||||||
stats['last_activity'] = item
|
stats['last_activity'] = item
|
||||||
if not stats.get('last_type_display', None) and item.type in STATUS_CHANGE_TYPES:
|
if not stats.get('last_type_display', None) and item.type in STATUS_CHANGE_TYPES:
|
||||||
stats['last_type_display'] = item.get_type_display
|
stats['last_type_display'] = item.get_type_display()
|
||||||
|
stats['type'] = item.type
|
||||||
|
groupped_by_type = defaultdict(list)
|
||||||
|
for stats in by_date_created:
|
||||||
|
type = stats['type'] or ApprovalActivity.ActivityType.AWAITING_REVIEW
|
||||||
|
groupped_by_type[type].append(stats)
|
||||||
|
result = []
|
||||||
|
for type in STATUS_CHANGE_TYPES:
|
||||||
|
result.extend(groupped_by_type[type])
|
||||||
return result
|
return result
|
||||||
|
|
||||||
template_name = 'reviewers/extensions_review_list.html'
|
template_name = 'reviewers/extensions_review_list.html'
|
||||||
@ -67,15 +77,30 @@ class ExtensionsApprovalDetailView(DetailView):
|
|||||||
|
|
||||||
template_name = 'reviewers/extensions_review_detail.html'
|
template_name = 'reviewers/extensions_review_detail.html'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.model.objects.prefetch_related(
|
||||||
|
'authors',
|
||||||
|
'previews',
|
||||||
|
'versions',
|
||||||
|
).all()
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
ctx['pending_previews'] = self.object.preview_set.exclude(
|
ctx['review_activity'] = (
|
||||||
file__status=File.STATUSES.APPROVED
|
self.object.review_activity.select_related('user').order_by('date_created').all()
|
||||||
)
|
)
|
||||||
ctx['status_change_types'] = STATUS_CHANGE_TYPES
|
ctx['status_change_types'] = STATUS_CHANGE_TYPES
|
||||||
|
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
form = ctx['comment_form'] = CommentForm()
|
initial = {}
|
||||||
|
if 'report_compatibility_issue' in self.request.GET:
|
||||||
|
version = self.request.GET.get('version')
|
||||||
|
initial['message'] = (
|
||||||
|
f'Compatibility range for version {version} is outdated\n'
|
||||||
|
'Platform: ...\n'
|
||||||
|
'Version of Blender where the add-on is **no longer** working: ?????'
|
||||||
|
)
|
||||||
|
form = ctx['comment_form'] = CommentForm(initial=initial)
|
||||||
# anyone can comment
|
# anyone can comment
|
||||||
filtered_activity_types = {ApprovalActivity.ActivityType.COMMENT}
|
filtered_activity_types = {ApprovalActivity.ActivityType.COMMENT}
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
49
utils.py
49
utils.py
@ -1,3 +1,4 @@
|
|||||||
|
from html.parser import HTMLParser
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
import datetime
|
import datetime
|
||||||
@ -20,6 +21,7 @@ from django.core.exceptions import ValidationError
|
|||||||
from django.core.validators import validate_ipv46_address
|
from django.core.validators import validate_ipv46_address
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.http.response import HttpResponseRedirectBase
|
from django.http.response import HttpResponseRedirectBase
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.encoding import force_bytes, force_str
|
from django.utils.encoding import force_bytes, force_str
|
||||||
from django.utils.http import _urlparse
|
from django.utils.http import _urlparse
|
||||||
import django.utils.text
|
import django.utils.text
|
||||||
@ -189,3 +191,50 @@ def absolutify(url: str, request=None) -> str:
|
|||||||
proto = 'http' if settings.DEBUG else 'https'
|
proto = 'http' if settings.DEBUG else 'https'
|
||||||
domain = get_current_site(request).domain
|
domain = get_current_site(request).domain
|
||||||
return urljoin(f'{proto}://{domain}', url)
|
return urljoin(f'{proto}://{domain}', url)
|
||||||
|
|
||||||
|
|
||||||
|
def absolute_url(
|
||||||
|
view_name: str, args: Optional[tuple] = None, kwargs: Optional[dict] = None
|
||||||
|
) -> str:
|
||||||
|
"""Same as django.urls.reverse() but returned as an absolute URL."""
|
||||||
|
relative_url = reverse(view_name, args=args, kwargs=kwargs)
|
||||||
|
return absolutify(relative_url)
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLFilter(HTMLParser):
|
||||||
|
skip_text_of = ('a', 'style')
|
||||||
|
text = ''
|
||||||
|
skip_tag_text = False
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag in self.skip_text_of:
|
||||||
|
self.skip_tag_text = True
|
||||||
|
for name, value in attrs:
|
||||||
|
if name == 'href':
|
||||||
|
self.skip_tag_text = True
|
||||||
|
self.text += value
|
||||||
|
if tag in ('quote', 'q'):
|
||||||
|
self.text += '“'
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag in self.skip_text_of:
|
||||||
|
self.skip_tag_text = False
|
||||||
|
if tag in ('quote', 'q'):
|
||||||
|
self.text += '”\n\n'
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if self.skip_tag_text:
|
||||||
|
return
|
||||||
|
self.text += data
|
||||||
|
|
||||||
|
|
||||||
|
def html_to_text(data: str) -> str:
|
||||||
|
f = HTMLFilter()
|
||||||
|
f.feed(data)
|
||||||
|
lines = [_.lstrip(' \t') for _ in f.text.split('\n')]
|
||||||
|
skip_empty = 0
|
||||||
|
for line in lines:
|
||||||
|
if not re.match(r'^\s*$', line):
|
||||||
|
break
|
||||||
|
skip_empty += 1
|
||||||
|
return '\n'.join(lines[skip_empty:])
|
||||||
|
Loading…
Reference in New Issue
Block a user