Extensions list: sort_by parameter #159

Merged
Márton Lente merged 36 commits from filter-sort into main 2024-06-03 12:57:45 +02:00
60 changed files with 777 additions and 374 deletions
Showing only changes of commit 11ecab3e1f - Show all commits

View File

@ -45,6 +45,7 @@ class AbuseReportTypeFilter(admin.SimpleListFilter):
class AbuseReportAdmin(CommaSearchInAdminMixin, admin.ModelAdmin): class AbuseReportAdmin(CommaSearchInAdminMixin, admin.ModelAdmin):
save_on_top = True
view_on_site = True view_on_site = True
actions = ('delete_selected', 'mark_as_valid', 'mark_as_suspicious') actions = ('delete_selected', 'mark_as_valid', 'mark_as_suspicious')
date_hierarchy = 'date_modified' date_hierarchy = 'date_modified'

@ -1 +1 @@
Subproject commit ffcc72b5cb153fc2a409c795adca82e350655aa2 Subproject commit 6a52e5abcc118133b8cb51137b34bf856da716c4

View File

@ -330,3 +330,6 @@ ACTSTREAM_SETTINGS = {
# Require file validation for other file processing (e.g. thumbnails). # Require file validation for other file processing (e.g. thumbnails).
# Should be set for staging/production. # Should be set for staging/production.
REQUIRE_FILE_VALIDATION = os.getenv('REQUIRE_FILE_VALIDATION', False) REQUIRE_FILE_VALIDATION = os.getenv('REQUIRE_FILE_VALIDATION', False)
# Maximum number of attempts for failing background tasks
MAX_ATTEMPTS = 5

View File

@ -1,25 +1,44 @@
(function() { (function() {
// Create function agreeWithTerms // Create function agreeWithTerms
function agreeWithTerms() { function agreeWithTerms() {
const agreeWithTermsInput = document.querySelector('.js-agree-with-terms-input'); const agreeWithTermsTrigger = document.querySelectorAll('.js-agree-with-terms-trigger');
const agreeWithTermsCheckbox = document.querySelector('.js-agree-with-terms-checkbox');
const agreeWithTermsFileInput = document.querySelector('.js-submit-form-file-input');
let agreeWithTermsBtnSubmit = document.querySelector('.js-agree-with-terms-btn-submit');
if (!agreeWithTermsInput) { if (!agreeWithTermsCheckbox) {
// Stop function execution if agreeWithTermsInput is not present // Stop function execution if agreeWithTermsCheckbox is not present
return; return;
} }
agreeWithTermsInput.addEventListener('change', function(e) { // Both the file input and checkbox can trigger the submit button.
const agreeWithTermsBtnSubmit = document.querySelector('.js-agree-with-terms-btn-submit'); agreeWithTermsTrigger.forEach((el) => {
el.addEventListener('change', function() {
// Check if checkbox is checked // Check if checkbox is checked, and file input has a file selected.
if (e.target.checked == true) { let is_allowed = (agreeWithTermsCheckbox.checked == true) && (agreeWithTermsFileInput.value != "");
agreeWithTermsBtnSubmit.removeAttribute('disabled');
} else { if (is_allowed) {
agreeWithTermsBtnSubmit.setAttribute('disabled', true); agreeWithTermsBtnSubmit.removeAttribute('disabled');
} } else {
agreeWithTermsBtnSubmit.setAttribute('disabled', true);
}
});
}); });
} }
// Create function submitFormFileInputClear
// When the user chooses a new file to upload, clear the error classes.
function submitFormFileInputClear() {
const submitFormFileInput = document.querySelector('.js-submit-form-file-input');
submitFormFileInput.addEventListener('change', function(e) {
e.target.classList.remove('is-invalid');
});
}
// Create function btnBack // Create function btnBack
function btnBack() { function btnBack() {
const btnBack = document.querySelectorAll('.js-btn-back'); const btnBack = document.querySelectorAll('.js-btn-back');
@ -133,6 +152,7 @@
// Create function init // Create function init
function init() { function init() {
agreeWithTerms(); agreeWithTerms();
submitFormFileInputClear();
btnBack(); btnBack();
commentForm(); commentForm();
copyInstallUrl(); copyInstallUrl();

View File

@ -1,16 +1,4 @@
function galleriaCloneFirstItem() { /* Set the image and attributes of the large preview element. */
const galleriaCarrousel = document.getElementById('galleria-items');
if (!galleriaCarrousel) { return;}
let firstGalleriaItem = galleriaCarrousel.firstElementChild.cloneNode(true);
firstGalleriaItem.classList.remove('js-galleria-item-preview', 'is-active');
firstGalleriaItem.classList.add('js-expand-on-click');
firstGalleriaItem.id = 'galleria-item-lg';
document.getElementById("galleria-container").prepend(firstGalleriaItem);
}
function galleriaSetLargePreview(item) { function galleriaSetLargePreview(item) {
let previewsContainer = document.getElementById('galleria-items'); let previewsContainer = document.getElementById('galleria-items');
let previewLarge = document.getElementById('galleria-item-lg'); let previewLarge = document.getElementById('galleria-item-lg');
@ -25,7 +13,7 @@ function galleriaSetLargePreview(item) {
const galleriaVideoUrl = item.dataset.galleriaVideoUrl; const galleriaVideoUrl = item.dataset.galleriaVideoUrl;
previewLarge.classList = item.classList; previewLarge.classList = item.classList;
previewLarge.firstElementChild.src = galleryItem.src; previewLarge.firstElementChild.src = item.href;
previewLarge.firstElementChild.alt = galleryItem.alt; previewLarge.firstElementChild.alt = galleryItem.alt;
previewLarge.dataset.galleriaIndex = galleriaIndex; previewLarge.dataset.galleriaIndex = galleriaIndex;
previewLarge.dataset.galleriaContentType = galleriaContentType; previewLarge.dataset.galleriaContentType = galleriaContentType;
@ -36,6 +24,22 @@ function galleriaSetLargePreview(item) {
} }
/* Create the large preview by cloning the first thumbnail. */
function galleriaCloneFirstItem() {
const galleriaCarrousel = document.getElementById('galleria-items');
if (!galleriaCarrousel) { return;}
let firstGalleriaItem = galleriaCarrousel.firstElementChild.cloneNode(true);
document.getElementById("galleria-container").prepend(firstGalleriaItem);
firstGalleriaItem.classList.remove('js-galleria-item-preview', 'is-active');
firstGalleriaItem.classList.add('js-expand-on-click');
firstGalleriaItem.id = 'galleria-item-lg';
galleriaSetLargePreview(firstGalleriaItem);
}
function galleriaCreateCaption(captionText, overlay) { function galleriaCreateCaption(captionText, overlay) {
let captionContainer = document.getElementById('galleria-caption'); let captionContainer = document.getElementById('galleria-caption');
@ -70,7 +74,7 @@ function galleriaScrollNavigation() {
}); });
} }
/* Create the overlay that will host the image and navigation controls. */
function galleriaCreateOverlay() { function galleriaCreateOverlay() {
let overlay = document.createElement("div"); let overlay = document.createElement("div");
overlay.classList.add("galleria"); overlay.classList.add("galleria");
@ -79,7 +83,7 @@ function galleriaCreateOverlay() {
return overlay; return overlay;
} }
/* Close and delete the overlay. */
function galleriaCloseOverlay(overlay) { function galleriaCloseOverlay(overlay) {
if (overlay.parentNode === document.body) { if (overlay.parentNode === document.body) {
document.body.removeChild(overlay); document.body.removeChild(overlay);
@ -87,7 +91,7 @@ function galleriaCloseOverlay(overlay) {
} }
} }
/* Create the backdrop behind the overlay. */
function galleriaCreateUnderlay() { function galleriaCreateUnderlay() {
let underlay = document.createElement("div"); let underlay = document.createElement("div");
underlay.classList.add("underlay"); underlay.classList.add("underlay");
@ -103,7 +107,7 @@ function galleriaCreateLoadingPlaceholder() {
} }
/* Create Image element. */ /* Create Large Image element. */
function galleriaCreateMediaImage(galleriaItem, overlay, loadingPlaceholder) { function galleriaCreateMediaImage(galleriaItem, overlay, loadingPlaceholder) {
let galleriaNewItem = new Image(); let galleriaNewItem = new Image();
galleriaNewItem.id = 'galleria-active-item'; galleriaNewItem.id = 'galleria-active-item';
@ -114,7 +118,7 @@ function galleriaCreateMediaImage(galleriaItem, overlay, loadingPlaceholder) {
} }
}; };
galleriaNewItem.src = galleriaItem.firstElementChild.src; galleriaNewItem.src = galleriaItem.href;
galleriaNewItem.alt = galleriaItem.firstElementChild.alt; galleriaNewItem.alt = galleriaItem.firstElementChild.alt;
galleriaCreateCaption(galleriaNewItem.alt, overlay); galleriaCreateCaption(galleriaNewItem.alt, overlay);
@ -157,7 +161,6 @@ function galleriaCreateMedia(galleriaItem, galleriaContentType, overlay) {
galleriaCreateMediaImage(galleriaItem, overlay, loadingPlaceholder); galleriaCreateMediaImage(galleriaItem, overlay, loadingPlaceholder);
} }
overlay.appendChild(loadingPlaceholder); overlay.appendChild(loadingPlaceholder);
} }

View File

@ -4,14 +4,17 @@
z-index: 0 z-index: 0
> li > li
--border-color: var(--box-bg-color)
position: relative position: relative
&:only-child
/* Remove vertical line if only item. */
.activity-item:before
display: none
&:first-child &:first-child
/* Remove top half of the vertical line for first item. */ /* Remove top half of the vertical line for first item. */
.activity-status-change:before .activity-item:before
height: 50% top: 3rem
top: 50%
&:last-child &:last-child
.activity-status-change:before .activity-status-change:before
@ -109,7 +112,7 @@
font-size: var(--fs-sm) font-size: var(--fs-sm)
height: var(--spacer-4) height: var(--spacer-4)
justify-content: center justify-content: center
left: -.66rem left: -1rem
position: absolute position: absolute
width: var(--spacer-4) width: var(--spacer-4)

View File

@ -7,9 +7,9 @@
.badge-notifications-count .badge-notifications-count
background-color: var(--color-accent) background-color: var(--color-accent)
border-color: var(--nav-global-color-bg) border-color: var(--bwa-color-bg-primary)
border-radius: var(--spacer-2) border-radius: var(--spacer-2)
color: var(--nav-global-color-text-active) color: var(--bwa-color-text-primary)
display: flex display: flex
font-size: .8rem font-size: .8rem
+fw-bold +fw-bold
@ -17,7 +17,7 @@
left: 1.8rem left: 1.8rem
min-width: var(--spacer) min-width: var(--spacer)
position: absolute position: absolute
top: var(--spacer-1) top: .2rem
a.badge-tag a.badge-tag
--badge-color: var(--color-text-secondary) --badge-color: var(--color-text-secondary)
@ -45,3 +45,18 @@ a.badge-tag
&-disabled-by-staff, &-disabled-by-staff,
&-disabled-by-author &-disabled-by-author
@extend .badge-secondary @extend .badge-secondary
.badge-outline
background-color: transparent
&.badge-status
&-approved
color: var(--color-success)
&-awaiting-review
color: var(--color-info)
&-incomplete,
&-awaiting-changes,
color: var(--color-warning)
&-disabled-by-staff,
&-disabled-by-author
color: var(--color-secondary)

View File

@ -13,7 +13,7 @@
.cards-item-content .cards-item-content
overflow: hidden overflow: hidden
.crads-item-excerpt .cards-item-excerpt
line-height: calc(24 / 18) line-height: calc(24 / 18)
.cards-item-extra .cards-item-extra
@ -25,5 +25,24 @@
.stars .stars
font-size: 1.4rem font-size: 1.4rem
.cards-item-headline
color: var(--color-text-secondary)
font-size: var(--fs-xs)
+fw-normal
letter-spacing: .1rem
line-height: var(--spacer)
+margin(1, bottom)
text-transform: uppercase
.cards-item-thumbnail
background-color: var(--color-bg-secondary)
border-bottom-left-radius: 0
border-bottom-right-radius: 0
.cards-item-title .cards-item-title
+padding(0, y) +padding(0, y)
.is-row-add-ons,
.is-row-themes
.cards-item-headline
display: none

View File

@ -16,14 +16,19 @@
overflow: initial overflow: initial
text-shadow: none text-shadow: none
h1
margin-left: calc(var(--spacer-3))
.hero-content .hero-content
margin: auto 0 margin: auto 0
.hero-subtitle .hero-subtitle
margin-left: calc(var(--spacer-4) + var(--spacer-1) + var(--fs-hero-title))
max-width: none max-width: none
.badge .badge
+margin(2, right) +margin(2, left)
pointer-events: none
.hero-overlay .hero-overlay
background-color: transparent background-color: transparent
@ -152,10 +157,10 @@
padding: 0 padding: 0
strong strong
font-size: var(--fs-lg) font-size: var(--fs-h4)
i i
font-size: var(--fs-lg) font-size: var(--fs-h4)
+margin(3, right) +margin(3, right)
.ext-detail-download .ext-detail-download
@ -244,11 +249,17 @@
font-size: var(--fs-sm) font-size: var(--fs-sm)
line-height: var(--lh-sm) line-height: var(--lh-sm)
ul .details-buttons
+list-unstyled align-items: baseline
margin: 0 display: flex
+padding(2, top) gap: var(--spacer-2)
@extend .list-inline justify-content: space-between
.btn-row
flex-wrap: nowrap
.btn
+padding(2, x)
.drag-widget .drag-widget
align-items: center align-items: center
@ -261,9 +272,18 @@
pointer-events: none pointer-events: none
user-select: none user-select: none
.form-control .ext-preview-thumbnail-icon
&[type="file"] align-items: center
max-width: 50% display: flex
font-size: var(--fs-h2)
justify-content: center
color: var(--color-text-tertiary)
&:hover i
color: var(--color-text-secondary)
i
transition: color var(--transition-speed)
.ext-version-history .ext-version-history
summary summary
@ -346,6 +366,12 @@
.badge .badge
text-decoration: none !important text-decoration: none !important
.ext-review-list-name
display: flex
.extension-icon
+margin(2, right)
.ext-review-list-type .ext-review-list-type
max-width: 9ch max-width: 9ch
min-width: 9ch min-width: 9ch
@ -392,7 +418,16 @@
+padding(3, x) +padding(3, x)
.extension-icon .extension-icon
width: var(--fs-h1) display: inline-block
vertical-align: bottom
width: var(--fs-lg)
img
border-radius: calc(var(--border-radius) / 2)
max-width: 100%
&.icon-lg
width: var(--fs-hero-title)
.icon-preview, .featured-image-preview .icon-preview, .featured-image-preview
height: 9rem height: 9rem

View File

@ -0,0 +1,3 @@
.navbar-search
input
min-width: calc(var(--spacer) * 4)

View File

@ -1,22 +1,12 @@
.nav-global .nav-global .nav-global-nav-links
--nav-global-border-radius: var(--border-radius) +padding(2, right)
--nav-global-border-radius-lg: var(--border-radius-lg)
--nav-global-button-height: calc(var(--spacer) * 2.5)
--nav-global-font-size: var(--fs-sm)
--nav-global-link-padding-y: var(--nav-global-spacer-xs);
--nav-global-navbar-height: var(--navbar-primary-height, var(--spacer-6));
--nav-global-spacer: var(--spacer)
--nav-global-spacer-sm: var(--spacer-2)
--nav-global-spacer-xs: var(--spacer-1)
.btn @media (max-width: 767px)
&:hover .nav-global .nav-global-nav-links li a:hover,
background-color: var(--nav-global-color-button-bg-hover) .nav-global .nav-global-nav-links li a.nav-global-link-active
color: var(--nav-global-color-text-hover) !important background-color: var(--bwa-color-accent-bg) !important
color: var(--bwa-color-accent) !important
.btn-primary +media-xl
color: var(--color-accent) !important .nav-global .nav-global-container
max-width: 1320px
input,
.form-control
height: var(--nav-global-button-height)

View File

@ -48,6 +48,10 @@
.style-rich-text .style-rich-text
+style-rich-text +style-rich-text
// TODO: @web-assets move style pre to web-assets
pre
+margin(3, bottom)
.text-accent .text-accent
color: var(--color-accent) color: var(--color-accent)

View File

@ -29,6 +29,7 @@ $container-width: map-get($container-max-widths, 'xl')
@import '_galleria.sass' @import '_galleria.sass'
@import '_hero.sass' @import '_hero.sass'
@import '_list.sass' @import '_list.sass'
@import '_navigation.sass'
@import '_navigation_global.sass' @import '_navigation_global.sass'
@import '_notifications.sass' @import '_notifications.sass'
@import '_table.sass' @import '_table.sass'
@ -39,19 +40,6 @@ $container-width: map-get($container-max-widths, 'xl')
\:root \:root
--z-index-galleria: 1050 --z-index-galleria: 1050
.nav-global button.nav-global-logo
+media-xs
width: 60px
/* TODO: temporarily here until it can be moved to web-assets v2. */
.nav-global-links-right
gap: 0 var(--spacer-2)
.navbar-search
margin: 0
.navbar-search
width: 160px
.profile-avatar .profile-avatar
border-radius: 50% border-radius: 50%
height: var(--spacer-4) height: var(--spacer-4)

View File

@ -31,19 +31,15 @@
</head> </head>
<body class="has-global-bar"> <body class="has-global-bar">
{% switch "is_alpha" %} {% switch "is_beta" %}
<div class="site-announcement-alpha">
This platform is currently in alpha.
<a class="text-underline" href="https://projects.blender.org/infrastructure/extensions-website/issues" target="_blank">Please report any issues you may find</a>, thanks! <a class="text-underline" href="https://devtalk.blender.org/tag/extensions" target="_blank">Learn more</a>
</div>
{% else %}
{% switch "is_beta" %}
<div class="site-announcement-beta"> <div class="site-announcement-beta">
The website will be officially released together with Blender 4.2. This platform is currently in beta.
Meanwhile you can use the extensions with a <a class="text-underline" href="https://builder.blender.org/" target="_blank"> daily build</a> of Blender. <a class="text-underline" href="https://devtalk.blender.org/tag/extensions" target="_blank">Learn more</a> <a class="text-underline" href="https://projects.blender.org/infrastructure/extensions-website/issues" target="_blank">Please report any issues you may find</a>, thanks!
Access extensions with a <a class="text-underline" href="https://builder.blender.org/" target="_blank"> daily build</a> of Blender. <a class="text-underline" href="https://code.blender.org/2024/05/extensions-platform-beta-release/" target="_blank">Learn more</a>
</div> </div>
{% endswitch %}
{% endswitch %} {% endswitch %}
{% if request.user.is_staff %} {% if request.user.is_staff %}
<div class="whoosh-container"> <div class="whoosh-container">
<a href="{% url 'admin:index' %}" title='Admin' class="whoosh"> <a href="{% url 'admin:index' %}" title='Admin' class="whoosh">
@ -52,10 +48,11 @@
{% block admin_button_page %}{% endblock %} {% block admin_button_page %}{% endblock %}
</div> </div>
{% endif %} {% endif %}
{# TODO: improve nav-global layout for small screens #}
<div class="nav-global"> <div class="nav-global">
<div class="nav-global-container"> <div class="nav-global-container">
<nav> <nav>
<div class="site-beta-logo-container"> <div class="d-md-block d-none site-beta-logo-container text-nowrap">
<a href="/" class="nav-global-logo{% if request.get_full_path == '/' %} is-active{% endif %}"> <a href="/" class="nav-global-logo{% if request.get_full_path == '/' %} is-active{% endif %}">
<svg fill-rule="nonzero" viewBox="0 0 200 162.05"> <svg fill-rule="nonzero" viewBox="0 0 200 162.05">
<path <path
@ -73,55 +70,48 @@
</div> </div>
<button class="nav-global-logo js-dropdown-toggle" data-toggle-menu-id="nav-global-nav-links"> <button class="nav-global-logo js-dropdown-toggle" data-toggle-menu-id="nav-global-nav-links">
<svg fill-rule="nonzero" viewBox="0 0 850.2 162.05"> <svg fill-rule="nonzero" viewBox="0 0 200 162.05">
<path <path
d="M61.1 104.56c.05 2.6.88 7.66 2.12 11.61a61.27 61.27 0 0 0 13.24 22.92 68.39 68.39 0 0 0 23.17 16.64 74.46 74.46 0 0 0 30.42 6.32 74.52 74.52 0 0 0 30.4-6.42 68.87 68.87 0 0 0 23.15-16.7 61.79 61.79 0 0 0 13.23-22.97 58.06 58.06 0 0 0 2.07-25.55 59.18 59.18 0 0 0-8.44-23.1 64.45 64.45 0 0 0-15.4-16.98h.02L112.76 2.46l-.16-.12c-4.09-3.14-10.96-3.13-15.46.02-4.55 3.18-5.07 8.44-1.02 11.75l-.02.02 26 21.14-79.23.08h-.1c-6.55.01-12.85 4.3-14.1 9.74-1.27 5.53 3.17 10.11 9.98 10.14v.02l40.15-.07-71.66 55-.27.2c-6.76 5.18-8.94 13.78-4.69 19.23 4.32 5.54 13.51 5.55 20.34.03l39.1-32s-.56 4.32-.52 6.91zm100.49 14.47c-8.06 8.2-19.34 12.86-31.54 12.89-12.23.02-23.5-4.6-31.57-12.79-3.93-4-6.83-8.59-8.61-13.48a35.57 35.57 0 0 1 2.34-29.25 39.1 39.1 0 0 1 9.58-11.4 44.68 44.68 0 0 1 28.24-9.85 44.59 44.59 0 0 1 28.24 9.77 38.94 38.94 0 0 1 9.58 11.36 35.58 35.58 0 0 1 4.33 14.18 35.1 35.1 0 0 1-1.98 15.05 37.7 37.7 0 0 1-8.61 13.52zm-57.6-27.91a23.55 23.55 0 0 1 8.55-16.68 28.45 28.45 0 0 1 18.39-6.57 28.5 28.5 0 0 1 18.38 6.57 23.57 23.57 0 0 1 8.55 16.67c.37 6.83-2.37 13.19-7.2 17.9a28.18 28.18 0 0 1-19.73 7.79c-7.83 0-14.84-3-19.75-7.8a23.13 23.13 0 0 1-7.19-17.88z" /> d="M61.1 104.56c.05 2.6.88 7.66 2.12 11.61a61.27 61.27 0 0 0 13.24 22.92 68.39 68.39 0 0 0 23.17 16.64 74.46 74.46 0 0 0 30.42 6.32 74.52 74.52 0 0 0 30.4-6.42 68.87 68.87 0 0 0 23.15-16.7 61.79 61.79 0 0 0 13.23-22.97 58.06 58.06 0 0 0 2.07-25.55 59.18 59.18 0 0 0-8.44-23.1 64.45 64.45 0 0 0-15.4-16.98h.02L112.76 2.46l-.16-.12c-4.09-3.14-10.96-3.13-15.46.02-4.55 3.18-5.07 8.44-1.02 11.75l-.02.02 26 21.14-79.23.08h-.1c-6.55.01-12.85 4.3-14.1 9.74-1.27 5.53 3.17 10.11 9.98 10.14v.02l40.15-.07-71.66 55-.27.2c-6.76 5.18-8.94 13.78-4.69 19.23 4.32 5.54 13.51 5.55 20.34.03l39.1-32s-.56 4.32-.52 6.91zm100.49 14.47c-8.06 8.2-19.34 12.86-31.54 12.89-12.23.02-23.5-4.6-31.57-12.79-3.93-4-6.83-8.59-8.61-13.48a35.57 35.57 0 0 1 2.34-29.25 39.1 39.1 0 0 1 9.58-11.4 44.68 44.68 0 0 1 28.24-9.85 44.59 44.59 0 0 1 28.24 9.77 38.94 38.94 0 0 1 9.58 11.36 35.58 35.58 0 0 1 4.33 14.18 35.1 35.1 0 0 1-1.98 15.05 37.7 37.7 0 0 1-8.61 13.52zm-57.6-27.91a23.55 23.55 0 0 1 8.55-16.68 28.45 28.45 0 0 1 18.39-6.57 28.5 28.5 0 0 1 18.38 6.57 23.57 23.57 0 0 1 8.55 16.67c.37 6.83-2.37 13.19-7.2 17.9a28.18 28.18 0 0 1-19.73 7.79c-7.83 0-14.84-3-19.75-7.8a23.13 23.13 0 0 1-7.19-17.88z" />
</svg> </svg>
<svg class="nav-global-icon nav-global-icon-dropdown-toggle" height="100px" width="100px" viewBox="0 0 1000 1000"> <strong>Extensions</strong>
<path <i class="i-chevron-down"></i>
d="m 206.53824,376.41174 a 42,42 0 0 1 71,-29 l 221,220 220,-220 a 42,42 0 1 1 59,59 l -250,250 a 42,42 0 0 1 -59,0 l -250,-250 a 42,42 0 0 1 -12,-30 z" />
</svg>
</button> </button>
<ul class="nav-global-nav-links nav-global-dropdown js-dropdown-menu" id="nav-global-nav-links"> <ul class="flex-nowrap nav-global-nav-links nav-global-dropdown js-dropdown-menu" id="nav-global-nav-links">
<li class="d-md-none">
<a href="/" class="{% if request.get_full_path == '/' %}nav-global-link-active{% endif %}">
Home
</a>
</li>
<li> <li>
<a href="{% url 'extensions:by-type' type_slug='add-ons' %}" class="{% if '/add-ons/' in request.get_full_path %}is-active{% endif %}"> <a href="{% url 'extensions:by-type' type_slug='add-ons' %}" class="{% if '/add-ons/' in request.get_full_path %}nav-global-link-active{% endif %}">
Add-ons Add-ons
</a> </a>
</li> </li>
<li> <li>
<a href="{% url 'extensions:by-type' type_slug='themes' %}" class="{% if '/themes/' in request.get_full_path %}is-active{% endif %}"> <a href="{% url 'extensions:by-type' type_slug='themes' %}" class="{% if '/themes/' in request.get_full_path %}nav-global-link-active{% endif %}">
Themes Themes
</a> </a>
</li> </li>
<li> <li>
<a href="{% url 'reviewers:approval-queue' %}" class="{% if '/approval-queue/' in request.get_full_path %}is-active{% endif %}"> <a href="{% url 'reviewers:approval-queue' %}" class="{% if '/approval-queue/' in request.get_full_path %}nav-global-link-active{% endif %}">
Approval Queue Approval Queue
</a> </a>
</li> </li>
<li> <li>
<a href="{% url 'flatpage-about' %}" class="{% if '/about/' in request.get_full_path %}is-active{% endif %}"> <a href="{% url 'flatpage-about' %}" class="{% if '/about/' in request.get_full_path %}nav-global-link-active{% endif %}">
About About
</a> </a>
</li> </li>
</ul> </ul>
<ul class="nav-global-links-right"> <ul class="nav-global-links-right">
<li> <li class="d-lg-inline-flex d-none">
<button class="js-toggle-theme-btn px-2"><i class="js-toggle-theme-btn-icon i-adjust"></i></button>
</li>
<li>
<search> <search>
<form action="{% url "extensions:search" %}" method="GET" class="navbar-search"> <form action="{% url "extensions:search" %}" class="navbar-search" method="GET">
<input type="text" name="q" class="form-control" <input aria-label="Search" aria-describedby="nav-search-button" class="form-control" type="text" placeholder="Search..." {% if request.GET.q %} value="{{ request.GET.q }}" {% else %} {% endif %}>
{% if request.GET.q %} <button id="nav-search-button" type="submit">
value="{{ request.GET.q }}"
{% else %}
placeholder="Search..."
{% endif %}
aria-label="Search"
aria-describedby="nav-search-button">
<button type="submit" id="nav-search-button">
<i class="i-search"></i> <i class="i-search"></i>
</button> </button>
</form> </form>
@ -129,17 +119,18 @@
</li> </li>
{% block nav-upload %} {% block nav-upload %}
<li> <li class="d-lg-inline-flex d-none">
<a href="{% url 'extensions:submit' %}" class="btn btn-primary"> <a class="nav-global-btn nav-global-btn-primary" href="{% url 'extensions:submit' %}"><i class="i-upload"></i><span>Upload Extension</span></a>
<i class="i-upload"></i> </li>
<span>Upload Extension</span>
</a>
</li>
{% endblock nav-upload %} {% endblock nav-upload %}
<li>
<button class="js-toggle-theme-btn px-2"><i class="js-toggle-theme-btn-icon i-adjust"></i></button>
</li>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<li> <li>
<a class="btn btn-link position-relative px-2" href="{% url 'notifications:notifications' %}"> <a class="nav-global-btn position-relative" href="{% url 'notifications:notifications' %}">
{% with unread_notification_count=user|unread_notification_count %} {% with unread_notification_count=user|unread_notification_count %}
{% if unread_notification_count %} {% if unread_notification_count %}
<div class="badge badge-notifications-count">{{ unread_notification_count }}</div> <div class="badge badge-notifications-count">{{ unread_notification_count }}</div>
@ -148,7 +139,7 @@
<i class="i-bell"></i> <i class="i-bell"></i>
</a> </a>
</li> </li>
<li class="nav-item dropdown"> <li class="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 pe-3 px-2"> <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>
@ -170,6 +161,12 @@
<li class="dropdown-divider"></li> <li class="dropdown-divider"></li>
{% endif %} {% endif %}
<li class="d-xl-none">
<a href="{% url 'extensions:submit' %}" class="dropdown-item">
<i class="i-upload"></i> {% trans 'Upload Extension' %}
</a>
</li>
<li> <li>
<a href="{% url 'extensions:manage-list' %}" class="dropdown-item"> <a href="{% url 'extensions:manage-list' %}" class="dropdown-item">
<i class="i-puzzle"></i> {% trans 'My Extensions' %} <i class="i-puzzle"></i> {% trans 'My Extensions' %}

View File

@ -1,38 +1,40 @@
{% load i18n common %} {% load i18n common %}
{% if num_pages > 1 %} {% if num_pages > 1 %}
<ol class="pagination"> <ul class="pagination">
{% if pager.has_previous %} {% if pager.has_previous %}
<li> <li class="page-item page-first">
<a rel="prev" href="{{ pager.url|urlparams:pager.previous_page_number }}"> <a href="?page=1">{% trans "First" %}</a>
{{ _('Prev') }} </li>
</a>
<li class="page-item page-prev">
<a href="?page={{ pager.previous_page_number }}" rel="prev"><i class="i-chevron-left"></i> {% trans "Prev" %}</a>
</li> </li>
{% endif %} {% endif %}
{% if pager.dotted_lower %}
<li><a href="{{ pager.url|urlparams:1 }}">{{ 1 }}</a></li>
<li class="skip">&hellip;</li>
{% endif %}
{% for x in pager.page_range %} {% for x in pager.page_range %}
<li {{ x|class_selected:pager.number }}> <li class="page-item {{ x|class_selected:pager.number }}">
<a href="{{ pager.url|urlparams:x }}">{{ x }}</a> <a href="?page={{ x }}">{{ x }}</a>
</li> </li>
{% endfor %} {% endfor %}
{% if pager.dotted_upper %}
<li class="skip">&hellip;</li>
<li><a href="{{ pager.url|urlparams:num_pages }}">{{ num_pages }}</a></li>
{% endif %}
{% if pager.has_next %} {% if pager.has_next %}
<li> <li class="page-item page-next">
<a rel="next" href="{{ pager.url|urlparams:pager.next_page_number }}"> <a href="?page={{ pager.next_page_number }}" rel="next">{% trans "Next" %} <i class="i-chevron-right"></i></a>
{{ _('Next') }} </li>
</a>
<li class="page-item page-last">
<a href="?page={{ num_pages }}">{% trans "Last" %}</a>
</li> </li>
{% endif %} {% endif %}
</ol> </ul>
<div class="num-results">
{% blocktranslate with begin=pager.start_index end=pager.end_index count=count %} {# TODO: add paginator page count if needed #}
Results <strong>{{ begin }}</strong>&ndash;<strong>{{ end }}</strong> {% comment %}
of <strong>{{ count }}</strong> <div class="num-results">
{% endblocktranslate %} {% blocktranslate with begin=pager.start_index end=pager.end_index count=count %}
</div> Results <strong>{{ begin }}</strong>&ndash;<strong>{{ end }}</strong>
of <strong>{{ count }}</strong>
{% endblocktranslate %}
</div>
{% endcomment %}
{% endif %} {% endif %}

View File

@ -76,7 +76,7 @@ def urlparams(url, page, *args, **kwargs):
@register.filter @register.filter
def class_selected(a, b): def class_selected(a, b):
"""Return ``'class="selected"'`` if ``a == b``.""" """Return ``'class="selected"'`` if ``a == b``."""
return mark_safe('class="selected"' if a == b else '') return mark_safe('active' if a == b else '')
@register.simple_tag @register.simple_tag

View File

@ -6,7 +6,7 @@ from mdgen import MarkdownPostProvider
import factory import factory
import factory.fuzzy import factory.fuzzy
from extensions.models import Extension, Version, Tag, Preview from extensions.models import Extension, Version, Tag, Preview, Platform
from ratings.models import Rating from ratings.models import Rating
fake_markdown = Faker() fake_markdown = Faker()
@ -83,6 +83,17 @@ class VersionFactory(DjangoModelFactory):
RatingFactory, size=lambda: random.randint(1, 50), factory_related_name='version' RatingFactory, size=lambda: random.randint(1, 50), factory_related_name='version'
) )
@factory.post_generation
def platforms(self, create, extracted, **kwargs):
if not create:
return
if not extracted:
return
tags = Platform.objects.filter(slug__in=extracted)
self.platforms.add(*tags)
@factory.post_generation @factory.post_generation
def tags(self, create, extracted, **kwargs): def tags(self, create, extracted, **kwargs):
if not create: if not create:

View File

@ -13,13 +13,13 @@ log = logging.getLogger(__name__)
class MaintainerInline(admin.TabularInline): class MaintainerInline(admin.TabularInline):
model = Maintainer model = Maintainer
raw_id_fields = ('user',) autocomplete_fields = ('user',)
extra = 0 extra = 0
class PreviewInline(NoAddDeleteMixin, admin.TabularInline): class PreviewInline(NoAddDeleteMixin, admin.TabularInline):
model = Extension.previews.through model = Extension.previews.through
raw_id_fields = ('file',) autocomplete_fields = ('file',)
show_change_link = True show_change_link = True
can_add = False can_add = False
extra = 0 extra = 0
@ -34,6 +34,8 @@ class VersionInline(NoAddDeleteMixin, admin.TabularInline):
class ExtensionAdmin(admin.ModelAdmin): class ExtensionAdmin(admin.ModelAdmin):
save_on_top = True
date_hierarchy = 'date_created'
list_display = ( list_display = (
'__str__', '__str__',
'type', 'type',
@ -43,13 +45,31 @@ class ExtensionAdmin(admin.ModelAdmin):
'view_count', 'view_count',
'average_score', 'average_score',
) )
list_filter = ('type', 'status') list_filter = (
search_fields = ('id', '^slug', 'name') 'type',
'status',
'is_listed',
'date_approved',
'date_created',
'date_modified',
'date_status_changed',
)
search_fields = (
'id',
'^slug',
'name',
'authors__email',
'authors__full_name',
'authors__username',
'team__name',
'versions__file__user__email',
'versions__file__user__full_name',
'versions__file__user__username',
)
inlines = (MaintainerInline, PreviewInline, VersionInline) inlines = (MaintainerInline, PreviewInline, VersionInline)
readonly_fields = ( readonly_fields = (
'id', 'id',
'type', 'type',
'name',
'slug', 'slug',
'date_created', 'date_created',
'date_status_changed', 'date_status_changed',
@ -61,9 +81,10 @@ class ExtensionAdmin(admin.ModelAdmin):
'download_count', 'download_count',
'view_count', 'view_count',
'website', 'website',
'icon',
'featured_image',
) )
raw_id_fields = ('team',) autocomplete_fields = ('team',)
autocomplete_fields = ('icon', 'featured_image')
fieldsets = ( fieldsets = (
( (
@ -71,7 +92,7 @@ class ExtensionAdmin(admin.ModelAdmin):
{ {
'fields': ( 'fields': (
('team',), ('team',),
('id', 'type'), ('id', 'type', 'extension_id'),
( (
'date_created', 'date_created',
'date_status_changed', 'date_status_changed',
@ -115,6 +136,8 @@ class ExtensionAdmin(admin.ModelAdmin):
class VersionAdmin(admin.ModelAdmin): class VersionAdmin(admin.ModelAdmin):
save_on_top = True
date_hierarchy = 'date_created'
list_display = ( list_display = (
'__str__', '__str__',
'extension', 'extension',
@ -125,12 +148,23 @@ class VersionAdmin(admin.ModelAdmin):
'file__status', 'file__status',
'blender_version_min', 'blender_version_min',
'blender_version_max', 'blender_version_max',
'permissions',
'date_created',
'date_modified',
'licenses', 'licenses',
'tags', 'tags',
'permissions', 'platforms',
) )
search_fields = ('id', 'extension__slug', 'extension__name') search_fields = (
raw_id_fields = ('extension', 'file') 'id',
'extension__slug',
'extension__name',
'extension__extension_id',
'file__user__email',
'file__user__full_name',
'file__user__username',
)
autocomplete_fields = ('extension', 'file')
readonly_fields = ( readonly_fields = (
'id', 'id',
'tagline', 'tagline',
@ -157,6 +191,7 @@ class VersionAdmin(admin.ModelAdmin):
'tags', 'tags',
'file', 'file',
'permissions', 'permissions',
'platforms',
), ),
}, },
), ),
@ -193,6 +228,10 @@ class LicenseAdmin(admin.ModelAdmin):
list_display = ('name', 'slug', 'url') list_display = ('name', 'slug', 'url')
class PlatformAdmin(admin.ModelAdmin):
list_display = ('name', 'slug')
class TagAdmin(admin.ModelAdmin): class TagAdmin(admin.ModelAdmin):
model = Tag model = Tag
list_display = ('name', 'slug', 'type') list_display = ('name', 'slug', 'type')
@ -213,5 +252,6 @@ 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.Platform, PlatformAdmin)
admin.site.register(models.Tag, TagAdmin) admin.site.register(models.Tag, TagAdmin)
admin.site.register(models.VersionPermission, VersionPermissionAdmin) admin.site.register(models.VersionPermission, VersionPermissionAdmin)

View File

@ -20,6 +20,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='extension', model_name='extension',
name='icon', 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'), field=models.OneToOneField(help_text='A 256 x 256 PNG icon representing this extension.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='icon_of', to='files.file'),
), ),
] ]

View File

@ -0,0 +1,38 @@
# Generated by Django 4.2.11 on 2024-05-14 11:06
from django.db import migrations, models
def populate_platforms(apps, schema_editor):
Platform = apps.get_model('extensions', 'Platform')
for p in ["windows-amd64", "windows-arm64", "macos-x86_64", "macos-arm64", "linux-x86_64"]:
Platform(name=p, slug=p).save()
class Migration(migrations.Migration):
dependencies = [
('extensions', '0029_remove_extensionreviewerflags_extension_and_more'),
]
operations = [
migrations.CreateModel(
name='Platform',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date_created', models.DateTimeField(auto_now_add=True)),
('date_modified', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=128, unique=True)),
('slug', models.SlugField(help_text='A platform tag, see https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/', unique=True)),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='version',
name='platforms',
field=models.ManyToManyField(blank=True, related_name='versions', to='extensions.platform'),
),
migrations.RunPython(populate_platforms),
]

View File

@ -99,6 +99,23 @@ class License(CreatedModifiedMixin, models.Model):
return cls.objects.filter(slug__startswith=slug).first() return cls.objects.filter(slug__startswith=slug).first()
class Platform(CreatedModifiedMixin, models.Model):
name = models.CharField(max_length=128, null=False, blank=False, unique=True)
slug = models.SlugField(
blank=False,
null=False,
help_text='A platform tag, see https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/', # noqa
unique=True,
)
def __str__(self) -> str:
return f'{self.name}'
@classmethod
def get_by_slug(cls, slug: str):
return cls.objects.filter(slug__startswith=slug).first()
class ExtensionManager(models.Manager): class ExtensionManager(models.Manager):
@property @property
def listed(self): def listed(self):
@ -162,7 +179,7 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
null=True, null=True,
blank=False, blank=False,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
help_text="A 256 x 256 icon representing this extension.", help_text="A 256 x 256 PNG icon representing this extension.",
) )
previews = FilterableManyToManyField('files.File', through='Preview', related_name='extensions') previews = FilterableManyToManyField('files.File', through='Preview', related_name='extensions')
@ -441,8 +458,9 @@ class VersionManager(models.Manager):
def update_or_create(self, *args, **kwargs): def update_or_create(self, *args, **kwargs):
# Stash the ManyToMany to be created after the Version has a valid ID already # Stash the ManyToMany to be created after the Version has a valid ID already
permissions = kwargs.pop('permissions', [])
licenses = kwargs.pop('licenses', []) licenses = kwargs.pop('licenses', [])
permissions = kwargs.pop('permissions', [])
platforms = kwargs.pop('platforms', [])
tags = kwargs.pop('tags', []) tags = kwargs.pop('tags', [])
version, result = super().update_or_create(*args, **kwargs) version, result = super().update_or_create(*args, **kwargs)
@ -450,6 +468,7 @@ class VersionManager(models.Manager):
# Add the ManyToMany to the already initialized Version # Add the ManyToMany to the already initialized Version
version.set_initial_licenses(licenses) version.set_initial_licenses(licenses)
version.set_initial_permissions(permissions) version.set_initial_permissions(permissions)
version.set_initial_platforms(platforms)
version.set_initial_tags(tags) version.set_initial_tags(tags)
return version, result return version, result
@ -518,6 +537,7 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
) )
permissions = models.ManyToManyField(VersionPermission, related_name='versions', blank=True) permissions = models.ManyToManyField(VersionPermission, related_name='versions', blank=True)
platforms = models.ManyToManyField(Platform, related_name='versions', blank=True)
release_notes = models.TextField(help_text=common.help_texts.markdown, blank=True) release_notes = models.TextField(help_text=common.help_texts.markdown, blank=True)
@ -546,6 +566,14 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
permission = VersionPermission.get_by_slug(permission_name) permission = VersionPermission.get_by_slug(permission_name)
self.permissions.add(permission) self.permissions.add(permission)
def set_initial_platforms(self, _platforms):
if not _platforms:
return
for slug in _platforms:
platform = Platform.get_by_slug(slug)
self.platforms.add(platform)
def set_initial_licenses(self, _licenses): def set_initial_licenses(self, _licenses):
if not _licenses: if not _licenses:
return return

View File

@ -30,27 +30,24 @@ function appendImageUploadForm() {
<div class="previews-list-item"> <div class="previews-list-item">
<div class="align-items-center d-flex previews-list-item-thumbnail ps-3"> <div class="align-items-center d-flex previews-list-item-thumbnail ps-3">
<div class="js-input-img-thumbnail previews-list-item-thumbnail-img" title="Preview"> <div class="js-input-img-thumbnail previews-list-item-thumbnail-img" title="Preview">
<div class="align-items-center d-flex js-input-img-thumbnail-icon justify-content-center"> <div class="ext-preview-thumbnail-icon js-input-img-thumbnail-icon">
<i class="i-image"></i> <i class="i-image"></i>/<i class="i-reel"></i>
</div> </div>
</div> </div>
</div> </div>
<div class="details flex-grow-1"> <div class="details">
<div class="mb-2"> <div>
<label for="${formsetPrefix}-${i}-caption">Caption</label> <label for="${formsetPrefix}-${i}-caption">Image or Video</label>
<input class="js-input-img-caption form-control" id="${formsetPrefix}-${i}-caption" type="text" maxlength="255" name="${formsetPrefix}-${i}-caption" placeholder="Describe the preview"> <input class="js-input-img-caption form-control" id="${formsetPrefix}-${i}-caption" type="text" maxlength="255" name="${formsetPrefix}-${i}-caption" placeholder="Description">
</div> </div>
<div class="align-items-center d-flex justify-content-between"> <div class="details-buttons">
<input accept="image/jpg,image/jpeg,image/png,image/webp,video/mp4" class="form-control form-control-sm js-input-img" id="id_${formsetPrefix}-${i}-source" type="file" name="${formsetPrefix}-${i}-source"> <input accept="image/jpg,image/jpeg,image/png,image/webp,video/mp4" class="form-control form-control-sm js-input-img" id="id_${formsetPrefix}-${i}-source" type="file" name="${formsetPrefix}-${i}-source">
<ul class="pt-0"> <div class="btn-row">
<li> <button class="btn btn-link btn-sm js-btn-remove-img-upload-form"><i class="i-trash"></i> Delete</button>
<button class="btn btn-link btn-sm js-btn-reset-img-upload-form ps-2 pe-0"><i class="i-refresh"></i> Reset</button> <button class="btn btn-link btn-sm js-btn-reset-img-upload-form"><i class="i-refresh"></i> Reset</button>
</li> </div>
<li>
<button class="btn btn-link btn-sm js-btn-remove-img-upload-form ps-2 pe-0"><i class="i-trash"></i> Delete</button>
</li>
</ul>
</div> </div>
<div class="form-text">A JPEG, PNG or WebP image, or an MP4 video.</div>
</div> </div>
</div> </div>
`; `;

View File

@ -18,7 +18,7 @@
</div> </div>
{% endblock hero_breadcrumbs %} {% endblock hero_breadcrumbs %}
<h1>{% include "extensions/components/icon.html" %} {{ extension.name }}</h1> <h1>{% include "extensions/components/icon.html" with classes="icon-lg" %} {{ extension.name }}</h1>
<div class="hero-subtitle"> <div class="hero-subtitle">
{% if latest.tagline %} {% if latest.tagline %}
@ -26,12 +26,6 @@
{% endif %} {% endif %}
<div class="ext-detail-authors"> <div class="ext-detail-authors">
{% if not extension.is_approved %}
<a href="{{ extension.get_review_url }}" class="badge badge-status-{{ extension.get_status_display|slugify }}">
{{ extension.get_status_display }}
</a>
{% endif %}
<a href="{% url 'extensions:by-type' type_slug=extension.type_slug %}">{{ extension.get_type_display }}</a> <a href="{% url 'extensions:by-type' type_slug=extension.type_slug %}">{{ extension.get_type_display }}</a>
{% if extension.team %} {% if extension.team %}
@ -43,6 +37,12 @@
{% elif extension.authors.count %} {% elif extension.authors.count %}
{% trans 'by' %} {% include "extensions/components/authors.html" %} {% trans 'by' %} {% include "extensions/components/authors.html" %}
{% endif %} {% endif %}
{% if not extension.is_approved %}
<span class="badge badge-outline badge-status-{{ extension.get_status_display|slugify }}">
{{ extension.get_status_display }}
</span>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,14 +1,23 @@
{% load common filters static %} {% load common filters static %}
{% static "common/images/no-image_640x360.png" as featured_image_missing %} {% with latest=extension.latest_version type_display=extension.get_type_display %}
{% 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 }}">
<div class="cards-item-thumbnail"> {% with featured_image=extension.featured_image.thumbnail_360p_url %}
<img alt="{{ extension.name }}" src="{{ thumbnail_360p_url }}" title="{{ extension.name }}"> <div class="cards-item-thumbnail">
</div> {% if featured_image %}
<img alt="{{ extension.name }}" src="{{ featured_image }}" title="{{ extension.name }}">
{% else %}
<div class="align-items-center d-flex justify-content-center position-absolute">
<i class="fs-3 {% if type_display == "Theme" %}i-brush{% else %}i-puzzle{% endif %}"></i>
</div>
{% endif %}
</div>
{% endwith %}
</a> </a>
<div class="cards-item-headline">
{{ type_display }}
</div>
<h3 class="cards-item-title"> <h3 class="cards-item-title">
<a href="{{ extension.get_absolute_url }}">{{ extension.name }}</a> <a href="{{ extension.get_absolute_url }}">{{ extension.name }}</a>
</h3> </h3>
@ -48,7 +57,7 @@
{% if show_type %} {% if show_type %}
<li class="ms-auto"> <li class="ms-auto">
{{ extension.get_type_display }} {{ type_display }}
</li> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -4,6 +4,7 @@
<div class="galleria-items{% if preview_count > 5 %} is-many{% endif %}{% if preview_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.file.thumbnail_1080p_url file=preview.file %} {% with thumbnail_1080p_url=preview.file.thumbnail_1080p_url file=preview.file %}
{% with thumbnail_360p_url=preview.file.thumbnail_360p_url file=preview.file %}
<a <a
class="galleria-item js-galleria-item-preview galleria-item-type-{{ file.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 }}"
@ -11,9 +12,10 @@
data-galleria-content-type="{{ file.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.caption }}"> <img src="{{ thumbnail_360p_url }}" alt="{{ preview.caption }}">
</a> </a>
{% endwith %} {% endwith %}
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}

View File

@ -1,3 +1,5 @@
{% load static %} {% 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 %}"> <div class="extension-icon {{ classes}}">
<img alt="{{ extension.get_type_display }} {{ extension.name }}" src="{% if extension.icon.source %}{{ extension.icon.source.url }}{% else %}{% static 'common/images/no-icon.png' %}{% endif %}">
</div>

View File

@ -0,0 +1,10 @@
{% if version.platforms.all %}
<div>
Supported platforms:
<ul>
{% for p in version.platforms.all %}
<li>{{p.name}}</li>
{% endfor %}
</ul>
</div>
{% endif %}

View File

@ -180,7 +180,10 @@
<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=latest %}</dd> <dd>
{% include "extensions/components/blender_version.html" with version=latest %}
{% include "extensions/components/platforms.html" with version=latest %}
</dd>
</div> </div>
</div> </div>

