Merge 'policies' and 'conditions of use' pages #117
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.
|
||||||
|
@ -1 +1 @@
|
|||||||
Subproject commit af61a962e1a30898279b4efdbb07a2dcb230a257
|
Subproject commit 1126f102d8542ffb76af0269854048f276d9e50b
|
@ -10,9 +10,13 @@ from django.http.request import HttpRequest
|
|||||||
|
|
||||||
def extra_context(request: HttpRequest) -> Dict[str, str]:
|
def extra_context(request: HttpRequest) -> Dict[str, str]:
|
||||||
"""Injects some configuration values into template context."""
|
"""Injects some configuration values into template context."""
|
||||||
|
user_is_moderator = False
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
user_is_moderator = request.user.is_moderator
|
||||||
return {
|
return {
|
||||||
'BLENDER_ID': {
|
'BLENDER_ID': {
|
||||||
'BASE_URL': settings.BLENDER_ID['BASE_URL'],
|
'BASE_URL': settings.BLENDER_ID['BASE_URL'],
|
||||||
},
|
},
|
||||||
'canonical_url': request.build_absolute_uri(request.path),
|
'canonical_url': request.build_absolute_uri(request.path),
|
||||||
|
'user_is_moderator': user_is_moderator,
|
||||||
}
|
}
|
||||||
|
BIN
common/static/common/images/no-icon.png
Normal file
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
54
common/static/common/images/no-image.svg
Normal 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 |
BIN
common/static/common/images/no-image_640x360.png
Normal file
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
@ -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();
|
||||||
|
});
|
||||||
|
}())
|
||||||
|
@ -67,6 +67,10 @@
|
|||||||
.activity-icon
|
.activity-icon
|
||||||
top: 2.2rem
|
top: 2.2rem
|
||||||
|
|
||||||
|
code,
|
||||||
|
pre
|
||||||
|
white-space: normal
|
||||||
|
|
||||||
.activity-status-change
|
.activity-status-change
|
||||||
color: var(--color-text-tertiary)
|
color: var(--color-text-tertiary)
|
||||||
|
|
||||||
|
4
common/static/common/styles/_button.sass
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
button,
|
||||||
|
.btn
|
||||||
|
&[type=submit]
|
||||||
|
transition: opacity var(--transition-speed)
|
@ -80,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)
|
||||||
@ -270,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
|
||||||
@ -311,7 +302,7 @@
|
|||||||
.label
|
.label
|
||||||
line-height: calc(var(--spacer) * 2)
|
line-height: calc(var(--spacer) * 2)
|
||||||
|
|
||||||
/* Nabdrawer. */
|
/* Navdrawer. */
|
||||||
.nav-link
|
.nav-link
|
||||||
&[class*=" i-"]::before
|
&[class*=" i-"]::before
|
||||||
+margin(2, right)
|
+margin(2, right)
|
||||||
@ -328,8 +319,6 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
@ -378,3 +367,23 @@
|
|||||||
@extend .dropdown-divider
|
@extend .dropdown-divider
|
||||||
|
|
||||||
+margin(0, top)
|
+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
|
||||||
|
@ -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
@ -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
|
@ -1,4 +1,10 @@
|
|||||||
table,
|
table,
|
||||||
.table
|
.table
|
||||||
|
a
|
||||||
|
text-decoration: underline
|
||||||
|
|
||||||
|
th
|
||||||
|
color: var(--color-text-secondary)
|
||||||
|
|
||||||
thead
|
thead
|
||||||
white-space: normal
|
white-space: normal
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -18,6 +18,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'
|
||||||
@ -28,6 +29,7 @@ $container-width: map-get($container-max-widths, 'xl')
|
|||||||
@import '_hero.sass'
|
@import '_hero.sass'
|
||||||
@import '_list.sass'
|
@import '_list.sass'
|
||||||
@import '_navigation_global.sass'
|
@import '_navigation_global.sass'
|
||||||
|
@import '_notifications.sass'
|
||||||
@import '_table.sass'
|
@import '_table.sass'
|
||||||
@import 'ratings/static/ratings/styles/_review.sass'
|
@import 'ratings/static/ratings/styles/_review.sass'
|
||||||
@import 'ratings/static/ratings/styles/_stars.sass'
|
@import 'ratings/static/ratings/styles/_stars.sass'
|
||||||
|
@ -126,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>
|
||||||
@ -135,11 +135,13 @@
|
|||||||
{% endblock nav-upload %}
|
{% endblock nav-upload %}
|
||||||
|
|
||||||
{% 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>
|
||||||
@ -151,7 +153,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.is_moderator %}
|
{% if user_is_moderator %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'abuse:report-list' %}" class="dropdown-item">
|
<a href="{% url 'abuse:report-list' %}" class="dropdown-item">
|
||||||
<i class="i-shield"></i> {% trans "Abuse Reports" %}
|
<i class="i-shield"></i> {% trans "Abuse Reports" %}
|
||||||
@ -202,7 +204,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>
|
||||||
|
@ -2,7 +2,10 @@
|
|||||||
{% spaceless %}
|
{% spaceless %}
|
||||||
{% with type=field.field.widget.input_type classes=classes|default:"" placeholder=placeholder|default:"" %}
|
{% 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 %}
|
{% 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 %}
|
{% comment %} Checkboxes {% endcomment %}
|
||||||
{% if type == 'checkbox' %}
|
{% if type == 'checkbox' %}
|
||||||
@ -37,8 +40,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if field.help_text %}
|
{% if help_text and not field.is_hidden %}
|
||||||
<div class="form-text">{{ field.help_text|safe }}</div>
|
<div class="form-text">{{ help_text|safe }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if field.errors %}
|
{% if field.errors %}
|
||||||
|
@ -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>
|
||||||
|
@ -60,7 +60,9 @@ EXTENSION_TYPE_PLURAL = {
|
|||||||
EXTENSION_SLUGS_PATH = '|'.join(EXTENSION_TYPE_SLUGS.values())
|
EXTENSION_SLUGS_PATH = '|'.join(EXTENSION_TYPE_SLUGS.values())
|
||||||
EXTENSION_SLUG_TYPES = {v: k for k, v in EXTENSION_TYPE_SLUGS_SINGULAR.items()}
|
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
|
# FIXME: this controls the initial widget rendered server-side, and server-side validation
|
||||||
# but not the additional JS-appended preview file inputs.
|
# but not the additional JS-appended preview file inputs.
|
||||||
# If this list changes, the "accept" attribute also has to be updated in appendImageUploadForm.
|
# If this list changes, the "accept" attribute also has to be updated in appendImageUploadForm.
|
||||||
|
@ -62,6 +62,7 @@ class ExtensionAdmin(admin.ModelAdmin):
|
|||||||
'website',
|
'website',
|
||||||
)
|
)
|
||||||
raw_id_fields = ('team',)
|
raw_id_fields = ('team',)
|
||||||
|
autocomplete_fields = ('icon', 'featured_image')
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
@ -79,6 +80,7 @@ class ExtensionAdmin(admin.ModelAdmin):
|
|||||||
'name',
|
'name',
|
||||||
'slug',
|
'slug',
|
||||||
'description',
|
'description',
|
||||||
|
('icon', 'featured_image'),
|
||||||
'status',
|
'status',
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -202,8 +204,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,15 @@ 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 constants.base import (
|
||||||
from constants.base import ALLOWED_PREVIEW_MIMETYPES
|
ALLOWED_FEATURED_IMAGE_MIMETYPES,
|
||||||
|
ALLOWED_ICON_MIMETYPES,
|
||||||
|
ALLOWED_PREVIEW_MIMETYPES,
|
||||||
|
)
|
||||||
|
|
||||||
import extensions.models
|
import extensions.models
|
||||||
|
import files.forms
|
||||||
import files.models
|
import files.models
|
||||||
import reviewers.models
|
import reviewers.models
|
||||||
|
|
||||||
@ -38,61 +41,22 @@ EditPreviewFormSet = forms.inlineformset_factory(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AddPreviewFileForm(forms.ModelForm):
|
class AddPreviewFileForm(files.forms.BaseMediaFileForm):
|
||||||
msg_unexpected_file_type = _('Choose a JPEG, PNG or WebP image, or an MP4 video')
|
allowed_mimetypes = ALLOWED_PREVIEW_MIMETYPES
|
||||||
|
error_messages = {'invalid_mimetype': _('Choose a JPEG, PNG or WebP image, or an MP4 video')}
|
||||||
|
|
||||||
class Meta:
|
class Meta(files.forms.BaseMediaFileForm.Meta):
|
||||||
model = files.models.File
|
fields = ('caption',) + files.forms.BaseMediaFileForm.Meta.fields
|
||||||
fields = ('caption', 'source', 'original_hash', 'hash')
|
|
||||||
widgets = {'original_hash': forms.HiddenInput(), 'hash': forms.HiddenInput()}
|
|
||||||
|
|
||||||
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)
|
caption = forms.CharField(max_length=255, required=False)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.request = kwargs.pop('request')
|
self.base_fields['source'].required = True
|
||||||
self.extension = kwargs.pop('extension')
|
|
||||||
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
|
|
||||||
self.instance.user = self.request.user
|
|
||||||
|
|
||||||
instance = super().save(*args, **kwargs)
|
instance = super().save(*args, **kwargs)
|
||||||
|
|
||||||
# Create extension preview and save caption to it
|
# Create extension preview and save caption to it
|
||||||
@ -169,24 +133,46 @@ class ExtensionUpdateForm(forms.ModelForm):
|
|||||||
extension=self.instance,
|
extension=self.instance,
|
||||||
request=self.request,
|
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:
|
else:
|
||||||
edit_preview_formset = EditPreviewFormSet(instance=self.instance)
|
edit_preview_formset = EditPreviewFormSet(instance=self.instance)
|
||||||
add_preview_formset = AddPreviewFormSet(extension=self.instance, request=self.request)
|
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.edit_preview_formset = edit_preview_formset
|
||||||
self.add_preview_formset = add_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
|
self.add_preview_formset.error_messages['too_few_forms'] = self.msg_need_previews
|
||||||
|
|
||||||
def is_valid(self, *args, **kwargs) -> bool:
|
def is_valid(self, *args, **kwargs) -> bool:
|
||||||
"""Validate all nested forms and form(set)s first."""
|
"""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 'submit_draft' in self.data:
|
||||||
|
# Require at least one preview image when requesting a review
|
||||||
if not self.instance.previews.exists():
|
if not self.instance.previews.exists():
|
||||||
self.add_preview_formset.min_num = 1
|
self.add_preview_formset.min_num = 1
|
||||||
self.add_preview_formset.validate_min = True
|
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 = [
|
is_valid_flags = [
|
||||||
self.edit_preview_formset.is_valid(),
|
self.edit_preview_formset.is_valid(),
|
||||||
self.add_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),
|
super().is_valid(*args, **kwargs),
|
||||||
]
|
]
|
||||||
return all(is_valid_flags)
|
return all(is_valid_flags)
|
||||||
@ -212,6 +198,14 @@ class ExtensionUpdateForm(forms.ModelForm):
|
|||||||
"""Save the nested form(set)s, then the main form."""
|
"""Save the nested form(set)s, then the main form."""
|
||||||
self.edit_preview_formset.save()
|
self.edit_preview_formset.save()
|
||||||
self.add_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):
|
if getattr(self.instance, 'converted_to_draft', False):
|
||||||
reviewers.models.ApprovalActivity(
|
reviewers.models.ApprovalActivity(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
@ -259,3 +253,17 @@ class VersionDeleteForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = extensions.models.Version
|
model = extensions.models.Version
|
||||||
fields = []
|
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')}
|
||||||
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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()
|
||||||
@ -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.',
|
help_text='Whether the extension should be listed. It is kept in sync via signals.',
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
previews = FilterableManyToManyField(
|
|
||||||
|
featured_image = models.OneToOneField(
|
||||||
'files.File',
|
'files.File',
|
||||||
through='Preview',
|
related_name='featured_image_of',
|
||||||
related_name='extensions',
|
null=True,
|
||||||
# TODO: filter only images and videos.
|
blank=False,
|
||||||
# q_filter=Q(type=FILE_TYPE_CHOICES.IMAGE),
|
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)
|
status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.INCOMPLETE)
|
||||||
support = models.URLField(
|
support = models.URLField(
|
||||||
help_text='URL for reporting issues or contact details for support.', null=True, blank=True
|
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):
|
def get_review_url(self):
|
||||||
return reverse('reviewers:approval-detail', args=[self.slug])
|
return reverse('reviewers:approval-detail', args=[self.slug])
|
||||||
|
|
||||||
def get_previews(self):
|
def get_previews(self) -> List['Preview']:
|
||||||
"""Get preview files, sorted by Preview.position.
|
"""Get all preview files, sorted by Preview.position.
|
||||||
|
|
||||||
Avoid triggering additional querysets, rely on prefetch_related in the view.
|
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
|
@property
|
||||||
def valid_file_statuses(self) -> List[int]:
|
def valid_file_statuses(self) -> List[int]:
|
||||||
@ -386,28 +395,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 +534,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):
|
||||||
|
@ -29,14 +29,27 @@ def _log_deletion(
|
|||||||
instance.record_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.Preview)
|
||||||
@receiver(post_delete, sender=extensions.models.Version)
|
@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
|
f = instance.file
|
||||||
args = {'f_id': f.pk, 'h': f.hash, 'pk': instance.pk, 'sender': sender, 's': f.source.name}
|
_delete_file(f, sender, instance, rel=sender)
|
||||||
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
|
@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)
|
@receiver(pre_save, sender=extensions.models.Extension)
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock hero_breadcrumbs %}
|
{% endblock hero_breadcrumbs %}
|
||||||
|
|
||||||
<h1>{{ extension.name }}</h1>
|
<h1>{% include "extensions/components/icon.html" %} {{ extension.name }}</h1>
|
||||||
|
|
||||||
<div class="hero-subtitle">
|
<div class="hero-subtitle">
|
||||||
{% if latest.tagline %}
|
{% if latest.tagline %}
|
||||||
|
@ -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' %}"
|
||||||
/>
|
/>
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
{% load common filters %}
|
{% load common filters static %}
|
||||||
{% with latest=extension.latest_version thumbnail_360p_url=extension.get_previews.0.thumbnail_360p_url %}
|
{% 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">
|
||||||
<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>
|
||||||
|
|
||||||
|
@ -1,30 +1,30 @@
|
|||||||
{% with previews=extension.get_previews %}
|
{% with preview_count=previews|length %}
|
||||||
<section class="galleria-container" id="galleria-container">
|
<section class="galleria-container" id="galleria-container">
|
||||||
{% if previews %}
|
{% if previews %}
|
||||||
<div class="galleria-items{% if previews.count > 5 %} is-many{% endif %}{% if previews.count == 1 %} is-single{% endif %}" id="galleria-items">
|
<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 %}
|
{% for preview in previews %}
|
||||||
{% with thumbnail_1080p_url=preview.thumbnail_1080p_url %}
|
{% with thumbnail_1080p_url=preview.file.thumbnail_1080p_url file=preview.file %}
|
||||||
<a
|
<a
|
||||||
class="galleria-item js-galleria-item-preview galleria-item-type-{{ preview.content_type|slugify|slice:5 }}{% if forloop.first %} is-active{% endif %}"
|
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 }}"
|
href="{{ thumbnail_1080p_url }}"
|
||||||
{% if 'video' in preview.content_type %}data-galleria-video-url="{{ preview.source.url }}"{% endif %}
|
{% if 'video' in file.content_type %}data-galleria-video-url="{{ file.source.url }}"{% endif %}
|
||||||
data-galleria-content-type="{{ preview.content_type }}"
|
data-galleria-content-type="{{ file.content_type }}"
|
||||||
data-galleria-index="{{ forloop.counter }}">
|
data-galleria-index="{{ forloop.counter }}">
|
||||||
|
|
||||||
<img src="{{ thumbnail_1080p_url }}" alt="{{ preview.preview.caption }}">
|
<img src="{{ thumbnail_1080p_url }}" alt="{{ preview.caption }}">
|
||||||
</a>
|
</a>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="galleria-item is-empty">
|
<div class="galleria-item is-empty">
|
||||||
<div>
|
<div>
|
||||||
No preview yet.
|
No preview yet.
|
||||||
{% if is_maintainer %}
|
{% if is_maintainer %}
|
||||||
<a class="btn btn-primary px-5" href="{{ extension.get_manage_url }}#previews">Add a Preview</a>
|
<a class="btn btn-primary px-5" href="{{ extension.get_manage_url }}#previews">Add a Preview</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
3
extensions/templates/extensions/components/icon.html
Normal 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 %}">
|
@ -14,14 +14,14 @@
|
|||||||
<div class="col-md-8 pt-2">
|
<div class="col-md-8 pt-2">
|
||||||
{# Gallery #}
|
{# Gallery #}
|
||||||
{% block extension_galleria %}
|
{% 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 %}
|
{% endblock extension_galleria %}
|
||||||
|
|
||||||
{# Description #}
|
{# Description #}
|
||||||
{% 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>
|
||||||
@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
{# Permissions #}
|
{# Permissions #}
|
||||||
{% block extension_permissions %}
|
{% block extension_permissions %}
|
||||||
{% if extension.type_slug == 'add-on' %}
|
{% if extension.type_slug == 'add-ons' %}
|
||||||
<hr class="my-4">
|
<hr class="my-4">
|
||||||
<section id="permissions" class="ext-detail-permissions">
|
<section id="permissions" class="ext-detail-permissions">
|
||||||
<h2 class="mb-3">{% trans "Permissions" %}</h2>
|
<h2 class="mb-3">{% trans "Permissions" %}</h2>
|
||||||
|
@ -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,26 @@
|
|||||||
<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>
|
||||||
|
</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>
|
</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,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 %}
|
@ -30,14 +30,33 @@
|
|||||||
|
|
||||||
<section class="card p-3">
|
<section class="card p-3">
|
||||||
<div>
|
<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>
|
||||||
|
|
||||||
<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">
|
||||||
|
<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">
|
<section class="mt-4">
|
||||||
<h2>{% trans 'Previews' %}</h2>
|
<h2>{% trans 'Previews' %}</h2>
|
||||||
<div class="previews-upload">
|
<div class="previews-upload">
|
||||||
|
@ -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 %}
|
||||||
|
@ -65,11 +65,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 %}
|
||||||
@ -79,6 +79,27 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{% if form.non_field_errors %}
|
{% if form.non_field_errors %}
|
||||||
|
@ -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 }}
|
||||||
|
BIN
extensions/tests/files/test_featured_image_0001.png
Normal file
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
BIN
extensions/tests/files/test_icon_0001.png
Normal file
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
@ -108,6 +108,8 @@ class DeleteTest(TestCase):
|
|||||||
'description',
|
'description',
|
||||||
'download_count',
|
'download_count',
|
||||||
'extension_id',
|
'extension_id',
|
||||||
|
'featured_image',
|
||||||
|
'icon',
|
||||||
'is_listed',
|
'is_listed',
|
||||||
'name',
|
'name',
|
||||||
'pk',
|
'pk',
|
||||||
|
@ -309,6 +309,9 @@ class SubmitFinaliseTest(TestCase):
|
|||||||
'extension_form': [{'description': ['This field is required.']}, None],
|
'extension_form': [{'description': ['This field is required.']}, None],
|
||||||
'add_preview_formset': [[], ['Please add at least one preview.']],
|
'add_preview_formset': [[], ['Please add at least one preview.']],
|
||||||
'edit_preview_formset': [[], []],
|
'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_name1 = 'test_preview_image_0001.png'
|
||||||
file_name2 = 'test_preview_image_0002.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(
|
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1, open(
|
||||||
TEST_FILES_DIR / file_name2, 'rb'
|
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 = {
|
files = {
|
||||||
'form-0-source': fp1,
|
'form-0-source': fp1,
|
||||||
'form-1-source': fp2,
|
'form-1-source': fp2,
|
||||||
|
'icon-source': fp3,
|
||||||
|
'featured-image-source': fp4,
|
||||||
}
|
}
|
||||||
response = self.client.post(self.file.get_submit_url(), {**data, **files})
|
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(File.objects.filter(type=File.TYPES.BPY).count(), 1)
|
||||||
self.assertEqual(Extension.objects.count(), 1)
|
self.assertEqual(Extension.objects.count(), 1)
|
||||||
self.assertEqual(Version.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
|
# Check an add-on was created with all given fields
|
||||||
extension = Extension.objects.first()
|
extension = Extension.objects.first()
|
||||||
self.assertEqual(extension.get_type_display(), 'Add-on')
|
self.assertEqual(extension.get_type_display(), 'Add-on')
|
||||||
|
@ -212,10 +212,7 @@ class UpdateTest(TestCase):
|
|||||||
[
|
[
|
||||||
{},
|
{},
|
||||||
{'__all__': ['Please correct the duplicate values below.']},
|
{'__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.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 Original hash already exists.']},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_post_upload_validation_error_unexpected_preview_format_gif(self):
|
def test_post_upload_validation_error_unexpected_preview_format_gif(self):
|
||||||
|
@ -16,8 +16,8 @@ from .mixins import (
|
|||||||
from extensions.forms import (
|
from extensions.forms import (
|
||||||
ExtensionDeleteForm,
|
ExtensionDeleteForm,
|
||||||
ExtensionUpdateForm,
|
ExtensionUpdateForm,
|
||||||
VersionForm,
|
|
||||||
VersionDeleteForm,
|
VersionDeleteForm,
|
||||||
|
VersionForm,
|
||||||
)
|
)
|
||||||
from extensions.models import Extension, Version
|
from extensions.models import Extension, Version
|
||||||
from files.forms import FileForm
|
from files.forms import FileForm
|
||||||
@ -39,9 +39,12 @@ class ExtensionDetailView(ExtensionQuerysetMixin, DetailView):
|
|||||||
"""
|
"""
|
||||||
return self.get_extension_queryset().prefetch_related(
|
return self.get_extension_queryset().prefetch_related(
|
||||||
'authors',
|
'authors',
|
||||||
|
'ratings',
|
||||||
|
'ratings__user',
|
||||||
'versions',
|
'versions',
|
||||||
'versions__file',
|
'versions__file',
|
||||||
'versions__file__validation',
|
'versions__file__validation',
|
||||||
|
'versions__permissions',
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None):
|
||||||
@ -126,6 +129,8 @@ class UpdateExtensionView(
|
|||||||
context = super().get_context_data(*args, **kwargs)
|
context = super().get_context_data(*args, **kwargs)
|
||||||
context['edit_preview_formset'] = context['form'].edit_preview_formset
|
context['edit_preview_formset'] = context['form'].edit_preview_formset
|
||||||
context['add_preview_formset'] = context['form'].add_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
|
return context
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
@ -348,6 +353,8 @@ class DraftExtensionView(
|
|||||||
context['extension_form'] = extension_form
|
context['extension_form'] = extension_form
|
||||||
context['edit_preview_formset'] = extension_form.edit_preview_formset
|
context['edit_preview_formset'] = extension_form.edit_preview_formset
|
||||||
context['add_preview_formset'] = extension_form.add_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
|
return context
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
@ -6,6 +6,7 @@ import tempfile
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
import django.core.exceptions
|
||||||
|
|
||||||
from .validators import (
|
from .validators import (
|
||||||
ExtensionIDManifestValidator,
|
ExtensionIDManifestValidator,
|
||||||
@ -52,9 +53,7 @@ class FileForm(forms.ModelForm):
|
|||||||
message=error_messages['invalid_zip_archive'],
|
message=error_messages['invalid_zip_archive'],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
widget=forms.ClearableFileInput(
|
widget=forms.ClearableFileInput(attrs={'accept': ','.join(ALLOWED_EXTENSION_MIMETYPES)}),
|
||||||
attrs={'accept': ','.join(ALLOWED_EXTENSION_MIMETYPES)}
|
|
||||||
),
|
|
||||||
help_text=msg_only_zip_files,
|
help_text=msg_only_zip_files,
|
||||||
)
|
)
|
||||||
agreed_with_terms = forms.BooleanField(
|
agreed_with_terms = forms.BooleanField(
|
||||||
@ -154,3 +153,68 @@ class FileForm(forms.ModelForm):
|
|||||||
self.cleaned_data['type'] = EXTENSION_SLUG_TYPES[manifest['type']]
|
self.cleaned_data['type'] = EXTENSION_SLUG_TYPES[manifest['type']]
|
||||||
|
|
||||||
return self.cleaned_data
|
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
|
||||||
|
@ -144,8 +144,9 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
|
|||||||
self.full_clean()
|
self.full_clean()
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
def is_listed(self):
|
@property
|
||||||
return self.status == self.model.STATUSES.APPROVED
|
def is_listed(self) -> bool:
|
||||||
|
return self.status == self.STATUSES.APPROVED
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_image(self) -> bool:
|
def is_image(self) -> bool:
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{% load common i18n %}
|
{% load common i18n %}
|
||||||
{# FIXME: we might want to rephrase is_moderator in terms of Django's (group) permissions #}
|
{# 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 %}
|
{% if suspicious_files %}
|
||||||
<section>
|
<section>
|
||||||
<div class="card pb-3 pt-4 px-4 mb-3 ext-detail-download-danger">
|
<div class="card pb-3 pt-4 px-4 mb-3 ext-detail-download-danger">
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{% load common i18n %}
|
{% load common i18n %}
|
||||||
{# FIXME: we might want to rephrase is_moderator in terms of Django's (group) permissions #}
|
{# 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 %}
|
{% if suspicious_files %}
|
||||||
{% blocktrans asvar alert_text %}Scan of the {{ suspicious_files.0 }} indicates malicious content.{% endblocktrans %}
|
{% blocktrans asvar alert_text %}Scan of the {{ suspicious_files.0 }} indicates malicious content.{% endblocktrans %}
|
||||||
<b class="text-danger pt-2" title="{{ alert_text }}">⚠</b>
|
<b class="text-danger pt-2" title="{{ alert_text }}">⚠</b>
|
||||||
|
@ -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
|
||||||
|
@ -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 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>
|
||||||
</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 %}
|
||||||
|
@ -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">
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{% extends "extensions/detail.html" %}
|
{% 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 %}
|
{% block page_title %}Review: {{ extension.name }}{% endblock page_title %}
|
||||||
|
|
||||||
@ -69,7 +69,26 @@
|
|||||||
|
|
||||||
|
|
||||||
{% block extension_galleria %}
|
{% 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 %}
|
{% endblock extension_galleria %}
|
||||||
|
|
||||||
|
|
||||||
@ -145,7 +164,7 @@
|
|||||||
{% 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">
|
||||||
|
@ -80,7 +80,8 @@ class ExtensionsApprovalDetailView(DetailView):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.model.objects.prefetch_related(
|
return self.model.objects.prefetch_related(
|
||||||
'authors',
|
'authors',
|
||||||
'previews',
|
'preview_set',
|
||||||
|
'preview_set__file',
|
||||||
'versions',
|
'versions',
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
@ -4,29 +4,34 @@
|
|||||||
<h1 class="mb-3">Teams</h1>
|
<h1 class="mb-3">Teams</h1>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="row border-bottom mb-2 pb-2">
|
<table class="table table-hover">
|
||||||
<div class="col">Team name</div>
|
<thead>
|
||||||
<div class="col">Role</div>
|
<tr>
|
||||||
<div class="col"></div>
|
<th class="w-100">
|
||||||
</div>
|
Team name
|
||||||
{% for team_member in user.team_users.all %}
|
</th>
|
||||||
{% with team=team_member.team %}
|
<th>
|
||||||
<div class="row">
|
Role
|
||||||
<div class="col">{{ team.name }}</div>
|
</th>
|
||||||
<div class="col">{{ team_member.get_role_display }}</div>
|
</tr>
|
||||||
<div class="col">
|
</thead>
|
||||||
{% comment %}
|
<tbody>
|
||||||
{% if team_member.is_manager %}
|
{% for team_member in user.team_users.all %}
|
||||||
<a href="{{ team.get_manage_url }}">Manage</a>{# TODO: add team manage page #}
|
{% with team=team_member.team %}
|
||||||
{% else %}
|
<tr>
|
||||||
<a href="{{ team.get_absolute_url }}">View</a>
|
<td>
|
||||||
{% endif %}
|
<a class="px-0" href="{{ team.get_absolute_url }}">{{ team.name }}</a>
|
||||||
{% endcomment %}
|
</td>
|
||||||
<a href="{{ team.get_absolute_url }}">View</a>
|
<td>
|
||||||
</div>
|
<div class="badge">
|
||||||
</div>
|
{{ team_member.get_role_display }}
|
||||||
{% endwith %}
|
</div>
|
||||||
{% endfor %}
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock settings %}
|
{% endblock settings %}
|
||||||
|