Merge 'policies' and 'conditions of use' pages #117

Merged
Anna Sirota merged 3 commits from tos-flatpage into main 2024-05-07 19:20:16 +02:00
58 changed files with 808 additions and 351 deletions
Showing only changes of commit 191549ab84 - Show all commits

View File

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

View File

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

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

View File

@ -10,9 +10,13 @@ from django.http.request import HttpRequest
def extra_context(request: HttpRequest) -> Dict[str, str]:
"""Injects some configuration values into template context."""
user_is_moderator = False
if request.user.is_authenticated:
user_is_moderator = request.user.is_moderator
return {
'BLENDER_ID': {
'BASE_URL': settings.BLENDER_ID['BASE_URL'],
},
'canonical_url': request.build_absolute_uri(request.path),
'user_is_moderator': user_is_moderator,
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="1920"
height="1080"
viewBox="0 0 1920 1080"
sodipodi:docname="no-image.svg"
inkscape:export-filename="no-image_640x360.png"
inkscape:export-xdpi="32"
inkscape:export-ydpi="32"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#bbbbbb"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="false"
inkscape:deskcolor="#d1d1d1"
showguides="true"
inkscape:export-bgcolor="#3f3f3fff"
inkscape:zoom="0.28945313"
inkscape:cx="540.67475"
inkscape:cy="393.84614"
inkscape:window-width="1920"
inkscape:window-height="1056"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g1" />
<g
inkscape:groupmode="layer"
inkscape:label="Image"
id="g1">
<path
fill="none"
stroke="#ffffff"
stroke-width="0.4"
d="m 823.5878,596.10633 q 0,-24.97037 20.96278,-36.37659 -20.96278,-10.78966 -20.96278,-35.76002 v -28.05313 q 0,-23.1207 16.64692,-39.76761 16.64691,-16.64692 39.76761,-16.64692 h 11.71449 q 2.15795,-23.73725 21.57934,-40.07589 19.42139,-16.33863 46.85797,-16.03036 27.43657,0.30827 46.54967,16.03036 19.1132,15.72208 21.5794,40.07589 h 12.0227 q 23.1207,0 39.4594,16.64692 16.3386,16.64691 16.6469,39.76761 v 28.05313 q 0,24.97036 -20.6544,36.37658 20.6544,10.78966 20.6544,35.76003 v 44.39177 q 0,23.12071 -16.6469,39.76761 -16.647,16.64691 -39.4594,16.33864 h -44.08347 q -24.97036,0 -36.0683,-20.65451 -10.78967,20.65451 -36.37658,20.65451 h -43.77522 q -23.1207,0 -39.76761,-16.33864 Q 823.5878,663.92708 823.5878,640.4981 Z m 240.7637,44.39177 v -44.39177 q 0,-7.70691 -5.549,-7.70691 -4.0076,0 -9.2483,3.69932 -5.2407,3.69932 -13.2559,4.00759 -11.4061,0 -19.7296,-10.48139 -8.3235,-10.48138 -8.3235,-25.8952 0,-15.4138 8.3235,-25.27863 8.3235,-9.86484 19.7296,-10.48139 8.6317,0 15.7222,6.16553 2.7744,1.84966 6.1654,1.84966 6.1656,0 6.1656,-8.01519 v -28.05313 q 0,-10.17311 -7.0903,-17.26346 -7.0905,-7.09035 -16.9553,-7.09035 h -44.08347 q -4.93242,0 -7.09034,-2.77448 -2.15793,-2.77449 0.92482,-9.55656 6.16552,-7.39863 6.16552,-15.72209 0,-11.71449 -10.48139,-19.72967 -10.48139,-8.01517 -25.58691,-8.01517 -15.10553,0 -25.89519,8.01517 -10.78968,8.01518 -10.48139,19.72967 0,6.4738 4.31586,13.56416 5.2407,8.01517 3.39104,11.4062 -1.84966,3.39105 -7.7069,3.08277 h -43.77522 q -10.1731,0 -17.26346,7.09035 -7.09034,7.09035 -7.09034,17.26346 v 28.05313 q 0,8.01519 6.16551,8.01519 2.77449,0 8.94002,-4.00761 5.24069,-4.00758 12.94759,-4.00758 11.71449,0 19.72968,10.48139 8.01517,10.48139 8.32344,25.27863 0.30829,14.79727 -8.32344,25.8952 -8.63173,11.09794 -19.72968,10.48139 -8.32346,0 -15.72208,-6.16552 -3.08276,-1.84966 -6.16553,-1.84966 -6.16551,0 -6.16551,8.01518 v 44.39177 q 0,9.55655 7.09034,16.64691 7.09036,7.09034 17.26346,7.09034 h 43.77522 q 5.85724,0 7.39862,-3.08276 1.54138,-3.08276 -3.08276,-11.40622 -4.31586,-6.16551 -4.31586,-13.56414 0,-11.71449 10.48139,-19.72967 10.48139,-8.01519 25.89519,-8.01519 15.41381,0 25.58691,8.01519 10.17312,8.01518 10.48139,19.72967 0,8.32346 -6.16552,15.72207 -3.08275,6.78208 -0.92482,9.55656 2.15792,2.77449 7.09034,2.77449 h 44.08347 q 9.8648,0 16.9553,-7.09034 7.0903,-7.09036 7.0903,-16.64691 z M 855.64853,547.09042 v 25.58693 q 1.84965,-0.92483 6.16551,-0.92483 9.86485,0 16.95519,6.16552 2.15793,2.46621 4.93242,2.46621 4.62414,0 8.63173,-6.16552 4.00759,-6.16553 3.69932,-14.48899 -0.30827,-8.32344 -3.69932,-14.18069 -3.39103,-5.85724 -8.63173,-5.85724 -0.61656,0 -5.24069,2.4662 -8.94002,5.85724 -16.64692,5.85724 -2.4662,0 -6.16551,-0.92483 z m 203.15397,24.97037 q 1.8497,0 5.549,0.92483 v -25.58691 q -1.8498,0.92483 -6.1656,0.92483 -9.5566,0 -16.3386,-6.16553 -2.4661,-2.4662 -5.549,-2.4662 -4.6241,0 -8.3234,5.85724 -3.6993,5.85725 -3.6993,14.18069 0,8.32346 3.6993,14.48899 3.6993,6.16552 8.3234,6.16552 1.2332,0 5.8573,-2.46621 8.94,-5.85725 16.6469,-5.85725 z m -111.90424,92.17456 h 25.89519 q -4.00759,-12.63932 4.93242,-22.81242 2.46621,-2.15795 2.46621,-5.2407 0,-4.62415 -5.85726,-8.32346 -5.85724,-3.6993 -14.18069,-3.6993 -8.32346,0 -14.48897,3.6993 -6.16553,3.69931 -5.54898,8.32346 0,1.2331 2.15793,5.85724 7.70691,11.09795 4.62415,22.19588 z"
horiz-adv-x="885"
id="path1"
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:7.27776;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -1,99 +1,125 @@
// Create function btnBack
function btnBack() {
const btnBack = document.querySelectorAll('.js-btn-back');
(function() {
// Create function agreeWithTerms
function agreeWithTerms() {
const agreeWithTermsInput = document.querySelector('.js-agree-with-terms-input');
btnBack.forEach(function(item) {
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-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) {
if (!agreeWithTermsInput) {
// Stop function execution if agreeWithTermsInput is not present
return;
}
// Get data install URL
const btnInstallUrl = btnInstall.getAttribute('data-install-url');
agreeWithTermsInput.addEventListener('change', function(e) {
const agreeWithTermsBtnSubmit = document.querySelector('.js-agree-with-terms-btn-submit');
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');
// Check if checkbox is checked
if (e.target.checked == true) {
agreeWithTermsBtnSubmit.removeAttribute('disabled');
} else {
agreeWithTermsBtnSubmit.setAttribute('disabled', true);
}
});
}
init();
}
// Create function init
function init() {
btnBack();
commentForm();
copyInstallUrl();
}
// Create function btnBack
function btnBack() {
const btnBack = document.querySelectorAll('.js-btn-back');
document.addEventListener('DOMContentLoaded', function() {
init();
});
btnBack.forEach(function(item) {
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();
});
}())

View File

@ -67,6 +67,10 @@
.activity-icon
top: 2.2rem
code,
pre
white-space: normal
.activity-status-change
color: var(--color-text-tertiary)

View File

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

View File

@ -80,13 +80,6 @@
.ext-detail-tagline
+margin(2, bottom)
.ext-detail-description
+padding(4)
+style-rich-text
pre
+margin(3, bottom)
.ext-detail-info
dd
color: var(--color-text)
@ -270,8 +263,6 @@
+margin(3, left)
details
padding: 0
&[open]
.show-on-collapse
display: none
@ -311,7 +302,7 @@
.label
line-height: calc(var(--spacer) * 2)
/* Nabdrawer. */
/* Navdrawer. */
.nav-link
&[class*=" i-"]::before
+margin(2, right)
@ -328,8 +319,6 @@
a
color: var(--color-text)
// TODO: @web-assets check arbitrary style table link display specificity
display: inline !important
+padding(1, y)
padding-inline: 0 !important
@ -378,3 +367,23 @@
@extend .dropdown-divider
+margin(0, top)
.dropdown-item
&a
+padding(3, x)
.extension-icon
width: var(--fs-h1)
.icon-preview, .featured-image-preview
height: 9rem
background-size: contain
background-repeat: no-repeat
background-color: var(--color-bg)
border-radius: var(--border-radius)
.icon-preview
width: 9rem
.featured-image-preview
width: 16rem

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,10 @@
table,
.table
a
text-decoration: underline
th
color: var(--color-text-secondary)
thead
white-space: normal

View File

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

View File

@ -18,6 +18,7 @@ $container-width: map-get($container-max-widths, 'xl')
@import '_alert.sass'
@import '_badge.sass'
@import '_box.sass'
@import '_button.sass'
@import '_cards.sass'
@import '_code.sass'
@import '_comments.sass'
@ -28,6 +29,7 @@ $container-width: map-get($container-max-widths, 'xl')
@import '_hero.sass'
@import '_list.sass'
@import '_navigation_global.sass'
@import '_notifications.sass'
@import '_table.sass'
@import 'ratings/static/ratings/styles/_review.sass'
@import 'ratings/static/ratings/styles/_stars.sass'

View File

@ -126,7 +126,7 @@
</li>
{% block nav-upload %}
<li class="me-2">
<li>
<a href="{% url 'extensions:submit' %}" class="btn btn-primary">
<i class="i-upload"></i>
<span>Upload Extension</span>
@ -135,11 +135,13 @@
{% endblock nav-upload %}
{% if user.is_authenticated %}
<a href="{% url 'notifications:notifications' %}">
<i class="i-bell {% if user|unread_notification_count %}text-primary{% endif %}"></i>
</a>
<li>
<a class="btn btn-link px-2" href="{% url 'notifications:notifications' %}">
<i class="i-bell {% if user|unread_notification_count %}text-accent{% endif %}"></i>
</a>
</li>
<li class="nav-item dropdown">
<button id="navbarDropdown" aria-expanded="false" aria-haspopup="true" data-toggle-menu-id="nav-account-dropdown" role="button" class="nav-link dropdown-toggle js-dropdown-toggle">
<button id="navbarDropdown" aria-expanded="false" aria-haspopup="true" data-toggle-menu-id="nav-account-dropdown" role="button" class="nav-link dropdown-toggle js-dropdown-toggle pe-3 px-2">
<i class="i-user"></i>
<i class="i-chevron-down"></i>
</button>
@ -151,7 +153,7 @@
</a>
</li>
{% endif %}
{% if user.is_moderator %}
{% if user_is_moderator %}
<li>
<a href="{% url 'abuse:report-list' %}" class="dropdown-item">
<i class="i-shield"></i> {% trans "Abuse Reports" %}
@ -202,7 +204,10 @@
</ul>
</li>
{% elif page_id != 'login' and page_id != 'register' %}
{% include "common/components/nav_item.html" with name="oauth:login" title="Sign in" %}
<a href="{% url 'oauth:login' %}" class="btn btn-link">
<i class="i-log-in"></i>
<span>{% trans "Sign in" %}</span>
</a>
{% endif %}
<li>

View File

@ -2,7 +2,10 @@
{% spaceless %}
{% 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 %}
{% autoescape off %}
{% firstof label field.label as label %}
{% firstof help_text field.help_text as help_text %}
{% endautoescape %}
{% comment %} Checkboxes {% endcomment %}
{% if type == 'checkbox' %}
@ -37,8 +40,8 @@
{% endif %}
{% endif %}
{% if field.help_text %}
<div class="form-text">{{ field.help_text|safe }}</div>
{% if help_text and not field.is_hidden %}
<div class="form-text">{{ help_text|safe }}</div>
{% endif %}
{% if field.errors %}

View File

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

View File

@ -60,7 +60,9 @@ EXTENSION_TYPE_PLURAL = {
EXTENSION_SLUGS_PATH = '|'.join(EXTENSION_TYPE_SLUGS.values())
EXTENSION_SLUG_TYPES = {v: k for k, v in EXTENSION_TYPE_SLUGS_SINGULAR.items()}
ALLOWED_EXTENSION_MIMETYPES = ('application/zip', )
ALLOWED_EXTENSION_MIMETYPES = ('application/zip',)
ALLOWED_FEATURED_IMAGE_MIMETYPES = ('image/jpg', 'image/jpeg', 'image/png', 'image/webp')
ALLOWED_ICON_MIMETYPES = ('image/png',)
# FIXME: this controls the initial widget rendered server-side, and server-side validation
# but not the additional JS-appended preview file inputs.
# If this list changes, the "accept" attribute also has to be updated in appendImageUploadForm.

View File

@ -62,6 +62,7 @@ class ExtensionAdmin(admin.ModelAdmin):
'website',
)
raw_id_fields = ('team',)
autocomplete_fields = ('icon', 'featured_image')
fieldsets = (
(
@ -79,6 +80,7 @@ class ExtensionAdmin(admin.ModelAdmin):
'name',
'slug',
'description',
('icon', 'featured_image'),
'status',
),
},
@ -202,8 +204,13 @@ class TagAdmin(admin.ModelAdmin):
return ()
class VersionPermissionAdmin(admin.ModelAdmin):
list_display = ('name', 'slug')
admin.site.register(models.Extension, ExtensionAdmin)
admin.site.register(models.Version, VersionAdmin)
admin.site.register(models.Maintainer, MaintainerAdmin)
admin.site.register(models.License, LicenseAdmin)
admin.site.register(models.Tag, TagAdmin)
admin.site.register(models.VersionPermission, VersionPermissionAdmin)

View File

@ -2,12 +2,15 @@ import logging
from django import forms
from django.utils.translation import gettext_lazy as _
import django.core.exceptions
from files.validators import FileMIMETypeValidator
from constants.base import ALLOWED_PREVIEW_MIMETYPES
from constants.base import (
ALLOWED_FEATURED_IMAGE_MIMETYPES,
ALLOWED_ICON_MIMETYPES,
ALLOWED_PREVIEW_MIMETYPES,
)
import extensions.models
import files.forms
import files.models
import reviewers.models
@ -38,61 +41,22 @@ EditPreviewFormSet = forms.inlineformset_factory(
)
class AddPreviewFileForm(forms.ModelForm):
msg_unexpected_file_type = _('Choose a JPEG, PNG or WebP image, or an MP4 video')
class AddPreviewFileForm(files.forms.BaseMediaFileForm):
allowed_mimetypes = ALLOWED_PREVIEW_MIMETYPES
error_messages = {'invalid_mimetype': _('Choose a JPEG, PNG or WebP image, or an MP4 video')}
class Meta:
model = files.models.File
fields = ('caption', 'source', 'original_hash', 'hash')
widgets = {'original_hash': forms.HiddenInput(), 'hash': forms.HiddenInput()}
class Meta(files.forms.BaseMediaFileForm.Meta):
fields = ('caption',) + files.forms.BaseMediaFileForm.Meta.fields
source = forms.FileField(
allow_empty_file=False,
required=True,
validators=[
FileMIMETypeValidator(
allowed_mimetypes=ALLOWED_PREVIEW_MIMETYPES,
message=msg_unexpected_file_type,
),
],
widget=forms.ClearableFileInput(
attrs={'accept': ','.join(ALLOWED_PREVIEW_MIMETYPES)},
),
)
caption = forms.CharField(max_length=255, required=False)
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
self.extension = kwargs.pop('extension')
self.base_fields['source'].required = True
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
super().__init__(*args, **kwargs)
def clean_original_hash(self, *args, **kwargs):
"""Calculate original hash of the uploaded file."""
if 'source' not in self.cleaned_data:
return
source = self.cleaned_data['source']
return files.models.File.generate_hash(source)
def clean_hash(self, *args, **kwargs):
return self.cleaned_data['original_hash']
def add_error(self, field, error):
"""Add hidden `original_hash`/`hash` errors to the visible `source` field instead."""
if isinstance(error, django.core.exceptions.ValidationError):
if getattr(error, 'error_dict', None):
hash_error = error.error_dict.pop('hash', None)
if hash_error:
error.error_dict['source'] = hash_error
# `original_hash` is treated identically to `hash`, so its errors can be discarded
error.error_dict.pop('original_hash', None)
super().add_error(field, error)
def save(self, *args, **kwargs):
"""Save Preview from the cleaned form data."""
# Fill in missing fields from request and the source file
self.instance.user = self.request.user
instance = super().save(*args, **kwargs)
# Create extension preview and save caption to it
@ -169,24 +133,46 @@ class ExtensionUpdateForm(forms.ModelForm):
extension=self.instance,
request=self.request,
)
featured_image_form = FeaturedImageForm(
self.request.POST,
self.request.FILES,
extension=self.instance,
request=self.request,
)
icon_form = IconForm(
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)
featured_image_form = FeaturedImageForm(extension=self.instance, request=self.request)
icon_form = IconForm(extension=self.instance, request=self.request)
self.edit_preview_formset = edit_preview_formset
self.add_preview_formset = add_preview_formset
self.featured_image_form = featured_image_form
self.icon_form = icon_form
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:
# Require at least one preview image when requesting a review
if not self.instance.previews.exists():
self.add_preview_formset.min_num = 1
self.add_preview_formset.validate_min = True
# Make feature image and icon required too
self.featured_image_form.fields['source'].required = True
self.icon_form.fields['source'].required = True
is_valid_flags = [
self.edit_preview_formset.is_valid(),
self.add_preview_formset.is_valid(),
self.featured_image_form.is_valid(),
self.icon_form.is_valid(),
super().is_valid(*args, **kwargs),
]
return all(is_valid_flags)
@ -212,6 +198,14 @@ class ExtensionUpdateForm(forms.ModelForm):
"""Save the nested form(set)s, then the main form."""
self.edit_preview_formset.save()
self.add_preview_formset.save()
# Featured image and icon are only required when ready for review,
# and can be empty or unchanged.
if self.featured_image_form.has_changed():
self.featured_image_form.save()
if self.icon_form.has_changed():
self.icon_form.save()
if getattr(self.instance, 'converted_to_draft', False):
reviewers.models.ApprovalActivity(
user=self.request.user,
@ -259,3 +253,17 @@ class VersionDeleteForm(forms.ModelForm):
class Meta:
model = extensions.models.Version
fields = []
class FeaturedImageForm(files.forms.BaseMediaFileForm):
prefix = 'featured-image'
to_field = 'featured_image'
allowed_mimetypes = ALLOWED_FEATURED_IMAGE_MIMETYPES
error_messages = {'invalid_mimetype': _('Choose a JPEG, PNG or WebP image')}
class IconForm(files.forms.BaseMediaFileForm):
prefix = 'icon'
to_field = 'icon'
allowed_mimetypes = ALLOWED_ICON_MIMETYPES
error_messages = {'invalid_mimetype': _('Choose a PNG image')}

View File

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

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.11 on 2024-05-06 17:20
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('files', '0008_alter_file_thumbnail'),
('extensions', '0028_alter_license_slug_alter_versionpermission_slug'),
]
operations = [
migrations.AddField(
model_name='extension',
name='featured_image',
field=models.OneToOneField(help_text='Shown by social networks when this extension is shared (used as `og:image` metadata field).Should have resolution of at least 1920 x 1080 and aspect ratio of 16:9.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='featured_image_of', to='files.file'),
),
migrations.AddField(
model_name='extension',
name='icon',
field=models.OneToOneField(help_text='A 256 x 256 icon representing this extension.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='icon_of', to='files.file'),
),
]

View File

@ -19,8 +19,6 @@ from constants.base import (
EXTENSION_TYPE_SLUGS,
FILE_STATUS_CHOICES,
)
from constants.licenses import ALL_LICENSES
from constants.version_permissions import ALL_VERSION_PERMISSIONS
import common.help_texts
import extensions.fields
@ -90,22 +88,13 @@ class License(CreatedModifiedMixin, models.Model):
blank=False,
null=False,
help_text='Should be taken from https://spdx.org/licenses/',
unique=True,
)
url = models.URLField(blank=False, null=False)
def __str__(self) -> str:
return f'{self.name}'
@classmethod
def generate(cls):
"""Generate License records from constants."""
licenses = [cls(id=li.id, name=li.name, slug=li.slug, url=li.url) for li in ALL_LICENSES]
cls.objects.bulk_create(licenses)
@classmethod
def get_by_name(cls, name: str):
return cls.objects.filter(name__startswith=name).first()
@classmethod
def get_by_slug(cls, slug: str):
return cls.objects.filter(slug__startswith=slug).first()
@ -155,13 +144,29 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
help_text='Whether the extension should be listed. It is kept in sync via signals.',
default=False,
)
previews = FilterableManyToManyField(
featured_image = models.OneToOneField(
'files.File',
through='Preview',
related_name='extensions',
# TODO: filter only images and videos.
# q_filter=Q(type=FILE_TYPE_CHOICES.IMAGE),
related_name='featured_image_of',
null=True,
blank=False,
on_delete=models.SET_NULL,
help_text=(
"Shown by social networks when this extension is shared"
" (used as `og:image` metadata field)."
"Should have resolution of at least 1920 x 1080 and aspect ratio of 16:9."
),
)
icon = models.OneToOneField(
'files.File',
related_name='icon_of',
null=True,
blank=False,
on_delete=models.SET_NULL,
help_text="A 256 x 256 icon representing this extension.",
)
previews = FilterableManyToManyField('files.File', through='Preview', related_name='extensions')
status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.INCOMPLETE)
support = models.URLField(
help_text='URL for reporting issues or contact details for support.', null=True, blank=True
@ -277,12 +282,16 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
def get_review_url(self):
return reverse('reviewers:approval-detail', args=[self.slug])
def get_previews(self):
"""Get preview files, sorted by Preview.position.
def get_previews(self) -> List['Preview']:
"""Get all preview files, sorted by Preview.position.
Avoid triggering additional querysets, rely on prefetch_related in the view.
"""
return [p.file for p in self.preview_set.all() if p.file.is_listed]
return [p for p in self.preview_set.all()]
def get_previews_listed(self) -> List['Preview']:
"""Get publicly listed preview files, sorted by Preview.position."""
return [p for p in self.get_previews() if p.file.is_listed]
@property
def valid_file_statuses(self) -> List[int]:
@ -386,28 +395,16 @@ class VersionPermission(CreatedModifiedMixin, models.Model):
blank=False,
null=False,
help_text='Permissions add-ons are expected to need.',
unique=True,
)
help = models.CharField(max_length=128, null=False, blank=False, unique=True)
def __str__(self) -> str:
return f'{self.name}'
@classmethod
def generate(cls):
"""Generate Permission records from constants."""
permissions = [
cls(id=li.id, name=li.name, slug=li.slug, help=li.help)
for li in ALL_VERSION_PERMISSIONS
]
cls.objects.bulk_create(permissions)
@classmethod
def get_by_name(cls, name: str):
return cls.objects.filter(name__startswith=name).first()
@classmethod
def get_by_slug(cls, slug: str):
return cls.objects.filter(slug__startswith=slug).first()
return cls.objects.get(slug=slug)
class Tag(CreatedModifiedMixin, models.Model):
@ -537,12 +534,7 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
return
for permission_name in _permissions:
permission = VersionPermission.get_by_name(permission_name)
# Just ignore versions that are incompatible.
if not permission:
continue
permission = VersionPermission.get_by_slug(permission_name)
self.permissions.add(permission)
def set_initial_licenses(self, _licenses):

View File

@ -29,14 +29,27 @@ def _log_deletion(
instance.record_deletion()
def _delete_file(f, sender, instance, rel):
source = f.source.name
args = {'f_id': f.pk, 'h': f.hash, 'pk': instance.pk, 'sender': sender, 's': source, 'r': rel}
logger.info('Deleting %(r)s file pk=%(f_id)s s=%(s)s hash=%(h)s of %(sender)s pk=%(pk)s', args)
f.delete()
@receiver(post_delete, sender=extensions.models.Preview)
@receiver(post_delete, sender=extensions.models.Version)
def _delete_file(sender: object, instance: object, **kwargs: object) -> None:
def _delete_preview_or_version_file(sender: object, instance: object, **kwargs: object) -> None:
f = instance.file
args = {'f_id': f.pk, 'h': f.hash, 'pk': instance.pk, 'sender': sender, 's': f.source.name}
logger.info('Deleting file pk=%(f_id)s s=%(s)s hash=%(h)s linked to %(sender)s pk=%(pk)s', args)
f.delete()
# TODO: this doesn't mean that the file was deleted from disk
_delete_file(f, sender, instance, rel=sender)
@receiver(post_delete, sender=extensions.models.Extension)
def _delete_featured_image_and_icon(sender: object, instance: object, **kwargs: object) -> None:
for rel in ('featured_image', 'icon'):
f = getattr(instance, rel)
if not f:
continue
_delete_file(f, sender, instance, rel)
@receiver(pre_save, sender=extensions.models.Extension)

View File

@ -18,7 +18,7 @@
</div>
{% endblock hero_breadcrumbs %}
<h1>{{ extension.name }}</h1>
<h1>{% include "extensions/components/icon.html" %} {{ extension.name }}</h1>
<div class="hero-subtitle">
{% if latest.tagline %}

View File

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

View File

@ -1,5 +1,7 @@
{% load common filters %}
{% with latest=extension.latest_version thumbnail_360p_url=extension.get_previews.0.thumbnail_360p_url %}
{% load common filters static %}
{% static "common/images/no-image_640x360.png" as featured_image_missing %}
{% with latest=extension.latest_version %}
{% firstof extension.featured_image.thumbnail_360p_url featured_image_missing as thumbnail_360p_url %}
<div class="cards-item">
<div class="cards-item-content">
<a href="{{ extension.get_absolute_url }}">

View File

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

View File

@ -1,30 +1,30 @@
{% with previews=extension.get_previews %}
<section class="galleria-container" id="galleria-container">
{% if previews %}
<div class="galleria-items{% if previews.count > 5 %} is-many{% endif %}{% if previews.count == 1 %} is-single{% endif %}" id="galleria-items">
{% for preview in previews %}
{% with thumbnail_1080p_url=preview.thumbnail_1080p_url %}
<a
class="galleria-item js-galleria-item-preview galleria-item-type-{{ preview.content_type|slugify|slice:5 }}{% if forloop.first %} is-active{% endif %}"
href="{{ thumbnail_1080p_url }}"
{% if 'video' in preview.content_type %}data-galleria-video-url="{{ preview.source.url }}"{% endif %}
data-galleria-content-type="{{ preview.content_type }}"
data-galleria-index="{{ forloop.counter }}">
{% with preview_count=previews|length %}
<section class="galleria-container" id="galleria-container">
{% if previews %}
<div class="galleria-items{% if preview_count > 5 %} is-many{% endif %}{% if preview_count == 1 %} is-single{% endif %}" id="galleria-items">
{% for preview in previews %}
{% with thumbnail_1080p_url=preview.file.thumbnail_1080p_url file=preview.file %}
<a
class="galleria-item js-galleria-item-preview galleria-item-type-{{ file.content_type|slugify|slice:5 }}{% if forloop.first %} is-active{% endif %}"
href="{{ thumbnail_1080p_url }}"
{% if 'video' in file.content_type %}data-galleria-video-url="{{ file.source.url }}"{% endif %}
data-galleria-content-type="{{ file.content_type }}"
data-galleria-index="{{ forloop.counter }}">
<img src="{{ thumbnail_1080p_url }}" alt="{{ preview.preview.caption }}">
</a>
{% endwith %}
{% endfor %}
</div>
{% else %}
<div class="galleria-item is-empty">
<div>
No preview yet.
{% if is_maintainer %}
<a class="btn btn-primary px-5" href="{{ extension.get_manage_url }}#previews">Add a Preview</a>
{% endif %}
</div>
</div>
{% endif %}
</section>
<img src="{{ thumbnail_1080p_url }}" alt="{{ preview.caption }}">
</a>
{% endwith %}
{% endfor %}
</div>
{% else %}
<div class="galleria-item is-empty">
<div>
No preview yet.
{% if is_maintainer %}
<a class="btn btn-primary px-5" href="{{ extension.get_manage_url }}#previews">Add a Preview</a>
{% endif %}
</div>
</div>
{% endif %}
</section>
{% endwith %}

View File

@ -0,0 +1,3 @@
{% load static %}
<img class="extension-icon mb-2 rounded" src="{% if extension.icon.source %}{{ extension.icon.source.url }}{% else %}{% static 'common/images/no-icon.png' %}{% endif %}">

View File

@ -14,14 +14,14 @@
<div class="col-md-8 pt-2">
{# Gallery #}
{% block extension_galleria %}
{% include "extensions/components/galleria.html" with extension=extension %}
{% include "extensions/components/galleria.html" with extension=extension previews=extension.get_previews_listed %}
{% endblock extension_galleria %}
{# Description #}
{% block extension_description %}
{% if extension.description %}
<section id="about" class="mt-3">
<div class="box ext-detail-description">
<div class="box style-rich-text">
{{ extension.description|markdown }}
</div>
</section>
@ -42,7 +42,7 @@
</span>
</summary>
<div class="px-4">
<div>
{{ latest.release_notes|markdown }}
</div>
</details>
@ -62,7 +62,7 @@
{# Permissions #}
{% block extension_permissions %}
{% if extension.type_slug == 'add-on' %}
{% if extension.type_slug == 'add-ons' %}
<hr class="my-4">
<section id="permissions" class="ext-detail-permissions">
<h2 class="mb-3">{% trans "Permissions" %}</h2>

View File

@ -45,7 +45,7 @@
{# TODO: fix handling of tags #}
<div class="row">
<div class="col">
{% include "common/components/field.html" %}
{% include "common/components/field.html" with placeholder="Enter the text here..." %}
</div>
</div>
{% endif %}
@ -55,7 +55,26 @@
<section class="mt-4">
<h2>{% trans 'Initial Version' %}</h2>
<div class="card p-3">
{% include "common/components/field.html" with field=form.release_notes %}
{% include "common/components/field.html" with field=form.release_notes placeholder="Add the release notes..." %}
</div>
</section>
<section class="mt-4">
<h2>{% trans 'Featured image and icon' %}</h2>
<div class="previews-upload">
<div class="row">
<div class="col">
{% trans "Icon" as icon_label %}
{% trans "A 256 x 256 icon representing this extension." as icon_help_text %}
{% include "extensions/manage/components/set_image.html" with image_form=icon_form label=icon_label help_text=icon_help_text %}
</div>
<div class="col">
{% trans "Featured image" as featured_image_label %}
{% trans "Should have resolution of at least 1920 x 1080 and aspect ratio of 16:9." as featured_image_help_text %}
{% include "extensions/manage/components/set_image.html" with image_form=featured_image_form label=featured_image_label help_text=featured_image_help_text %}
</div>
</div>
</div>
</section>

View File

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

View File

@ -0,0 +1,33 @@
{% load common %}
{# Handles displaying and editing the featured image #}
{% with inlineform=image_form|add_form_classes %}
{% with current_file=inlineform.instance.source %}
<div class="{{ image_form.prefix }}-preview"
style="background-image: url('{% if current_file %}{{ current_file.url }}{% endif %}');"
title="{{ label }} of the extension">
</div>
{% for field in inlineform %}
{% if field.name == "source" %}
<small>
{% include "common/components/field.html" with label=label help_text=help_text %}
</small>
{% else %}
{% include "common/components/field.html" %}
{% endif %}
{% endfor %}
{{ inlineform.non_form_errors }}
<script>
(function() {
const input = document.getElementById('id_{{ image_form.prefix }}-source');
const previewEl = document.getElementsByClassName('{{ image_form.prefix }}-preview')[0];
input.addEventListener('change', function() {
const curFiles = input.files;
if (curFiles.length > 0) {
const dataUrl = URL.createObjectURL(curFiles[0]);
previewEl.style['background-image'] = `url("${dataUrl}")`;
}
});
})();
</script>
{% endwith %}
{% endwith %}

View File

@ -30,14 +30,33 @@
<section class="card p-3">
<div>
{% include "common/components/field.html" with field=form.description label="Description" classes="one two three" placeholder="Describe this extension" %}
{% include "common/components/field.html" with field=form.description label="Description" placeholder="Describe the extension..." %}
</div>
<div>
{% include "common/components/field.html" with field=form.support %}
{% include "common/components/field.html" with field=form.support placeholder="https://example.com" %}
</div>
</section>
<section class="mt-4">
<h2>{% trans 'Featured image and icon' %}</h2>
<div class="previews-upload">
<div class="row">
<div class="col">
{% trans "Icon" as icon_label %}
{% trans "A 256 x 256 icon representing this extension." as icon_help_text %}
{% include "extensions/manage/components/set_image.html" with image_form=icon_form label=icon_label help_text=icon_help_text %}
</div>
<div class="col">
{% trans "Featured image" as featured_image_label %}
{% trans "Should have resolution of at least 1920 x 1080 and aspect ratio of 16:9." as featured_image_help_text %}
{% include "extensions/manage/components/set_image.html" with image_form=featured_image_form label=featured_image_label help_text=featured_image_help_text %}
</div>
</div>
</div>
</section>
<section class="mt-4">
<h2>{% trans 'Previews' %}</h2>
<div class="previews-upload">

View File

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

View File

@ -65,11 +65,11 @@
</div>
<div class="row">
<div class="col mx-4 mt-4">
{% include "common/components/field.html" with field=form.agreed_with_terms %}
{% include "common/components/field.html" with field=form.agreed_with_terms classes="js-agree-with-terms-input" %}
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-block btn-primary px-5 py-2">
<button type="submit" class="btn btn-block btn-primary js-agree-with-terms-btn-submit px-5 py-2" disabled>
<i class="i-upload"></i>
<span>
{% if extension %}
@ -79,6 +79,27 @@
{% endif %}
</span>
</button>
<noscript>
<div>
<hr class="mt-5">
<p>
{% trans 'You see this, because of JavaScript is disabled in your browser.' %}
</p>
<button type="submit" class="btn btn-block btn-primary mb-2 px-5 py-2">
<i class="i-upload"></i>
<span>
{% if extension %}
{% trans 'Upload New Version' %}
{% else %}
{% trans 'Upload Extension' %}
{% endif %}
</span>
</button>
<p>
{% trans 'By clicking the submit button, you agree to Blender Extensions conditions of use and policies.' %}
</p>
</div>
</noscript>
</div>
{% if form.non_field_errors %}

View File

@ -42,7 +42,7 @@
<div class="row">
<div class="col-md-7">
<div class="px-4">
<div>
{% if version.release_notes %}
<h3 class="mb-3">Changelog</h3>
{{ version.release_notes|markdown }}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -108,6 +108,8 @@ class DeleteTest(TestCase):
'description',
'download_count',
'extension_id',
'featured_image',
'icon',
'is_listed',
'name',
'pk',

View File

@ -309,6 +309,9 @@ class SubmitFinaliseTest(TestCase):
'extension_form': [{'description': ['This field is required.']}, None],
'add_preview_formset': [[], ['Please add at least one preview.']],
'edit_preview_formset': [[], []],
'featured_image_form': [{'source': ['This field is required.']}, None],
'icon_form': [{'source': ['This field is required.']}, None],
'image_form': [{'source': ['This field is required.']}, None],
},
)
@ -349,12 +352,18 @@ class SubmitFinaliseTest(TestCase):
}
file_name1 = 'test_preview_image_0001.png'
file_name2 = 'test_preview_image_0002.png'
file_name3 = 'test_icon_0001.png'
file_name4 = 'test_featured_image_0001.png'
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1, open(
TEST_FILES_DIR / file_name2, 'rb'
) as fp2:
) as fp2, open(TEST_FILES_DIR / file_name3, 'rb') as fp3, open(
TEST_FILES_DIR / file_name4, 'rb'
) as fp4:
files = {
'form-0-source': fp1,
'form-1-source': fp2,
'icon-source': fp3,
'featured-image-source': fp4,
}
response = self.client.post(self.file.get_submit_url(), {**data, **files})
@ -363,7 +372,7 @@ class SubmitFinaliseTest(TestCase):
self.assertEqual(File.objects.filter(type=File.TYPES.BPY).count(), 1)
self.assertEqual(Extension.objects.count(), 1)
self.assertEqual(Version.objects.count(), 1)
self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 2)
self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 4)
# Check an add-on was created with all given fields
extension = Extension.objects.first()
self.assertEqual(extension.get_type_display(), 'Add-on')

View File

@ -212,10 +212,7 @@ class UpdateTest(TestCase):
[
{},
{'__all__': ['Please correct the duplicate values below.']},
[
'Please select another file instead of the duplicate',
'Please select another file instead of the duplicate',
],
['Please select another file instead of the duplicate'],
],
)
@ -243,7 +240,7 @@ class UpdateTest(TestCase):
self.maxDiff = None
self.assertEqual(
response.context['add_preview_formset'].forms[0].errors,
{'source': ['File with this Hash already exists.']},
{'source': ['File with this Original hash already exists.']},
)
def test_post_upload_validation_error_unexpected_preview_format_gif(self):

View File

@ -16,8 +16,8 @@ from .mixins import (
from extensions.forms import (
ExtensionDeleteForm,
ExtensionUpdateForm,
VersionForm,
VersionDeleteForm,
VersionForm,
)
from extensions.models import Extension, Version
from files.forms import FileForm
@ -39,9 +39,12 @@ class ExtensionDetailView(ExtensionQuerysetMixin, DetailView):
"""
return self.get_extension_queryset().prefetch_related(
'authors',
'ratings',
'ratings__user',
'versions',
'versions__file',
'versions__file__validation',
'versions__permissions',
)
def get_object(self, queryset=None):
@ -126,6 +129,8 @@ class UpdateExtensionView(
context = super().get_context_data(*args, **kwargs)
context['edit_preview_formset'] = context['form'].edit_preview_formset
context['add_preview_formset'] = context['form'].add_preview_formset
context['featured_image_form'] = context['form'].featured_image_form
context['icon_form'] = context['form'].icon_form
return context
@transaction.atomic
@ -348,6 +353,8 @@ class DraftExtensionView(
context['extension_form'] = extension_form
context['edit_preview_formset'] = extension_form.edit_preview_formset
context['add_preview_formset'] = extension_form.add_preview_formset
context['featured_image_form'] = extension_form.featured_image_form
context['icon_form'] = extension_form.icon_form
return context
def post(self, request, *args, **kwargs):

View File

@ -6,6 +6,7 @@ import tempfile
from django import forms
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
import django.core.exceptions
from .validators import (
ExtensionIDManifestValidator,
@ -52,9 +53,7 @@ class FileForm(forms.ModelForm):
message=error_messages['invalid_zip_archive'],
),
],
widget=forms.ClearableFileInput(
attrs={'accept': ','.join(ALLOWED_EXTENSION_MIMETYPES)}
),
widget=forms.ClearableFileInput(attrs={'accept': ','.join(ALLOWED_EXTENSION_MIMETYPES)}),
help_text=msg_only_zip_files,
)
agreed_with_terms = forms.BooleanField(
@ -154,3 +153,68 @@ class FileForm(forms.ModelForm):
self.cleaned_data['type'] = EXTENSION_SLUG_TYPES[manifest['type']]
return self.cleaned_data
class BaseMediaFileForm(forms.ModelForm):
class Meta:
model = files.models.File
fields = ('source', 'original_hash')
widgets = {'original_hash': forms.HiddenInput()}
source = forms.ImageField(widget=forms.FileInput)
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request')
self.extension = kwargs.pop('extension')
# Set current File so that the form actually displays it:
if hasattr(self, 'to_field'):
kwargs['instance'] = getattr(self.extension, getattr(self, 'to_field'))
source_field = self.base_fields['source']
# File might not be required depending on the context (saving draft vs sending to review)
source_field.required = False
accept = ','.join(self.allowed_mimetypes)
source_field.widget.attrs.update({'accept': accept})
# Replace ImageField's file extension validator with one that also check file's content
source_field.validators = [
FileMIMETypeValidator(
allowed_mimetypes=self.allowed_mimetypes,
message=self.error_messages['invalid_mimetype'],
)
]
super().__init__(*args, **kwargs)
self.instance.user = self.request.user
def clean_original_hash(self, *args, **kwargs):
"""Calculate original hash of the uploaded file."""
source = self.cleaned_data.get('source')
if not source:
return
return files.models.File.generate_hash(source)
def add_error(self, field, error):
"""Add hidden `original_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('original_hash', None)
if hash_error:
error.error_dict['source'] = hash_error
super(forms.ModelForm, self).add_error(field, error)
def save(self, *args, **kwargs):
"""Save as `to_field` on the parent object (Extension)."""
source = self.cleaned_data['source']
self.instance.hash = self.instance.original_hash
self.instance.original_name = source.name
self.instance.size_bytes = source.size
instance = super().save(*args, **kwargs)
if hasattr(self, 'to_field'):
to_field = self.to_field
setattr(self.extension, to_field, instance)
return instance

View File

@ -144,8 +144,9 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
self.full_clean()
return super().save(*args, **kwargs)
def is_listed(self):
return self.status == self.model.STATUSES.APPROVED
@property
def is_listed(self) -> bool:
return self.status == self.STATUSES.APPROVED
@property
def is_image(self) -> bool:

View File

@ -1,6 +1,6 @@
{% load common i18n %}
{# FIXME: we might want to rephrase is_moderator in terms of Django's (group) permissions #}
{% if perms.files.view_file or request.user.is_moderator %}
{% if perms.files.view_file or user_is_moderator %}
{% if suspicious_files %}
<section>
<div class="card pb-3 pt-4 px-4 mb-3 ext-detail-download-danger">

View File

@ -1,6 +1,6 @@
{% load common i18n %}
{# FIXME: we might want to rephrase is_moderator in terms of Django's (group) permissions #}
{% if perms.files.view_file or request.user.is_moderator %}
{% if perms.files.view_file or user_is_moderator %}
{% if suspicious_files %}
{% blocktrans asvar alert_text %}Scan of the {{ suspicious_files.0 }} indicates malicious content.{% endblocktrans %}
<b class="text-danger pt-2" title="{{ alert_text }}"></b>

View File

@ -54,12 +54,11 @@ class FileMIMETypeValidator:
class ExtensionIDManifestValidator:
"""
Make sure the extension id is valid:
* Extension id consists of Unicode letters, numbers or underscores.
* Neither hyphens nor spaces are supported.
* Each extension id most be unique across all extensions.
* All versions of an extension must have the same extension id.
"""Make sure the extension id is valid:
* Extension id consists of Unicode letters, numbers or underscores.
* Neither hyphens nor spaces are supported.
* Each extension id most be unique across all extensions.
* All versions of an extension must have the same extension id.
"""
def __init__(self, manifest, extension_to_be_updated):
@ -307,10 +306,11 @@ class PermissionsValidator:
is_error = True
else:
for permission in value:
if VersionPermission.get_by_slug(permission):
continue
is_error = True
logger.info(f'Permission unavailable: {permission}')
try:
VersionPermission.get_by_slug(permission)
except VersionPermission.DoesNotExist:
is_error = True
logger.info(f'Permission unavailable: {permission}')
if not is_error:
return

View File

@ -4,45 +4,64 @@
{% block page_title %}{% blocktranslate %}Notifications{% endblocktranslate %}{% endblock page_title %}
{% block content %}
<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">
<h1>{% trans 'Notifications' %}</h1>
{{ notification.action.timestamp | naturaltime_compact }}
<a href="{% url 'extensions:by-author' user_id=notification.action.actor.pk %}">
{{ notification.action.actor }}
</a>
{{ notification.action.verb }}
<a href="{{ notification.action.target.get_absolute_url }}">{{ notification.action.target }}</a>
<a href="{{ notification.get_absolute_url }}"><button class="btn btn-sm">{% trans 'View' %}</button></a>
{% if not notification.read_at %}
<form class="d-inline" action="{% url 'notifications:notifications-mark-read' pk=notification.pk %}" method="post">
{% csrf_token %}
<button class="btn btn-sm" type="submit">{% trans 'Mark as read' %}</button>
</form>
{% if notification_list %}
<div class="notifications">
{% if user|unread_notification_count %}
<form action="{% url 'notifications:notifications-mark-read-all' %}" method="post">
{% csrf_token %}
<button class="btn mb-3" type="submit"><i class="i-eye"></i> {% trans 'Mark All as Read' %}</button>
</form>
{% 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 Unread" 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>
{% endfor %}
{% else %}
<p>
{% trans 'You have no notifications' %}
</p>
{% endif %}
{% else %}
<p>
{% trans 'You have no notifications' %}
</p>
{% endif %}
{% endblock content %}

View File

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

View File

@ -1,5 +1,5 @@
{% extends "extensions/detail.html" %}
{% load common extensions filters i18n humanize %}
{% load common extensions filters i18n humanize static %}
{% block page_title %}Review: {{ extension.name }}{% endblock page_title %}
@ -69,7 +69,26 @@
{% block extension_galleria %}
{% include "extensions/components/galleria.html" with extension=extension %}
{% include "extensions/components/galleria.html" with extension=extension previews=extension.get_previews %}
{% static "common/images/no-image.png" as featured_image_missing %}
{% trans "Featured image" as featured_image_title %}
{% with featured_image=extension.featured_image %}{% with has_featured_image=featured_image.source.name %}
<div class="card p-2 mt-2" style="width: 18rem;" title="{{ featured_image_title }}">
<a
{% if has_featured_image %}
href="{{ featured_image.source.url }}"
target="_blank"
{% endif %}
>
<img class="card-img-top rounded" src="{% if has_featured_image %}{{ featured_image.source.url }}{% else %}{{ featured_image_missing }}{% endif %}" alt="{{ featured_image_title }}">
</a>
<div class="card-body">
<small class="card-text">{{ featured_image_title}}</small>
</div>
</div>
{% endwith %}{% endwith %}
{% endblock extension_galleria %}
@ -145,7 +164,7 @@
{% csrf_token %}
{% with form=comment_form|add_form_classes %}
{% include "common/components/field.html" with field=form.message %}
{% include "common/components/field.html" with field=form.message placeholder="Enter the text here..." %}
<div class="d-flex align-items-center">
<div class="btn-row ms-3 w-100 justify-content-end">

View File

@ -80,7 +80,8 @@ class ExtensionsApprovalDetailView(DetailView):
def get_queryset(self):
return self.model.objects.prefetch_related(
'authors',
'previews',
'preview_set',
'preview_set__file',
'versions',
).all()

View File

@ -4,29 +4,34 @@
<h1 class="mb-3">Teams</h1>
<div class="row">
<div class="col">
<div class="row border-bottom mb-2 pb-2">
<div class="col">Team name</div>
<div class="col">Role</div>
<div class="col"></div>
</div>
{% for team_member in user.team_users.all %}
{% with team=team_member.team %}
<div class="row">
<div class="col">{{ team.name }}</div>
<div class="col">{{ team_member.get_role_display }}</div>
<div class="col">
{% comment %}
{% if team_member.is_manager %}
<a href="{{ team.get_manage_url }}">Manage</a>{# TODO: add team manage page #}
{% else %}
<a href="{{ team.get_absolute_url }}">View</a>
{% endif %}
{% endcomment %}
<a href="{{ team.get_absolute_url }}">View</a>
</div>
</div>
{% endwith %}
{% endfor %}
<table class="table table-hover">
<thead>
<tr>
<th class="w-100">
Team name
</th>
<th>
Role
</th>
</tr>
</thead>
<tbody>
{% for team_member in user.team_users.all %}
{% with team=team_member.team %}
<tr>
<td>
<a class="px-0" href="{{ team.get_absolute_url }}">{{ team.name }}</a>
</td>
<td>
<div class="badge">
{{ team_member.get_role_display }}
</div>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock settings %}