View File

@ -14,27 +14,25 @@
<div class="col-md-12"> <div class="col-md-12">
<h2> <h2>
{% blocktranslate with type=type|lower %} {% blocktranslate with type=type|lower %}
Submit your {{ type }} for review Submit {{ type }} for approval
{% endblocktranslate %} {% endblocktranslate %}
</h2> </h2>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-8 mt-3"> <div class="col-md-8">
<form id="update-extension-form" method="post" enctype="multipart/form-data"> <form id="update-extension-form" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{% with form=form|add_form_classes extension_form=extension_form|add_form_classes %} {% with form=form|add_form_classes extension_form=extension_form|add_form_classes %}
<section class="card p-3 mb-3"> <section class="pb-3">
<p> <p>
{% blocktranslate with type=type|lower %} {% blocktranslate with type=type|lower %}
Please check and edit your {{ type }}'s details before submitting it for review. Please check and edit your {{ type }}'s details before submitting it.
{% endblocktranslate %} {% endblocktranslate %}
</p> <br>
<p>
{% blocktranslate with type=type|lower %} {% blocktranslate with type=type|lower %}
After the {{ type }} is approved by the Blender community or Blender Institute's staff, The {{ type }} will be publicly visible once it is approved by the moderation team.
it will become publicly visible on the platform.
{% endblocktranslate %} {% endblocktranslate %}
</p> </p>
</section> </section>
@ -60,21 +58,22 @@
</section> </section>
<section class="mt-4"> <section class="mt-4">
<h2>{% trans 'Featured image and icon' %}</h2> <h2>{% trans 'Media' %}</h2>
<div class="previews-upload"> <div class="row flex">
<div class="row"> <div class="col-md-6">
<div class="col"> <div class="box p-3">
{% trans "Icon" as icon_label %} {% trans "Featured Image" as featured_image_label %}
{% trans "A 256 x 256 icon representing this extension." as icon_help_text %} {% trans "A JPEG, PNG or WebP image, at least 1920 x 1080 and with aspect ratio of 16:9." as featured_image_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 %} {% 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>
<div class="col-md-6">
<div class="box p-3">
{% trans "Icon" as icon_label %}
{% trans "A 256 x 256 PNG 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>
</div> </div>
</section> </section>

View File

@ -1,10 +1,10 @@
{% extends "common/base.html" %} {% extends "common/base.html" %}
{% load i18n %} {% load i18n %}
{% block page_title %}{% include "extensions/components/listing_title" %}{% endblock page_title %} {% block page_title %}{% include "extensions/components/listing_title.html" %}{% endblock page_title %}
{% block content %} {% block content %}
<div class="row"> <div class="row {% if type == 'Add-ons' %}is-row-add-ons{% elif type == 'Themes' %}is-row-themes{% endif %}">
<div class="col-md-12 my-4"> <div class="col-md-12 my-4">
{% if type %} {% if type %}
<h2>{{ type }}</h2> <h2>{{ type }}</h2>
@ -33,8 +33,10 @@
<div class="box box-filter-sort mb-2 mb-md-0 me-md-3 overflow-hidden p-2"> <div class="box box-filter-sort mb-2 mb-md-0 me-md-3 overflow-hidden p-2">
<div class="box-filter-sort-start d-none h-100 js-box-filter-sort-start position-absolute start-0 top-0 w-5 z-1"></div> <div class="box-filter-sort-start d-none h-100 js-box-filter-sort-start position-absolute start-0 top-0 w-5 z-1"></div>
<div class="btn-row flex-nowrap js-box-filter-sort-btns overflow-x-auto"> <div class="btn-row flex-nowrap js-box-filter-sort-btns overflow-x-auto">
{# TODO: @back-end add conditional link to either Add-ons or Themes pages #} <a class="align-items-center btn btn-sm d-flex {% if not tag %}btn-primary{% endif %}" href="{% url 'extensions:by-type' type_slug=type|slugify %}" title="All">
<a class="align-items-center btn btn-sm d-flex {% if not tag %}btn-primary{% endif %}" href="/add-ons" title="All"> {% if tag %}
{# TODO @back-end: Find a proper way to get the plural tag type to build the URL. #}
<a class="btn btn-sm" href="/{{ tag.get_type_display|slugify }}s/" title="All">
<div> <div>
All All
</div> </div>
@ -42,6 +44,18 @@
1 1
</div> </div>
</a> </a>
{% else %}
<a class="btn btn-sm btn-primary" href="{% url 'extensions:by-type' type_slug=type|slugify %}" title="All">
<div>
All
</div>
<div class="align-items-center bg-primary d-flex h-3 fs-xs justify-content-center ms-2 rounded-circle w-3">
1
</div>
</a>
{% endif %}
{% for list_tag in tags %}
{% for list_tag in tags %} {% for list_tag in tags %}
<a class="align-items-center btn btn-sm d-flex {% if tag == list_tag %}btn-primary{% endif %}" href="{% url "extensions:by-tag" tag_slug=list_tag.slug %}" title="{{ list_tag.name }}"> <a class="align-items-center btn btn-sm d-flex {% if tag == list_tag %}btn-primary{% endif %}" href="{% url "extensions:by-tag" tag_slug=list_tag.slug %}" title="{{ list_tag.name }}">
<div> <div>
@ -86,9 +100,11 @@
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p> <div class="mt-3">
{% blocktranslate %}No extensions found.{% endblocktranslate %} <p class="pt-3 text-center">
</p> {% blocktranslate %}No extensions found.{% endblocktranslate %}
</p>
</div>
{% endif %} {% endif %}
<div class="row"> <div class="row">

View File

@ -2,6 +2,10 @@
{# Upload new preview images #} {# Upload new preview images #}
{% load i18n %} {% load i18n %}
<div id="add-img-container" class="previews-list"> <div id="add-img-container" class="previews-list">
<div class="form-text">
Preview images are displayed in 16:9 ratio.
</div>
{{ add_preview_formset.management_form }} {{ add_preview_formset.management_form }}
{{ add_preview_formset.non_form_errors }} {{ add_preview_formset.non_form_errors }}
{% for newform in add_preview_formset %} {% for newform in add_preview_formset %}
@ -10,22 +14,23 @@
<div class="previews-list-item"> <div class="previews-list-item">
<div class="d-flex previews-list-item-thumbnail ps-3"> <div class="d-flex previews-list-item-thumbnail ps-3">
<div class="js-input-img-thumbnail previews-list-item-thumbnail-img" title="Preview"> <div class="js-input-img-thumbnail previews-list-item-thumbnail-img" title="Preview">
<div class="align-items-center d-flex js-input-img-thumbnail-icon justify-content-center"> <div class="ext-preview-thumbnail-icon js-input-img-thumbnail-icon">
<i class="i-image"></i> <i class="i-image"></i>/<i class="i-reel"></i>
</div> </div>
</div> </div>
</div> </div>
<div class="details flex-grow-1"> <div class="details">
<div class="js-input-img-caption-helper mb-2"> <div class="js-input-img-caption-helper">
{% include "common/components/field.html" with field=inlineform.caption label='Caption' placeholder="Describe the preview" %} {% include "common/components/field.html" with field=inlineform.caption label='Image or Video' placeholder="Description" %}
</div> </div>
<div class="align-items-center d-flex js-input-img-helper justify-content-between"> <div class="details-buttons js-input-img-helper">
{% include "common/components/field.html" with classes="form-control-sm" field=inlineform.source label='File' %} <div>
<ul class="pt-0"> {% trans "A JPEG, PNG or WebP image, or an MP4 video." as preview_help_text %}
<li> {% include "common/components/field.html" with classes="form-control-sm" field=inlineform.source label='File' help_text=preview_help_text %}
<button class="btn btn-link btn-sm js-btn-reset-img-upload-form ps-2 pe-0"><i class="i-refresh"></i> Reset</button> </div>
</li> <div class="btn-row">
</ul> <button class="btn btn-link btn-sm js-btn-reset-img-upload-form"><i class="i-refresh"></i> Reset</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -38,15 +43,7 @@
<div class="col text-right mt-3"> <div class="col text-right mt-3">
<a id="btn-add-img" class="btn"> <a id="btn-add-img" class="btn">
<i class="i-plus"></i> <i class="i-plus"></i>
<span>{% trans 'Add Preview' %}</span> <span>{% trans 'Add Preview Slot' %}</span>
</a> </a>
</div> </div>
</div> </div>
<div class="row">
<div class="col">
<div class="form-text">
Preview images are displayed in 16:9 ratio.
</div>
</div>
</div>

View File

@ -15,18 +15,23 @@
<div class="js-preview-drag drag-widget is-draggable"> <div class="js-preview-drag drag-widget is-draggable">
<i class="i-menu"></i> <i class="i-menu"></i>
<div class="previews-list-item-thumbnail"> <div class="previews-list-item-thumbnail">
<div class="previews-list-item-thumbnail-img" style="background-image: url('{% if file.is_image %}{{ file.source.url }}{% elif file.is_video and file.thumbnail %}{{ file.thumbnail.url }}{% endif %}');" title="Preview"></div> {% with thumbnail_360p_url=file.thumbnail_360p_url file=file %}
<div
class="previews-list-item-thumbnail-img"
style="background-image: url('{% if file.is_image %}{{ thumbnail_360p_url }}{% elif file.is_video and file.thumbnail %}{{ file.thumbnail.url }}{% endif %}');" title="Preview">
</div>
{% endwith %}
</div> </div>
</div> </div>
<div class="details"> <div class="details">
<div> <div>
{% include "common/components/field.html" with field=inlineform.id %} {% include "common/components/field.html" with field=inlineform.id %}
{% include "common/components/field.html" with field=inlineform.caption %} {% include "common/components/field.html" with field=inlineform.caption label=file.get_type_display placeholder="Description" %}
{% include "common/components/field.html" with field=inlineform.position %} {% include "common/components/field.html" with field=inlineform.position %}
</div> </div>
<ul> <ul class="list-inline">
<li> <li>
<a href="{{ inlineform.instance.file.source.url }}" target="_blank"> <a href="{{ inlineform.instance.file.source.url }}" target="_blank" class="text-underline">
<small>Source File</small> <small>Source File</small>
</a> </a>
</li> </li>

View File

@ -2,7 +2,7 @@
{# Handles displaying and editing the featured image #} {# Handles displaying and editing the featured image #}
{% with inlineform=image_form|add_form_classes %} {% with inlineform=image_form|add_form_classes %}
{% with current_file=inlineform.instance.source %} {% with current_file=inlineform.instance.source %}
<div class="align-items-center d-flex justify-content-center mb-2 {{ image_form.prefix }}-preview" <div class="ext-preview-thumbnail-icon mb-2 {{ image_form.prefix }}-preview"
style="background-image: url('{% if current_file %}{{ current_file.url }}{% endif %}');" style="background-image: url('{% if current_file %}{{ current_file.url }}{% endif %}');"
title="{{ label }} of the extension"> title="{{ label }} of the extension">
<i class="i-image js-i-image"></i> <i class="i-image js-i-image"></i>

View File

@ -44,13 +44,13 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% trans "Icon" as icon_label %} {% trans "Icon" as icon_label %}
{% trans "A 256 x 256 icon representing this extension." as icon_help_text %} {% trans "A 256 x 256 PNG 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 %} {% include "extensions/manage/components/set_image.html" with image_form=icon_form label=icon_label help_text=icon_help_text %}
</div> </div>
<div class="col"> <div class="col">
{% trans "Featured image" as featured_image_label %} {% 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 %} {% trans "A JPEG, PNG or WebP image, at least 1920 x 1080 and with 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 %} {% 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>

View File

@ -46,7 +46,7 @@
{% switch is_alpha %} {% switch is_alpha %}
<a class="text-underline" href="https://docs.blender.org/manual/en/dev/extensions/#how-to-create-extensions">guidelines</a>. <a class="text-underline" href="https://docs.blender.org/manual/en/dev/extensions/#how-to-create-extensions">guidelines</a>.
{% else %} {% else %}
<a class="text-underline" href="https://docs.blender.org/manual/en/latest/extensions/#how-to-create-extensions">guidelines</a>. <a class="text-underline" href="https://docs.blender.org/manual/en/dev/extensions/#how-to-create-extensions">guidelines</a>.
{% endswitch %} {% endswitch %}
</strong> </strong>
</li> </li>
@ -60,12 +60,12 @@
{% with form=form|add_form_classes %} {% with form=form|add_form_classes %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% include "common/components/field.html" with field=form.source label='File' %} {% include "common/components/field.html" with field=form.source label='File' classes="js-agree-with-terms-trigger js-submit-form-file-input" %}
</div> </div>
</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 classes="js-agree-with-terms-input" %} {% include "common/components/field.html" with field=form.agreed_with_terms classes="js-agree-with-terms-trigger js-agree-with-terms-checkbox" %}
</div> </div>
</div> </div>
<div class="mt-4"> <div class="mt-4">

View File

@ -56,7 +56,10 @@
<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 %}</dd> <dd>
{% include "extensions/components/blender_version.html" with version=version %}
{% include "extensions/components/platforms.html" with version=version %}
</dd>
</div> </div>
</div> </div>
<div class="dl-row"> <div class="dl-row">
@ -94,7 +97,7 @@
{% endif %} {% endif %}
</section> </section>
<div class="btn-col mb-3"> <div class="btn-col">
<a href="{{ version.download_url }}" download="{{ version.download_name }}" class="btn btn-primary btn-block"> <a href="{{ version.download_url }}" download="{{ version.download_name }}" class="btn btn-primary btn-block">
<i class="i-download"></i> <i class="i-download"></i>
<span>{% trans 'Download' %} v{{ version.version }}</span> <span>{% trans 'Download' %} v{{ version.version }}</span>

View File

@ -15,23 +15,23 @@ def naturaltime_compact(time):
# Take only the first part, e.g. "3 days, 2h ago", becomes " 3d ago" # Take only the first part, e.g. "3 days, 2h ago", becomes " 3d ago"
compact_time = compact_time.split(',')[0] compact_time = compact_time.split(',')[0]
compact_time = compact_time.replace(' seconds ago', ' s') compact_time = compact_time.replace(' ago', '')
compact_time = compact_time.replace('a minute ago', '1 m') compact_time = compact_time.replace(' seconds', ' s')
compact_time = compact_time.replace(' minutes ago', ' m') compact_time = compact_time.replace(' second', ' s')
compact_time = compact_time.replace('an hour ago', '1 h') # Exactly 1 hour. compact_time = compact_time.replace(' minutes', ' m')
compact_time = compact_time.replace(' hours ago', ' h') compact_time = compact_time.replace('a minute', '1 m')
compact_time = compact_time.replace('1 day ago', '1 d') compact_time = compact_time.replace(' minute', ' m')
compact_time = compact_time.replace('1 day', '1 d') compact_time = compact_time.replace(' hours', ' h')
compact_time = compact_time.replace(' days ago', ' d') compact_time = compact_time.replace('an hour', '1 h')
compact_time = compact_time.replace('1 week ago', '1 w') compact_time = compact_time.replace(' hour', ' h')
compact_time = compact_time.replace('1 week', '1 w') compact_time = compact_time.replace(' days', ' d')
compact_time = compact_time.replace(' weeks ago', ' w') compact_time = compact_time.replace(' day', ' d')
compact_time = compact_time.replace('1 month ago', '1 mo') compact_time = compact_time.replace(' weeks', ' w')
compact_time = compact_time.replace('1 month', '1 mo') compact_time = compact_time.replace(' week', ' w')
compact_time = compact_time.replace(' months ago', ' mo') compact_time = compact_time.replace(' months', ' mo')
compact_time = compact_time.replace('1 year ago', '1 y') compact_time = compact_time.replace(' month', ' mo')
compact_time = compact_time.replace('1 year', '1 y') compact_time = compact_time.replace(' years', ' y')
compact_time = compact_time.replace(' years ago', ' y') compact_time = compact_time.replace(' year', ' y')
return compact_time return compact_time

Binary file not shown.

View File

@ -84,6 +84,11 @@ EXPECTED_VALIDATION_ERRORS = {
}, },
'invalid-manifest-toml.zip': {'source': ['Could not parse the manifest file.']}, 'invalid-manifest-toml.zip': {'source': ['Could not parse the manifest file.']},
'invalid-theme-multiple-xmls.zip': {'source': ['A theme should have exactly one XML file.']}, 'invalid-theme-multiple-xmls.zip': {'source': ['A theme should have exactly one XML file.']},
'invalid-missing-wheels.zip': {
'source': [
'A declared wheel is missing in the zip file, expected path: addon/./wheels/test-wheel-whatever.whl'
]
},
} }
POST_DATA = { POST_DATA = {
'preview_set-TOTAL_FORMS': ['0'], 'preview_set-TOTAL_FORMS': ['0'],
@ -169,13 +174,13 @@ class SubmitFileTest(TestCase):
user = UserFactory() user = UserFactory()
self.client.force_login(user) self.client.force_login(user)
for test_archive, extected_errors in EXPECTED_VALIDATION_ERRORS.items(): for test_archive, expected_errors in EXPECTED_VALIDATION_ERRORS.items():
with self.subTest(test_archive=test_archive): with self.subTest(test_archive=test_archive):
with open(TEST_FILES_DIR / test_archive, 'rb') as fp: with open(TEST_FILES_DIR / test_archive, 'rb') as fp:
response = self.client.post(self.url, {'source': fp, 'agreed_with_terms': True}) response = self.client.post(self.url, {'source': fp, 'agreed_with_terms': True})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertDictEqual(response.context['form'].errors, extected_errors) self.assertDictEqual(response.context['form'].errors, expected_errors)
def test_addon_without_top_level_directory(self): def test_addon_without_top_level_directory(self):
self.assertEqual(Extension.objects.count(), 0) self.assertEqual(Extension.objects.count(), 0)

View File

@ -7,6 +7,7 @@ from common.tests.factories.extensions import create_version, create_approved_ve
from common.tests.factories.users import UserFactory from common.tests.factories.users import UserFactory
from extensions.models import Extension, Version from extensions.models import Extension, Version
from files.models import File from files.models import File
from teams.models import Team
def _create_extension(): def _create_extension():
@ -112,6 +113,18 @@ class ApiViewsTest(_BaseTestCase):
).json() ).json()
self.assertEqual(len(json3['data']), 3) self.assertEqual(len(json3['data']), 3)
def test_platform_filter(self):
create_approved_version(platforms=['windows-amd64'])
create_approved_version(platforms=['windows-arm64'])
create_approved_version()
url = reverse('extensions:api')
json = self.client.get(
url + '?platform=windows-amd64',
HTTP_ACCEPT='application/json',
).json()
self.assertEqual(len(json['data']), 2)
def test_blender_version_filter_latest_not_max_version(self): def test_blender_version_filter_latest_not_max_version(self):
version = create_approved_version(blender_version_min='4.0.1') version = create_approved_version(blender_version_min='4.0.1')
version.date_created version.date_created
@ -144,6 +157,20 @@ class ApiViewsTest(_BaseTestCase):
# we are expecting the latest matching, not the maximum version # we are expecting the latest matching, not the maximum version
self.assertEqual(json['data'][0]['version'], '1.0.1') self.assertEqual(json['data'][0]['version'], '1.0.1')
def test_maintaner_is_team(self):
version = create_approved_version(blender_version_min='4.0.1')
team = Team(name='test team', slug='test-team')
team.save()
version.extension.team = team
version.extension.save()
url = reverse('extensions:api')
json = self.client.get(
url,
HTTP_ACCEPT='application/json',
).json()
self.assertEqual(json['data'][0]['maintainer'], 'test team')
class ExtensionDetailViewTest(_BaseTestCase): class ExtensionDetailViewTest(_BaseTestCase):
def test_cannot_view_unlisted_extension_anonymously(self): def test_cannot_view_unlisted_extension_anonymously(self):

View File

@ -7,7 +7,7 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from common.compare import is_in_version_range, version from common.compare import is_in_version_range, version
from extensions.models import Extension from extensions.models import Extension, Platform
from extensions.utils import clean_json_dictionary_from_optional_fields from extensions.utils import clean_json_dictionary_from_optional_fields
@ -20,7 +20,10 @@ log = logging.getLogger(__name__)
class ListedExtensionsSerializer(serializers.ModelSerializer): class ListedExtensionsSerializer(serializers.ModelSerializer):
error_messages = { error_messages = {
"invalid_version": "Invalid version: use full semantic versioning like 4.2.0." "invalid_blender_version": "Invalid blender_version: use full semantic versioning like "
"4.2.0.",
"invalid_platform": "Invalid platform: use notation specified in "
"https://developer.blender.org/docs/features/extensions/schema/1.0.0/",
} }
class Meta: class Meta:
@ -30,16 +33,22 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request', None) self.request = kwargs.pop('request', None)
self.blender_version = kwargs.pop('blender_version', None) self.blender_version = kwargs.pop('blender_version', None)
self.platform = kwargs.pop('platform', None)
self._validate() self._validate()
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def _validate(self): def _validate(self):
if self.blender_version is None: if self.blender_version:
return try:
try: version(self.blender_version)
version(self.blender_version) except ValidationError:
except ValidationError: self.fail('invalid_blender_version')
self.fail('invalid_version') if self.platform:
# FIXME change to an in-memory lookup?
try:
Platform.objects.get(slug=self.platform)
except Platform.DoesNotExist:
self.fail('invalid_platform')
def to_representation(self, instance): def to_representation(self, instance):
matching_version = None matching_version = None
@ -52,18 +61,19 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
if not versions: if not versions:
return None return None
versions = sorted(versions, key=lambda v: v.date_created, reverse=True) versions = sorted(versions, key=lambda v: v.date_created, reverse=True)
if self.blender_version: for v in versions:
for v in versions: if self.blender_version and not is_in_version_range(
if is_in_version_range( self.blender_version,
self.blender_version, v.blender_version_min,
v.blender_version_min, v.blender_version_max,
v.blender_version_max, ):
): continue
matching_version = v platform_slugs = set(p.slug for p in v.platforms.all())
break # empty platforms field matches any platform filter
else: if self.platform and not (not platform_slugs or self.platform in platform_slugs):
# same as latest_version, but without triggering a new queryset continue
matching_version = versions[0] matching_version = v
break
if not matching_version: if not matching_version:
return None return None
@ -82,9 +92,10 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
'blender_version_max': matching_version.blender_version_max, 'blender_version_max': matching_version.blender_version_max,
'website': self.request.build_absolute_uri(instance.get_absolute_url()), 'website': self.request.build_absolute_uri(instance.get_absolute_url()),
# avoid triggering additional db queries, reuse the prefetched queryset # avoid triggering additional db queries, reuse the prefetched queryset
'maintainer': str(instance.authors.all()[0]), 'maintainer': instance.team and instance.team.name or str(instance.authors.all()[0]),
'license': [license_iter.slug for license_iter in matching_version.licenses.all()], 'license': [license_iter.slug for license_iter in matching_version.licenses.all()],
'permissions': [permission.slug for permission in matching_version.permissions.all()], 'permissions': [permission.slug for permission in matching_version.permissions.all()],
'platforms': [platform.slug for platform in matching_version.platforms.all()],
# TODO: handle copyright # TODO: handle copyright
'tags': [str(tag) for tag in matching_version.tags.all()], 'tags': [str(tag) for tag in matching_version.tags.all()],
} }
@ -101,21 +112,33 @@ class ExtensionsAPIView(APIView):
name="blender_version", name="blender_version",
description=("Blender version to check for compatibility"), description=("Blender version to check for compatibility"),
type=str, type=str,
) ),
OpenApiParameter(
name="platform",
description=("Platform to check for compatibility"),
type=str,
),
] ]
) )
def get(self, request): def get(self, request):
blender_version = request.GET.get('blender_version') blender_version = request.GET.get('blender_version')
platform = request.GET.get('platform')
qs = Extension.objects.listed.prefetch_related( qs = Extension.objects.listed.prefetch_related(
'authors', 'authors',
'team',
'versions', 'versions',
'versions__file', 'versions__file',
'versions__licenses', 'versions__licenses',
'versions__permissions', 'versions__permissions',
'versions__platforms',
'versions__tags', 'versions__tags',
).all() ).all()
serializer = self.serializer_class( serializer = self.serializer_class(
qs, blender_version=blender_version, request=request, many=True qs,
blender_version=blender_version,
platform=platform,
request=request,
many=True,
) )
data = [e for e in serializer.data if e is not None] data = [e for e in serializer.data if e is not None]
return Response( return Response(

View File

@ -45,6 +45,7 @@ class ExtensionDetailView(ExtensionQuerysetMixin, DetailView):
'versions__file', 'versions__file',
'versions__file__validation', 'versions__file__validation',
'versions__permissions', 'versions__permissions',
'versions__platforms',
) )
def get_object(self, queryset=None): def get_object(self, queryset=None):

View File

@ -29,7 +29,7 @@ class ListedExtensionsView(ListView):
class HomeView(ListedExtensionsView): class HomeView(ListedExtensionsView):
paginate_by = 15 paginate_by = 16
template_name = 'extensions/home.html' template_name = 'extensions/home.html'
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
@ -69,7 +69,7 @@ def extension_version_download(request, type_slug, slug, version):
class SearchView(ListedExtensionsView): class SearchView(ListedExtensionsView):
paginate_by = 15 paginate_by = 16
template_name = 'extensions/list.html' template_name = 'extensions/list.html'
def _get_type_id_by_slug(self): def _get_type_id_by_slug(self):
@ -95,7 +95,7 @@ class SearchView(ListedExtensionsView):
qs = self.request.GET['q'].split() qs = self.request.GET['q'].split()
search_query = Q() search_query = Q()
for token in qs: for token in qs:
search_query |= ( search_query &= (
Q(slug__icontains=token) Q(slug__icontains=token)
| Q(name__icontains=token) | Q(name__icontains=token)
| Q(description__icontains=token) | Q(description__icontains=token)

View File

@ -63,6 +63,7 @@ class FileAdmin(admin.ModelAdmin):
kwargs.update({'help_texts': {'metadata': help_text}}) kwargs.update({'help_texts': {'metadata': help_text}})
return super().get_form(request, obj, **kwargs) return super().get_form(request, obj, **kwargs)
date_hierarchy = 'date_created'
view_on_site = False view_on_site = False
save_on_top = True save_on_top = True
@ -70,8 +71,10 @@ class FileAdmin(admin.ModelAdmin):
'validation__is_ok', 'validation__is_ok',
'type', 'type',
'status', 'status',
'date_status_changed',
'date_approved', 'date_approved',
'date_created',
'date_modified',
'date_status_changed',
('extension', admin.EmptyFieldListFilter), ('extension', admin.EmptyFieldListFilter),
) )
list_display = ( list_display = (
@ -84,8 +87,9 @@ class FileAdmin(admin.ModelAdmin):
'is_ok', 'is_ok',
) )
list_select_related = ('version__extension', 'user') list_select_related = ('version__extension', 'user', 'extension', 'version', 'validation')
autocomplete_fields = ['user']
readonly_fields = ( readonly_fields = (
'id', 'id',
'date_created', 'date_created',
@ -96,7 +100,6 @@ class FileAdmin(admin.ModelAdmin):
'thumbnails', 'thumbnails',
'thumbnail', 'thumbnail',
'type', 'type',
'user',
'original_hash', 'original_hash',
'original_name', 'original_name',
'hash', 'hash',
@ -110,6 +113,9 @@ class FileAdmin(admin.ModelAdmin):
'original_name', 'original_name',
'hash', 'hash',
'source', 'source',
'user__email',
'user__full_name',
'user__username',
) )
fieldsets = ( fieldsets = (

View File

@ -39,6 +39,7 @@ class FileForm(forms.ModelForm):
'missing_or_multiple_theme_xml': _('A theme should have exactly one XML file.'), 'missing_or_multiple_theme_xml': _('A theme should have exactly one XML file.'),
'invalid_zip_archive': msg_only_zip_files, 'invalid_zip_archive': msg_only_zip_files,
'missing_manifest_toml': _('The manifest file is missing.'), 'missing_manifest_toml': _('The manifest file is missing.'),
'missing_wheel': _('A declared wheel is missing in the zip file, expected path: %(path)s'),
} }
class Meta: class Meta:
@ -143,7 +144,15 @@ class FileForm(forms.ModelForm):
manifest, error_codes = utils.read_manifest_from_zip(file_path) manifest, error_codes = utils.read_manifest_from_zip(file_path)
for code in error_codes: for code in error_codes:
errors.append(forms.ValidationError(self.error_messages[code])) if isinstance(code, dict):
errors.append(
forms.ValidationError(
self.error_messages[code['code']],
params=code['params'],
)
)
else:
errors.append(forms.ValidationError(self.error_messages[code]))
if errors: if errors:
self.add_error('source', errors) self.add_error('source', errors)

View File

@ -108,6 +108,9 @@ def read_manifest_from_zip(archive_path):
""" """
manifest_name = 'blender_manifest.toml' manifest_name = 'blender_manifest.toml'
error_codes = [] error_codes = []
file_list = []
manifest_content = None
try: try:
with zipfile.ZipFile(archive_path) as myzip: with zipfile.ZipFile(archive_path) as myzip:
bad_file = myzip.testzip() bad_file = myzip.testzip()
@ -129,34 +132,45 @@ def read_manifest_from_zip(archive_path):
error_codes.append('invalid_manifest_path') error_codes.append('invalid_manifest_path')
return None, error_codes return None, error_codes
# Extract the file content
with myzip.open(manifest_filepath) as file_content: with myzip.open(manifest_filepath) as file_content:
toml_content = toml.loads(file_content.read().decode()) manifest_content = file_content.read().decode()
# If manifest was parsed successfully, do additional type-specific validation
type_slug = toml_content['type']
if type_slug == 'theme':
theme_xmls = filter_paths_by_ext(file_list, '.xml')
if len(list(theme_xmls)) != 1:
error_codes.append('missing_or_multiple_theme_xml')
elif type_slug == 'add-on':
# __init__.py is expected to be next to the manifest
expected_init_path = os.path.join(os.path.dirname(manifest_filepath), '__init__.py')
init_filepath = find_exact_path(file_list, expected_init_path)
if not init_filepath:
error_codes.append('invalid_missing_init')
return toml_content, error_codes
except toml.decoder.TomlDecodeError as e:
logger.error(f"Manifest Error: {e.msg}")
error_codes.append('invalid_manifest_toml')
except Exception as e: except Exception as e:
logger.error(f"Error extracting from archive: {e}") logger.error(f"Error extracting from archive: {e}")
error_codes.append('invalid_zip_archive') error_codes.append('invalid_zip_archive')
return None, error_codes
return None, error_codes try:
toml_content = toml.loads(manifest_content)
except toml.decoder.TomlDecodeError as e:
logger.error(f"Manifest Error: {e.msg}")
error_codes.append('invalid_manifest_toml')
return None, error_codes
# If manifest was parsed successfully, do additional type-specific validation
type_slug = toml_content['type']
if type_slug == 'theme':
theme_xmls = filter_paths_by_ext(file_list, '.xml')
if len(list(theme_xmls)) != 1:
error_codes.append('missing_or_multiple_theme_xml')
elif type_slug == 'add-on':
# __init__.py is expected to be next to the manifest
expected_init_path = os.path.join(os.path.dirname(manifest_filepath), '__init__.py')
init_filepath = find_exact_path(file_list, expected_init_path)
if not init_filepath:
error_codes.append('invalid_missing_init')
wheels = toml_content.get('wheels')
if wheels:
for wheel in wheels:
expected_wheel_path = os.path.join(os.path.dirname(manifest_filepath), wheel)
wheel_filepath = find_exact_path(file_list, expected_wheel_path)
if not wheel_filepath:
error_codes.append(
{'code': 'missing_wheel', 'params': {'path': expected_wheel_path}}
)
return toml_content, error_codes
def guess_mimetype_from_ext(file_name: str) -> str: def guess_mimetype_from_ext(file_name: str) -> str:

View File

@ -7,7 +7,13 @@ from django.utils.deconstruct import deconstructible
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from extensions.models import Extension, License, VersionPermission, Tag from extensions.models import (
Extension,
License,
Platform,
Tag,
VersionPermission,
)
from constants.base import EXTENSION_TYPE_SLUGS_SINGULAR, EXTENSION_TYPE_CHOICES from constants.base import EXTENSION_TYPE_SLUGS_SINGULAR, EXTENSION_TYPE_CHOICES
from files.utils import guess_mimetype_from_ext, guess_mimetype_from_content from files.utils import guess_mimetype_from_ext, guess_mimetype_from_content
@ -214,7 +220,7 @@ class LicenseValidator(ListValidator):
error_message = mark_safe( error_message = mark_safe(
f'Manifest value error: <code>license</code> expects a list of ' f'Manifest value error: <code>license</code> expects a list of '
f'<a href="https://docs.blender.org/manual/en/dev/extensions/licenses.html">' f'<a href="https://docs.blender.org/manual/en/dev/advanced/extensions/licenses.html">'
f'supported licenses</a>. e.g., {cls.example}.' f'supported licenses</a>. e.g., {cls.example}.'
) )
if unknown_value: if unknown_value:
@ -247,7 +253,7 @@ class TagsValidatorBase:
error_message = mark_safe( error_message = mark_safe(
f'Manifest value error: <code>tags</code> expects a list of ' f'Manifest value error: <code>tags</code> expects a list of '
f'<a href="https://docs.blender.org/manual/en/dev/extensions/tags.html" ' f'<a href="https://docs.blender.org/manual/en/dev/advanced/extensions/tags.html" '
f'target="_blank"> supported {type_name} tags</a>. e.g., {cls.example}. ' f'target="_blank"> supported {type_name} tags</a>. e.g., {cls.example}. '
) )
if unknown_value: if unknown_value:
@ -361,6 +367,53 @@ class PermissionsValidator:
return mark_safe(error_message) return mark_safe(error_message)
class PlatformsValidator:
"""See https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/"""
example = ["windows-amd64", "linux-x86_64"]
@classmethod
def validate(cls, *, name: str, value: list[str], manifest: dict) -> str:
"""Return error message if there is any license that is not accepted by the site"""
is_error = False
error_message = ""
unknown_value = None
if type(value) != list:
is_error = True
else:
for platform in value:
if Platform.get_by_slug(platform):
continue
is_error = True
unknown_value = platform
break
if not is_error:
return
error_message = mark_safe(
f'Manifest value error: <code>platforms</code> expects a list of '
f'supported platforms. e.g., {cls.example}.'
)
if unknown_value:
error_message += mark_safe(f' Unknown value: {escape(unknown_value)}.')
return error_message
class WheelsValidator:
example = ["./wheels/mywheel-v1.0.0-py3-none-any.whl"]
@classmethod
def validate(cls, *, name: str, value: list[str], manifest: dict) -> str:
if type(value) != list:
return mark_safe(
f'Manifest value error: <code>wheels</code> expects a list of '
f'wheel files . e.g., {cls.example}.'
)
class VersionValidator: class VersionValidator:
example = '1.0.0' example = '1.0.0'
@ -396,7 +449,7 @@ class SchemaVersionValidator(VersionValidator):
# TODO make a user manual page with the list of the different schemas. # TODO make a user manual page with the list of the different schemas.
return mark_safe( return mark_safe(
f'Manifest value error: <code>schema</code> version ({escape(value)}) ' f'Manifest value error: <code>schema</code> version ({escape(value)}) '
f'<a href="https://docs.blender.org/manual/en/dev/extensions/' f'<a href="https://docs.blender.org/manual/en/dev/advanced/extensions/'
f'getting_started.html#manifest" target="_blank">not supported</a>.' f'getting_started.html#manifest" target="_blank">not supported</a>.'
) )
@ -489,10 +542,12 @@ class ManifestValidator:
} }
optional_fields = { optional_fields = {
'blender_version_max': VersionMaxValidator, 'blender_version_max': VersionMaxValidator,
'website': StringValidator,
'copyright': ListValidator, 'copyright': ListValidator,
'permissions': PermissionsValidator, 'permissions': PermissionsValidator,
'platforms': PlatformsValidator,
'tags': TagsValidator, 'tags': TagsValidator,
'website': StringValidator,
'wheels': WheelsValidator,
} }
all_fields = {**mandatory_fields, **optional_fields} all_fields = {**mandatory_fields, **optional_fields}

View File

@ -64,4 +64,6 @@
{% trans 'You have no notifications' %} {% trans 'You have no notifications' %}
</p> </p>
{% endif %} {% endif %}
{{ page_obj|paginator }}
{% endblock content %} {% endblock content %}

View File

@ -13,7 +13,7 @@ from notifications.models import Notification
class NotificationsView(LoginRequiredMixin, ListView): class NotificationsView(LoginRequiredMixin, ListView):
model = Notification model = Notification
paginate_by = 10 paginate_by = 20
def get_queryset(self): def get_queryset(self):
return Notification.objects.filter(recipient=self.request.user).order_by('-id') return Notification.objects.filter(recipient=self.request.user).order_by('-id')

View File

@ -2,7 +2,7 @@
- name: Coming up next - name: Coming up next
ansible.builtin.debug: ansible.builtin.debug:
msg: Running {{ playbook_type }} of {{ source_url }}@{{ branch }} to {{ env }} ({{ domain }}) msg: Running {{ playbook_type }} of {{ source_url }}@{{ branch }} to {{ env }} ({{ domain }})
when: 'playbook_type is defined' when: playbook_type is defined
tags: tags:
- always - always
@ -16,7 +16,7 @@
{% if not ansible_check_mode and not ansible_diff_mode %} and {% endif %}{% if not ansible_check_mode %}--check for dry run{% endif %}. {% if not ansible_check_mode and not ansible_diff_mode %} and {% endif %}{% if not ansible_check_mode %}--check for dry run{% endif %}.
{% endif %} {% endif %}
Press return to continue. Press Ctrl+c and then "a" to abort. Press return to continue. Press Ctrl+c and then "a" to abort.
when: 'playbook_type is defined' when: playbook_type is defined
tags: tags:
- always - always

View File

@ -1,10 +1,10 @@
--- ---
- name: "Writing {{ conf_d }}{{ conf_f }}" - name: Writing {{ conf_d }}{{ conf_f }}
vars: vars:
conf_d: /etc/nginx/conf.d/ conf_d: /etc/nginx/conf.d/
conf_f: log-format-upstreaminfo.conf conf_f: log-format-upstreaminfo.conf
ansible.builtin.template: ansible.builtin.template:
src: "templates/nginx/conf.d/{{ conf_f }}" src: templates/nginx/conf.d/{{ conf_f }}
dest: "{{ conf_d }}{{ conf_f }}" dest: "{{ conf_d }}{{ conf_f }}"
backup: true backup: true
mode: 0664 mode: 0664

View File

@ -24,6 +24,28 @@
tags: tags:
- deps - deps
- name: Configuring ClamAV
ansible.builtin.lineinfile:
path: /etc/clamav/clamd.conf
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
state: present
backup: true
with_items:
- regexp: ^#*\s*MaxScanSize\s
line: MaxScanSize 200M
- regexp: ^#*\s*MaxFileSize\s
line: MaxFileSize 200M
- regexp: ^#*\s*PCREMaxFileSize\s
line: PCREMaxFileSize 200M
- regexp: ^#*\s*StreamMaxLength\s
line: StreamMaxLength 200M
notify:
- Restart ClamAV daemon
tags:
- deps
- clamav
- name: Creating user "{{ user }}:{{ group }}" - name: Creating user "{{ user }}:{{ group }}"
ansible.builtin.user: ansible.builtin.user:
name: "{{ user }}" name: "{{ user }}"
@ -60,3 +82,10 @@
- import_tasks: tasks/setup_other_services.yaml - import_tasks: tasks/setup_other_services.yaml
tags: tags:
- services - services
handlers:
- name: Restart ClamAV daemon
ansible.builtin.systemd:
name: clamav-daemon.service
state: restarted
enabled: true

View File

@ -5,7 +5,7 @@
owner: root owner: root
group: root group: root
state: directory state: directory
mode: '0755' mode: "0755"
tags: tags:
- uwsgi - uwsgi
- name: Copying uWSGI config files - name: Copying uWSGI config files

View File

@ -9,7 +9,7 @@ max_requests: 1000
max_requests_jitter: 50 max_requests_jitter: 50
port: 8200 port: 8200
workers: 2 workers: 2
client_max_body_size: "50m" client_max_body_size: "200m"
python_version: "3.10" python_version: "3.10"
delete_venv: false # set to true if venv has to be re-created from scratch delete_venv: false # set to true if venv has to be re-created from scratch

View File

@ -1,7 +1,10 @@
{% load filters %} {% load filters %}
<tr> <tr>
<td class="ext-review-list-type">{{ extension.get_type_display }}</td> <td class="ext-review-list-type">{{ extension.get_type_display }}</td>
<td> <td class="ext-review-list-name">
<a href="{{ extension.get_review_url }}">
{% include "extensions/components/icon.html" %}
</a>
<a href="{{ extension.get_review_url }}"> <a href="{{ extension.get_review_url }}">
{{ extension.name }} {{ extension.name }}
</a> </a>

View File

@ -101,31 +101,8 @@
<ul class="activity-list"> <ul class="activity-list">
{% for activity in review_activity %} {% for activity in review_activity %}
<li id="activity-{{ activity.id }}"> <li id="activity-{{ activity.id }}">
{% if activity.type in status_change_types %}
<div class="activity-item activity-status-change activity-status-{{ activity.get_type_display|slugify }}">
<i class="activity-icon i-activity-{{ activity.get_type_display|slugify }}"></i>
<a href="{% url "extensions:by-author" user_id=activity.user.pk %}">
{% include "users/components/profile_display.html" with user=activity.user classes="" %}
</a>
<a href="{% url "extensions:by-author" user_id=activity.user.pk %}"><strong>{{ activity.user }}</strong></a>
changed review status to
<span class="badge badge-status-{{ activity.get_type_display|slugify }}">
{{ activity.get_type_display }}
</span>
<a href="#activity-{{ activity.id }}" title="{{ activity.date_created }}">
{{ activity.date_created|naturaltime_compact }}
</a>
</div>
{% endif %}
{# Comments. #}
{% if activity.message %}
<article class="activity-item comment-card"> <article class="activity-item comment-card">
<i class="activity-icon i-comment"></i> <i class="activity-icon {% if activity.type in status_change_types %}i-activity-{{ activity.get_type_display|slugify }}{% else %}i-comment{% endif %}"></i>
<aside> <aside>
<a href="{% url "extensions:by-author" user_id=activity.user.pk %}"> <a href="{% url "extensions:by-author" user_id=activity.user.pk %}">
{% include "users/components/profile_display.html" with user=activity.user classes="" %} {% include "users/components/profile_display.html" with user=activity.user classes="" %}
@ -138,6 +115,12 @@
<a href="{% url "extensions:by-author" user_id=activity.user.pk %}"> <a href="{% url "extensions:by-author" user_id=activity.user.pk %}">
{{ activity.user }} {{ activity.user }}
</a> </a>
{% if activity.type in status_change_types %}
changed review status to
<span class="badge badge-status-{{ activity.get_type_display|slugify }}">
{{ activity.get_type_display }}
</span>
{% endif %}
</li> </li>
<li class="ms-auto"> <li class="ms-auto">
<a href="#activity-{{ activity.id }}" title="{{ activity.date_created }}"> <a href="#activity-{{ activity.id }}" title="{{ activity.date_created }}">
@ -146,10 +129,10 @@
</li> </li>
</ul> </ul>
</header> </header>
<div>{{ activity.message|markdown }}</div> <div>
{{ activity.message|markdown }}</div>
</div> </div>
</article> </article>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -1,5 +1,5 @@
{% extends "common/base.html" %} {% extends "common/base.html" %}
{% load i18n humanize filters %} {% load i18n humanize filters common %}
{% block page_title %}Approval queue{% endblock page_title %} {% block page_title %}Approval queue{% endblock page_title %}
@ -45,5 +45,6 @@
{% else %} {% else %}
<p>{% trans "No extensions to review." %}</p> <p>{% trans "No extensions to review." %}</p>
{% endif %} {% endif %}
{{ page_obj|paginator }}
</section> </section>
{% endblock content %} {% endblock content %}

View File

@ -11,6 +11,7 @@ class TeamsUsersInline(admin.TabularInline):
@admin.register(teams.models.Team) @admin.register(teams.models.Team)
class TeamAdmin(admin.ModelAdmin): class TeamAdmin(admin.ModelAdmin):
save_on_top = True
list_display = ('name', 'slug', 'user_count', 'date_created') list_display = ('name', 'slug', 'user_count', 'date_created')
list_display_links = ['name'] list_display_links = ['name']
list_filter = ['date_created'] list_filter = ['date_created']

View File

@ -13,6 +13,7 @@ class UserAdmin(auth_admin.UserAdmin):
list_display_links = ['username'] list_display_links = ['username']
list_filter = auth_admin.UserAdmin.list_filter + ( list_filter = auth_admin.UserAdmin.list_filter + (
'groups__name',
'date_joined', 'date_joined',
'confirmed_email_at', 'confirmed_email_at',
'date_deletion_requested', 'date_deletion_requested',