Intitial teams support #147

Merged
Oleg-Komarov merged 34 commits from teams-support into main 2024-05-23 19:43:54 +02:00
71 changed files with 1576 additions and 515 deletions
Showing only changes of commit 8d1e50adce - Show all commits

View File

@ -17,6 +17,8 @@ Blender Extensions platform, heavily inspired by Mozilla's https://github.com/mo
# Requirements # Requirements
* Python 3.10 * Python 3.10
* `libmagic`: `sudo apt-get install libmagic1` in Debian/Ubuntu, `brew install libmagic` on OSX.
## Development ## Development

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

View File

@ -2,6 +2,7 @@ import logging
import random import random
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
import faker
from common.tests.factories.extensions import create_approved_version, create_version from common.tests.factories.extensions import create_approved_version, create_version
from common.tests.factories.files import FileFactory from common.tests.factories.files import FileFactory
@ -11,6 +12,8 @@ from constants.version_permissions import VERSION_PERMISSION_FILE, VERSION_PERMI
from constants.licenses import LICENSE_GPL2, LICENSE_GPL3 from constants.licenses import LICENSE_GPL2, LICENSE_GPL3
from extensions.models import Extension, Tag from extensions.models import Extension, Tag
_faker = faker.Faker()
FILE_SOURCES = { FILE_SOURCES = {
"blender-kitsu": { "blender-kitsu": {
"file": 'files/ed/ed656b177b01999e6fcd0e37c34ced471ef88c89db578f337e40958553dca5d2.zip', "file": 'files/ed/ed656b177b01999e6fcd0e37c34ced471ef88c89db578f337e40958553dca5d2.zip',
@ -94,6 +97,7 @@ class Command(BaseCommand):
original_hash=FILE_SOURCES["blender-kitsu"]["hash"], original_hash=FILE_SOURCES["blender-kitsu"]["hash"],
size_bytes=FILE_SOURCES["blender-kitsu"]["size"], size_bytes=FILE_SOURCES["blender-kitsu"]["size"],
status=File.STATUSES.APPROVED, status=File.STATUSES.APPROVED,
metadata={'name': 'Blender Kitsu'},
), ),
extension__previews=[ extension__previews=[
FileFactory( FileFactory(
@ -111,9 +115,12 @@ class Command(BaseCommand):
# Create a few publicly listed extensions # Create a few publicly listed extensions
for i in range(10): for i in range(10):
extension__type = random.choice(Extension.TYPES)[0] extension__type = random.choice(Extension.TYPES)[0]
name = _faker.catch_phrase()
version = create_approved_version( version = create_approved_version(
file__status=File.STATUSES.APPROVED, file__status=File.STATUSES.APPROVED,
file__metadata={'name': name},
# extension__status=Extension.STATUSES.APPROVED, # extension__status=Extension.STATUSES.APPROVED,
extension__name=name,
extension__type=extension__type, extension__type=extension__type,
tags=random.sample(tags[extension__type], k=1), tags=random.sample(tags[extension__type], k=1),
extension__previews=[ extension__previews=[

View File

@ -0,0 +1,141 @@
import logging
import random
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import signals
import factory
from common.tests.factories.extensions import create_version, RatingFactory
from common.tests.factories.files import FileFactory
from files.models import File
from constants.licenses import LICENSE_GPL2, LICENSE_GPL3
from extensions.models import Extension, Tag
from utils import slugify, chunked
import faker
_faker = faker.Faker()
FILE_SOURCES = {
"blender-kitsu": {
"file": 'files/ed/ed656b177b01999e6fcd0e37c34ced471ef88c89db578f337e40958553dca5d2.zip',
"hash": "sha256:3d2972a6f6482e3c502273434ca53eec0c5ab3dae628b55c101c95a4bc4e15b2",
"size": 856650,
},
}
PREVIEW_SOURCES = (
'images/b0/b03fa981527593fbe15b28cf37c020220c3d83021999eab036b87f3bca9c9168.png',
'images/be/bee9e018e80aee1176019a7ef979a3305381b362d5dedc99b9329a55bfa921b9.png',
'images/cf/cfe6569d16ed50d3beffc13ccb3c4375fe3b0fd734197daae3c80bc6d496430f.png',
'images/f4/f4c377d3518b6e17865244168c44b29f61c702b01a34aeb3559f492b1b744e50.png',
'images/dc/dc8d57c6af69305b2005c4c7c71eff3db855593942bebf3c311b4d3515e955f0.png',
'images/09/091b7bc282e8137a79c46f23d5dea4a97ece3bc2f9f0ca9c3ff013727c22736b.png',
)
EXAMPLE_DESCRIPTION = '''# blender-kitsu
blender-kitsu is a Blender Add-on to interact with Kitsu from within Blender.
It also has features that are not directly related to Kitsu but support certain
aspects of the Blender Studio Pipeline.
## Table of Contents
- [Installation](#installation)
- [How to get started](#how-to-get-started)
- [Features](#features)
- [Sequence Editor](#sequence-editor)
- [Context](#context)
- [Troubleshoot](#troubleshoot)
## Installation
Download or clone this repository.
In the root project folder you will find the 'blender_kitsu' folder.
Place this folder in your Blender addons directory or create a sym link to it.
## How to get started
After installing you need to setup the addon preferences to fit your environment.
In order to be able to log in to Kitsu you need a server that runs
the Kitsu production management suite.
Information on how to set up Kitsu can be found [here](https://zou.cg-wire.com/).
If Kitsu is up and running and you can succesfully log in via the web interface you have
to setup the `addon preferences`.
...
'''
LICENSES = (LICENSE_GPL2.id, LICENSE_GPL3.id)
class Command(BaseCommand):
help = 'Generate fake data with extensions, users and versions using test factories.'
def add_arguments(self, parser):
parser.add_argument('--approved-add-ons', type=int, default=0, dest='approved_add_ons')
parser.add_argument('--approved-themes', type=int, default=0, dest='approved_themes')
parser.add_argument('--disabled-add-ons', type=int, default=0, dest='disabled_add_ons')
parser.add_argument('--disabled-themes', type=int, default=0, dest='disabled_themes')
parser.add_argument('--awaiting-add-ons', type=int, default=0, dest='awaiting_add_ons')
parser.add_argument('--awaiting-themes', type=int, default=0, dest='awaiting_themes')
def handle(self, *args, **options):
verbosity = int(options['verbosity'])
root_logger = logging.getLogger('root')
if verbosity > 2:
root_logger.setLevel(logging.DEBUG)
elif verbosity > 1:
root_logger.setLevel(logging.INFO)
else:
root_logger.setLevel(logging.WARNING)
tags = {
type_id: list(Tag.objects.filter(type=type_id).values_list('name', flat=True))
for type_id, _ in Extension.TYPES
}
# Create a few publicly listed extensions
e_sts = Extension.STATUSES
f_sts = File.STATUSES
e_t = Extension.TYPES
for number, _type, file_status, extension_status in (
(options['approved_add_ons'], e_t.BPY, f_sts.APPROVED, e_sts.APPROVED),
(options['approved_themes'], e_t.THEME, f_sts.APPROVED, e_sts.APPROVED),
(options['awaiting_add_ons'], e_t.BPY, f_sts.AWAITING_REVIEW, e_sts.AWAITING_REVIEW),
(options['awaiting_themes'], e_t.THEME, f_sts.AWAITING_REVIEW, e_sts.AWAITING_REVIEW),
(options['disabled_add_ons'], e_t.BPY, f_sts.AWAITING_REVIEW, e_sts.DISABLED),
(options['disabled_themes'], e_t.THEME, f_sts.AWAITING_REVIEW, e_sts.DISABLED),
):
for i_chunked in chunked(range(number), 100):
with factory.django.mute_signals(signals.post_save, signals.pre_save):
with transaction.atomic():
for _ in i_chunked:
name = _faker.catch_phrase() + _faker.bothify('###???')
extension_id = name.replace(' ', '_')
slug = slugify(extension_id)[:50]
version = create_version(
file__status=file_status,
file__metadata={'name': name, 'id': extension_id},
tags=random.sample(tags[_type], k=1),
extension__extension_id=extension_id,
extension__is_listed=extension_status == e_sts.APPROVED,
extension__name=name,
extension__slug=slug,
extension__status=extension_status,
extension__type=_type,
extension__previews=[
FileFactory(
type=File.TYPES.IMAGE,
source=source,
status=file_status,
)
for source in random.sample(
PREVIEW_SOURCES,
k=random.randint(1, len(PREVIEW_SOURCES) - 1),
)
],
# Create these separately
ratings=[],
)
for i in range(random.randint(1, len(LICENSES))):
version.licenses.add(LICENSES[i])
if version.is_listed:
for __ in range(random.randint(1, 10)):
RatingFactory(version=version, extension=version.extension)

View File

@ -22,12 +22,14 @@ function galleriaSetLargePreview(item) {
const galleryItem = item.firstElementChild; const galleryItem = item.firstElementChild;
const galleriaIndex = item.dataset.galleriaIndex; const galleriaIndex = item.dataset.galleriaIndex;
const galleriaContentType = item.dataset.galleriaContentType; const galleriaContentType = item.dataset.galleriaContentType;
const galleriaVideoUrl = item.dataset.galleriaVideoUrl;
previewLarge.classList = item.classList; previewLarge.classList = item.classList;
previewLarge.firstElementChild.src = galleryItem.src; previewLarge.firstElementChild.src = galleryItem.src;
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;
previewLarge.dataset.galleriaVideoUrl = galleriaVideoUrl;
/* Scroll the container as we click on items. */ /* Scroll the container as we click on items. */
previewsContainer.scrollLeft = item.offsetLeft; previewsContainer.scrollLeft = item.offsetLeft;

View File

@ -5,6 +5,20 @@
display: flex display: flex
+padding(2, y) +padding(2, y)
.badge-notifications-count
background-color: var(--color-accent)
border-color: var(--nav-global-color-bg)
border-radius: var(--spacer-2)
color: var(--nav-global-color-text-active)
display: flex
font-size: .8rem
+fw-bold
height: var(--spacer)
left: 1.8rem
min-width: var(--spacer)
position: absolute
top: .6rem
a.badge-tag a.badge-tag
--badge-color: var(--color-text-secondary) --badge-color: var(--color-text-secondary)
--badge-bg: var(--color-text-tertiary) --badge-bg: var(--color-text-tertiary)

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

@ -87,6 +87,10 @@
text-overflow: ellipsis text-overflow: ellipsis
white-space: nowrap white-space: nowrap
&.ext-detail-info-tags
text-overflow: clip
white-space: wrap
dl, dl,
dd:last-child dd:last-child
margin-bottom: 0 margin-bottom: 0
@ -134,10 +138,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
@ -207,6 +211,9 @@
+margin(2, y) +margin(2, y)
width: var(--preview-thumbnail-max-size) width: var(--preview-thumbnail-max-size)
&:hover
cursor: pointer
.previews-list-item-thumbnail-img .previews-list-item-thumbnail-img
background-color: var(--color-bg) background-color: var(--color-bg)
background-position: center background-position: center
@ -242,7 +249,6 @@
.form-control .form-control
&[type="file"] &[type="file"]
font-size: var(--fs-xs)
max-width: 50% max-width: 50%
.ext-version-history .ext-version-history
@ -387,6 +393,9 @@ a
background-color: var(--color-bg) background-color: var(--color-bg)
border-radius: var(--border-radius) border-radius: var(--border-radius)
&:hover
cursor: pointer
.icon-preview .icon-preview
width: 9rem width: 9rem

View File

@ -9,10 +9,10 @@
.form-check-label .form-check-label
+margin(2, left) +margin(2, left)
.form-control .form-control-sm
&[type="file"] &[type="file"]
// TODO: @web-assets improve component style font-size: var(--fs-xs)
height: calc(var(--spacer) * 3.5) height: calc(var(--spacer) * 2)
.invalid-feedback .invalid-feedback
ul ul

View File

@ -9,29 +9,3 @@
&:first-child &:first-child
+padding(0, top) +padding(0, top)
.list-filters
+box-card
background-color: var(--color-bg-primary)
+padding(3)
h3
border-bottom: var(--border-width) solid var(--border-color)
color: var(--color-text-secondary)
+padding(2, bottom)
ul
+list-unstyled
margin: 0
li
&.is-active
color: var(--color-text-primary)
+fw-bold
a
display: block
&:hover
color: var(--color-text-primary)
text-decoration: none

View File

@ -2,14 +2,16 @@
--nav-global-border-radius: var(--border-radius) --nav-global-border-radius: var(--border-radius)
--nav-global-border-radius-lg: var(--border-radius-lg) --nav-global-border-radius-lg: var(--border-radius-lg)
--nav-global-button-height: calc(var(--spacer) * 2.5) --nav-global-button-height: calc(var(--spacer) * 2.5)
--nav-global-font-size: var(--fs-sm) --nav-global-font-size: var(--fs-base)
--nav-global-link-padding-y: var(--nav-global-spacer-xs); --nav-global-link-padding-y: var(--nav-global-spacer-xs);
--nav-global-navbar-height: var(--navbar-primary-height, var(--spacer-6)); --nav-global-navbar-height: var(--navbar-primary-height, var(--spacer-6));
--nav-global-spacer: var(--spacer) --nav-global-spacer: var(--spacer-2)
--nav-global-spacer-sm: var(--spacer-2) --nav-global-spacer-sm: var(--spacer-2)
--nav-global-spacer-xs: var(--spacer-1) --nav-global-spacer-xs: var(--spacer-1)
.btn .btn
line-height: calc(var(--spacer) * 2)
&:hover &:hover
background-color: var(--nav-global-color-button-bg-hover) background-color: var(--nav-global-color-button-bg-hover)
color: var(--nav-global-color-text-hover) !important color: var(--nav-global-color-text-hover) !important
@ -20,3 +22,23 @@
input, input,
.form-control .form-control
height: var(--nav-global-button-height) height: var(--nav-global-button-height)
.nav-global-nav-links
width: auto
+media-md
.nav-global
--nav-global-spacer: calc(var(--spacer) * .75)
--nav-global-font-size: var(--fs-sm)
/* Match nav global links dropdown styles with component dropdown menu */
/* TODO: @web-assets simplify components navigation */
// #nav-global-nav-links
// @extend .dropdown-menu
//
// li
// a
// @extend .dropdown-item
//
// +media-md
// display: inline-flex !important

View File

@ -40,6 +40,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

@ -39,18 +39,14 @@ $container-width: map-get($container-max-widths, 'xl')
\:root \:root
--z-index-galleria: 1050 --z-index-galleria: 1050
.nav-global button.nav-global-logo .navbar-search-helper
+media-xs max-width: 16.0rem
width: 60px min-width: 6.0rem
/* TODO: temporarily here until it can be moved to web-assets v2. */ /* TODO: temporarily here until it can be moved to web-assets v2. */
.nav-global-links-right .nav-global-links-right
gap: 0 var(--spacer-2) gap: 0 var(--spacer-2)
.navbar-search
margin: 0
.navbar-search
width: 160px
.profile-avatar .profile-avatar
border-radius: 50% border-radius: 50%

View File

@ -31,19 +31,15 @@
</head> </head>
<body class="has-global-bar"> <body class="has-global-bar">
{% switch "is_alpha" %}
<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" %} {% 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="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
@ -72,18 +69,21 @@
{% endswitch %} {% endswitch %}
</div> </div>
<button class="nav-global-logo js-dropdown-toggle" data-toggle-menu-id="nav-global-nav-links"> <button class="align-items-center d-flex d-md-none 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 class="me-2" 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 class="fs-base me-1">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 me-4 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 == '/' %}is-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 %}is-active{% endif %}">
Add-ons Add-ons
@ -106,13 +106,13 @@
</li> </li>
</ul> </ul>
<ul class="nav-global-links-right"> <ul class="flex-grow-1 flex-nowrap justify-content-end ms-0 nav-global-links-right">
<li> <li>
<button class="js-toggle-theme-btn px-2"><i class="js-toggle-theme-btn-icon i-adjust"></i></button> <button class="js-toggle-theme-btn px-2"><i class="js-toggle-theme-btn-icon i-adjust"></i></button>
</li> </li>
<li> <li class="flex-grow-1 navbar-search-helper">
<search> <search class="w-100">
<form action="{% url "extensions:search" %}" method="GET" class="navbar-search"> <form action="{% url "extensions:search" %}" method="GET" class="me-0 ms-0 navbar-search">
<input type="text" name="q" class="form-control" <input type="text" name="q" class="form-control"
{% if request.GET.q %} {% if request.GET.q %}
value="{{ request.GET.q }}" value="{{ request.GET.q }}"
@ -129,7 +129,7 @@
</li> </li>
{% block nav-upload %} {% block nav-upload %}
<li> <li class="d-none d-xl-flex">
<a href="{% url 'extensions:submit' %}" class="btn btn-primary"> <a href="{% url 'extensions:submit' %}" class="btn btn-primary">
<i class="i-upload"></i> <i class="i-upload"></i>
<span>Upload Extension</span> <span>Upload Extension</span>
@ -138,9 +138,14 @@
{% endblock nav-upload %} {% endblock nav-upload %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
<li> <li class="nav-item">
<a class="btn btn-link px-2" href="{% url 'notifications:notifications' %}"> <a class="btn btn-link position-relative px-2" href="{% url 'notifications:notifications' %}">
<i class="i-bell {% if user|unread_notification_count %}text-accent{% endif %}"></i> {% with unread_notification_count=user|unread_notification_count %}
{% if unread_notification_count %}
<div class="badge badge-notifications-count">{{ unread_notification_count }}</div>
{% endif %}
{% endwith %}
<i class="i-bell"></i>
</a> </a>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
@ -165,6 +170,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>
{# TODO: add paginator page count if needed #}
{% comment %}
<div class="num-results"> <div class="num-results">
{% blocktranslate with begin=pager.start_index end=pager.end_index count=count %} {% blocktranslate with begin=pager.start_index end=pager.end_index count=count %}
Results <strong>{{ begin }}</strong>&ndash;<strong>{{ end }}</strong> Results <strong>{{ begin }}</strong>&ndash;<strong>{{ end }}</strong>
of <strong>{{ count }}</strong> of <strong>{{ count }}</strong>
{% endblocktranslate %} {% endblocktranslate %}
</div> </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()
@ -19,6 +19,7 @@ class ExtensionFactory(DjangoModelFactory):
name = factory.Faker('catch_phrase') name = factory.Faker('catch_phrase')
extension_id = factory.Faker('slug') extension_id = factory.Faker('slug')
slug = factory.Faker('slug')
description = factory.LazyAttribute( description = factory.LazyAttribute(
lambda _: fake_markdown.post(size=random.choice(('medium', 'large'))) lambda _: fake_markdown.post(size=random.choice(('medium', 'large')))
) )
@ -68,11 +69,31 @@ class VersionFactory(DjangoModelFactory):
download_count = factory.Faker('random_int') download_count = factory.Faker('random_int')
tagline = factory.Faker('bs') tagline = factory.Faker('bs')
file = factory.SubFactory('common.tests.factories.files.FileFactory') file = factory.SubFactory(
'common.tests.factories.files.FileFactory',
metadata=factory.Dict(
{
'name': factory.Faker('name'),
'support': factory.Faker('url'),
'website': factory.Faker('url'),
}
),
)
ratings = factory.RelatedFactoryList( ratings = factory.RelatedFactoryList(
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:
@ -87,7 +108,10 @@ class VersionFactory(DjangoModelFactory):
def create_version(**kwargs) -> 'Version': def create_version(**kwargs) -> 'Version':
version = VersionFactory(**kwargs) version = VersionFactory(**kwargs)
version.file.extension.authors.add(version.file.user) file = version.file
file.extension = version.extension
file.save(update_fields={'extension'})
file.extension.authors.add(version.file.user)
return version return version

View File

@ -1,27 +1,27 @@
from factory.django import DjangoModelFactory from factory.django import DjangoModelFactory
import factory import factory
import factory.fuzzy import factory.fuzzy
import secrets
import hashlib
from files.models import File from files.models import File
def generate_random_sha256():
"""Generate a random (but valid) sha256 hash."""
random_string = secrets.token_hex(32)
sha256_hash = hashlib.sha256(random_string.encode()).hexdigest()
return f"sha256:{sha256_hash}"
class FileFactory(DjangoModelFactory): class FileFactory(DjangoModelFactory):
class Meta: class Meta:
model = File model = File
original_name = factory.LazyAttribute(lambda x: x.source) original_name = factory.LazyAttribute(lambda x: x.source)
original_hash = factory.LazyFunction(lambda: generate_random_sha256()) original_hash = factory.Faker('lexify', text='fakehash:??????????????????', letters='deadbeef')
hash = factory.LazyAttribute(lambda x: x.original_hash) hash = factory.Faker('lexify', text='fakehash:??????????????????', letters='deadbeef')
size_bytes = factory.Faker('random_int') size_bytes = factory.Faker('random_int')
source = factory.Faker('file_name', extension='zip') source = factory.Faker('file_name', extension='zip')
user = factory.SubFactory('common.tests.factories.users.UserFactory') user = factory.SubFactory('common.tests.factories.users.UserFactory')
metadata = factory.Dict({})
class ImageFactory(FileFactory):
original_name = factory.Faker('file_name', extension='png')
source = 'images/de/deadbeef.png'
type = File.TYPES.IMAGE
size_bytes = 1234

View File

@ -14,7 +14,7 @@ class OAuthUserInfoFactory(DjangoModelFactory):
class Meta: class Meta:
model = OAuthUserInfo model = OAuthUserInfo
oauth_user_id = factory.Sequence(lambda n: n + 899999) oauth_user_id = factory.Faker('numerify', text='#############')
user = factory.SubFactory('common.tests.factories.users.UserFactory') user = factory.SubFactory('common.tests.factories.users.UserFactory')
@ -23,7 +23,7 @@ class OAuthUserTokenFactory(DjangoModelFactory):
class Meta: class Meta:
model = OAuthToken model = OAuthToken
oauth_user_id = factory.Sequence(lambda n: n + 899999) oauth_user_id = factory.Faker('numerify', text='#############')
user = factory.SubFactory('common.tests.factories.users.UserFactory') user = factory.SubFactory('common.tests.factories.users.UserFactory')
@ -32,7 +32,17 @@ class UserFactory(DjangoModelFactory):
class Meta: class Meta:
model = User model = User
id = factory.Sequence(lambda n: n + 999) # Bumped to 1000 because there's a fixture with a pk=1 full_name = factory.Faker('name')
username = factory.LazyAttribute(
lambda o: f'{o.full_name.replace(" ", "_")}#{random.randint(1, 9999):04}'
)
email = factory.LazyAttribute(lambda o: f'{o.username}@example.com')
password = 'pass'
class OAuthUserFactory(DjangoModelFactory):
class Meta:
model = User
full_name = factory.Faker('name') full_name = factory.Faker('name')
username = factory.LazyAttribute( username = factory.LazyAttribute(

View File

@ -87,3 +87,25 @@ def _get_all_form_errors(response):
if response.context if response.context
else None else None
) )
class CheckFilePropertiesMixin:
def _test_file_properties(self, file, **kwargs):
if 'content_type' in kwargs:
self.assertEqual(file.content_type, kwargs.get('content_type'))
if 'get_status_display' in kwargs:
self.assertEqual(file.get_status_display(), kwargs.get('get_status_display'))
if 'get_type_display' in kwargs:
self.assertEqual(file.get_type_display(), kwargs.get('get_type_display'))
if 'hash' in kwargs:
self.assertTrue(file.hash.startswith(kwargs.get('hash')), file.hash)
if 'name' in kwargs:
self.assertTrue(file.source.name.startswith(kwargs.get('name')), file.source.name)
if 'original_hash' in kwargs:
self.assertTrue(
file.original_hash.startswith(kwargs.get('original_hash')), file.original_hash
)
if 'original_name' in kwargs:
self.assertEqual(file.original_name, kwargs.get('original_name'))
if 'size_bytes' in kwargs:
self.assertEqual(file.size_bytes, kwargs.get('size_bytes'))

View File

@ -107,5 +107,5 @@ ABUSE_TYPE = Choices(
# thumbnails of existing images must exist in MEDIA_ROOT before # thumbnails of existing images must exist in MEDIA_ROOT before
# the code expecting thumbnails of new dimensions can be deployed! # the code expecting thumbnails of new dimensions can be deployed!
THUMBNAIL_SIZES = {'1080p': [1920, 1080], '360p': [640, 360]} THUMBNAIL_SIZES = {'1080p': [1920, 1080], '360p': [640, 360]}
THUMBNAIL_FORMAT = 'PNG' THUMBNAIL_FORMAT = 'WEBP'
THUMBNAIL_QUALITY = 83 THUMBNAIL_QUALITY = 83

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,21 +34,41 @@ class VersionInline(NoAddDeleteMixin, admin.TabularInline):
class ExtensionAdmin(admin.ModelAdmin): class ExtensionAdmin(admin.ModelAdmin):
date_hierarchy = 'date_created'
list_display = ( list_display = (
'__str__', '__str__',
'type', 'type',
'status', 'status',
'date_created',
'download_count', 'download_count',
'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',
@ -60,9 +80,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 = (
( (
@ -70,7 +91,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',
@ -114,6 +135,7 @@ class ExtensionAdmin(admin.ModelAdmin):
class VersionAdmin(admin.ModelAdmin): class VersionAdmin(admin.ModelAdmin):
date_hierarchy = 'date_created'
list_display = ( list_display = (
'__str__', '__str__',
'extension', 'extension',
@ -124,12 +146,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',
@ -156,6 +189,7 @@ class VersionAdmin(admin.ModelAdmin):
'tags', 'tags',
'file', 'file',
'permissions', 'permissions',
'platforms',
), ),
}, },
), ),
@ -192,6 +226,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')
@ -212,5 +250,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

@ -1,12 +1,14 @@
import logging import logging
from django import forms from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from constants.base import ( from constants.base import (
ALLOWED_FEATURED_IMAGE_MIMETYPES, ALLOWED_FEATURED_IMAGE_MIMETYPES,
ALLOWED_ICON_MIMETYPES, ALLOWED_ICON_MIMETYPES,
ALLOWED_PREVIEW_MIMETYPES, ALLOWED_PREVIEW_MIMETYPES,
FILE_STATUS_CHOICES,
) )
import extensions.models import extensions.models
@ -48,12 +50,15 @@ class AddPreviewFileForm(files.forms.BaseMediaFileForm):
class Meta(files.forms.BaseMediaFileForm.Meta): class Meta(files.forms.BaseMediaFileForm.Meta):
fields = ('caption',) + files.forms.BaseMediaFileForm.Meta.fields fields = ('caption',) + files.forms.BaseMediaFileForm.Meta.fields
# Preview files might be videos, not only images
source = forms.FileField(allow_empty_file=False)
caption = forms.CharField(max_length=255, required=False) caption = forms.CharField(max_length=255, required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.base_fields['source'].required = True
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'}) self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['source'].allow_empty_file = False
self.fields['source'].required = True
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Save Preview from the cleaned form data.""" """Save Preview from the cleaned form data."""
@ -111,6 +116,7 @@ class ExtensionUpdateForm(forms.ModelForm):
'An extension can be converted to draft only while it is Awating Review' 'An extension can be converted to draft only while it is Awating Review'
) )
msg_need_previews = _('Please add at least one preview.') msg_need_previews = _('Please add at least one preview.')
msg_duplicate_file = _('Please select another file instead of the duplicate.')
class Meta: class Meta:
model = extensions.models.Extension model = extensions.models.Extension
@ -175,6 +181,21 @@ class ExtensionUpdateForm(forms.ModelForm):
self.icon_form.is_valid(), self.icon_form.is_valid(),
super().is_valid(*args, **kwargs), super().is_valid(*args, **kwargs),
] ]
new_file_forms = [
*self.add_preview_formset.forms,
self.featured_image_form,
self.icon_form,
]
seen_hashes = set()
for f in new_file_forms:
hash = f.instance.original_hash
if hash:
if hash in seen_hashes:
f.add_error('source', self.msg_duplicate_file)
is_valid_flags.append(False)
break
seen_hashes.add(hash)
return all(is_valid_flags) return all(is_valid_flags)
def clean(self): def clean(self):
@ -202,8 +223,14 @@ class ExtensionUpdateForm(forms.ModelForm):
# Featured image and icon are only required when ready for review, # Featured image and icon are only required when ready for review,
# and can be empty or unchanged. # and can be empty or unchanged.
if self.featured_image_form.has_changed(): if self.featured_image_form.has_changed():
# Automatically approve featured image of an already approved extension
if self.instance.is_approved:
self.featured_image_form.instance.status = FILE_STATUS_CHOICES.APPROVED
self.featured_image_form.save() self.featured_image_form.save()
if self.icon_form.has_changed(): if self.icon_form.has_changed():
# Automatically approve icon of an already approved extension
if self.instance.is_approved:
self.icon_form.instance.status = FILE_STATUS_CHOICES.APPROVED
self.icon_form.save() self.icon_form.save()
if getattr(self.instance, 'converted_to_draft', False): if getattr(self.instance, 'converted_to_draft', False):
@ -266,4 +293,20 @@ class IconForm(files.forms.BaseMediaFileForm):
prefix = 'icon' prefix = 'icon'
to_field = 'icon' to_field = 'icon'
allowed_mimetypes = ALLOWED_ICON_MIMETYPES allowed_mimetypes = ALLOWED_ICON_MIMETYPES
error_messages = {'invalid_mimetype': _('Choose a PNG image')} error_messages = {
'invalid_mimetype': _('Choose a PNG image'),
'invalid_size_px': _('Choose a 256 x 256 PNG image'),
}
expected_size_px = 256
def clean_source(self):
"""Check image resolution."""
source = self.cleaned_data.get('source')
if not source:
return
image = getattr(source, 'image', None)
if not image:
return
if image.width > self.expected_size_px or image.height > self.expected_size_px:
raise ValidationError(self.error_messages['invalid_size_px'])
return source

View File

@ -24,6 +24,7 @@ addons_tags = (
'Add Mesh', 'Add Mesh',
'Add Curve', 'Add Curve',
'Animation', 'Animation',
'Bake',
'Compositing', 'Compositing',
'Development', 'Development',
'Game Engine', 'Game Engine',
@ -40,6 +41,7 @@ addons_tags = (
'Render', 'Render',
'Rigging', 'Rigging',
'Scene', 'Scene',
'Sculpt',
'Sequencer', 'Sequencer',
'System', 'System',
'Text Editor', 'Text Editor',

View File

@ -0,0 +1,30 @@
# Generated by Django 4.2.11 on 2024-05-14 06:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extensions', '0028_terms_flatpages_rename'),
]
operations = [
migrations.RemoveField(
model_name='extensionreviewerflags',
name='extension',
),
migrations.RemoveField(
model_name='versionreviewerflags',
name='version',
),
migrations.DeleteModel(
name='ExtensionApprovalsCounter',
),
migrations.DeleteModel(
name='ExtensionReviewerFlags',
),
migrations.DeleteModel(
name='VersionReviewerFlags',
),
]

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

@ -1,12 +1,11 @@
from typing import List from typing import List
from statistics import median from statistics import median
import datetime
import logging import logging
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist, BadRequest, ValidationError from django.core.exceptions import ObjectDoesNotExist, BadRequest
from django.db import models, transaction from django.db import models, transaction
from django.db.models import F, Q, Count from django.db.models import Q, Count
from django.urls import reverse from django.urls import reverse
from common.fields import FilterableManyToManyField from common.fields import FilterableManyToManyField
@ -100,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):
@ -200,25 +216,28 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
def status_slug(self) -> str: def status_slug(self) -> str:
return utils.slugify(EXTENSION_STATUS_CHOICES[self.status - 1][1]) return utils.slugify(EXTENSION_STATUS_CHOICES[self.status - 1][1])
def clean(self) -> None:
if not self.slug:
self.slug = utils.slugify(self.name)
# Require at least one approved version with a file for approved extensions
if self.status == self.STATUSES.APPROVED:
if not self.latest_version:
raise ValidationError(
{
'status': (
'Extension cannot have an approved status without an approved version'
)
}
)
super().clean()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.clean() self.clean()
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def update_metadata_from_version(self, version):
update_fields = set()
metadata = version.file.metadata
# check if we can also update name
# if we are uploading a new version, we have just validated and don't expect a clash,
# but if a version is being deleted, we want to rollback to a name from an older version,
# which may by clashing now, and we can't do anything about it
name = metadata.get('name')
if not self.__class__.objects.filter(name=name).exclude(pk=self.pk).exists():
update_fields.add('name')
for field in ['support', 'website']:
# if a field is missing from manifest, don't reset the corresponding extension field
if metadata.get(field):
update_fields.add(field)
for field in update_fields:
setattr(self, field, metadata.get(field))
self.save(update_fields=update_fields)
@transaction.atomic @transaction.atomic
def approve(self, reviewer=None): def approve(self, reviewer=None):
"""TODO: Approve an extension which is currently in review.""" """TODO: Approve an extension which is currently in review."""
@ -439,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)
@ -448,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
@ -516,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)
@ -544,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
@ -588,20 +618,6 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
reasons.append('version_has_ratings') reasons.append('version_has_ratings')
return reasons return reasons
@property
def pending_rejection(self):
try:
return self.reviewerflags.pending_rejection
except VersionReviewerFlags.DoesNotExist:
return None
@property
def pending_rejection_by(self):
try:
return self.reviewerflags.pending_rejection_by
except VersionReviewerFlags.DoesNotExist:
return None
@property @property
def download_name(self) -> str: def download_name(self) -> str:
"""Return a file name for downloads.""" """Return a file name for downloads."""
@ -673,88 +689,5 @@ class Preview(CreatedModifiedMixin, RecordDeletionMixin, models.Model):
"""Return a list of reasons why this preview cannot be deleted.""" """Return a list of reasons why this preview cannot be deleted."""
return [] return []
def __str__(self) -> str:
class ExtensionReviewerFlags(models.Model): return f'Preview {self.pk} of extension {self.extension_id}: {self.file}'
extension = models.OneToOneField(
Extension, primary_key=True, on_delete=models.CASCADE, related_name='reviewerflags'
)
needs_admin_code_review = models.BooleanField(default=False)
auto_approval_disabled = models.BooleanField(default=False)
auto_approval_disabled_unlisted = models.BooleanField(default=None, null=True)
auto_approval_disabled_until_next_approval = models.BooleanField(default=None, null=True)
auto_approval_disabled_until_next_approval_unlisted = models.BooleanField(
default=None, null=True
)
auto_approval_delayed_until = models.DateTimeField(default=None, null=True)
notified_about_auto_approval_delay = models.BooleanField(default=None, null=True)
notified_about_expiring_delayed_rejections = models.BooleanField(default=None, null=True)
class VersionReviewerFlags(models.Model):
version = models.OneToOneField(
Version,
primary_key=True,
on_delete=models.CASCADE,
related_name='reviewerflags',
)
pending_rejection = models.DateTimeField(default=None, null=True, blank=True, db_index=True)
pending_rejection_by = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
class Meta:
constraints = [
models.CheckConstraint(
name='pending_rejection_all_none',
check=(
models.Q(
pending_rejection__isnull=True,
pending_rejection_by__isnull=True,
)
| models.Q(
pending_rejection__isnull=False,
)
),
),
]
class ExtensionApprovalsCounter(models.Model):
"""Count the number of times a listed version of an add-on has been approved by a human.
Reset everytime a listed version is auto-approved for this add-on.
Holds 2 additional date fields:
- last_human_review, the date of the last time a human fully reviewed the
add-on
"""
extension = models.OneToOneField(Extension, primary_key=True, on_delete=models.CASCADE)
counter = models.PositiveIntegerField(default=0)
last_human_review = models.DateTimeField(null=True)
def __str__(self):
return '%s: %d' % (str(self.pk), self.counter) if self.pk else ''
@classmethod
def increment_for_extension(cls, extension):
"""Increment approval counter for the specified extension.
Set the last human review date and last content review date to now.
If an ExtensionApprovalsCounter already exists, it updates it, otherwise it
creates and saves a new instance.
"""
now = datetime.now()
data = {
'counter': 1,
'last_human_review': now,
}
obj, created = cls.objects.get_or_create(extension=extension, defaults=data)
if not created:
data['counter'] = F('counter') + 1
obj.update(**data)
return obj
@classmethod
def reset_for_extension(cls, extension):
"""Reset the approval counter (but not the dates) for the specified extension."""
obj, created = cls.objects.update_or_create(extension=extension, defaults={'counter': 0})
return obj

View File

@ -16,6 +16,22 @@ logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
@receiver(pre_save, sender=extensions.models.Preview)
def _set_extension(
sender: object, instance: extensions.models.Preview, raw: bool, **kwargs: object
) -> None:
if raw:
return
file = instance.file
if not file:
return
if not file.extension_id:
file.extension_id = instance.extension_id
file.save(update_fields={'extension_id'})
@receiver(pre_delete, sender=extensions.models.Extension) @receiver(pre_delete, sender=extensions.models.Extension)
@receiver(pre_delete, sender=extensions.models.Preview) @receiver(pre_delete, sender=extensions.models.Preview)
@receiver(pre_delete, sender=extensions.models.Version) @receiver(pre_delete, sender=extensions.models.Version)
@ -29,29 +45,6 @@ def _log_deletion(
instance.record_deletion() instance.record_deletion()
def _delete_file(f, sender, instance, rel):
source = f.source.name
args = {'f_id': f.pk, 'h': f.hash, 'pk': instance.pk, 'sender': sender, 's': source, 'r': rel}
logger.info('Deleting %(r)s file pk=%(f_id)s s=%(s)s hash=%(h)s of %(sender)s pk=%(pk)s', args)
f.delete()
@receiver(post_delete, sender=extensions.models.Preview)
@receiver(post_delete, sender=extensions.models.Version)
def _delete_preview_or_version_file(sender: object, instance: object, **kwargs: object) -> None:
f = instance.file
_delete_file(f, sender, instance, rel=sender)
@receiver(post_delete, sender=extensions.models.Extension)
def _delete_featured_image_and_icon(sender: object, instance: object, **kwargs: object) -> None:
for rel in ('featured_image', 'icon'):
f = getattr(instance, rel)
if not f:
continue
_delete_file(f, sender, instance, rel)
@receiver(pre_save, sender=extensions.models.Extension) @receiver(pre_save, sender=extensions.models.Extension)
@receiver(pre_save, sender=extensions.models.Version) @receiver(pre_save, sender=extensions.models.Version)
def _record_changes( def _record_changes(
@ -89,20 +82,20 @@ def extension_should_be_listed(extension):
def _set_is_listed( def _set_is_listed(
sender: object, sender: object,
instance: Union[extensions.models.Extension, extensions.models.Version, files.models.File], instance: Union[extensions.models.Extension, extensions.models.Version, files.models.File],
raw: bool,
*args: object, *args: object,
**kwargs: object, **kwargs: object,
) -> None: ) -> None:
if raw:
return
if isinstance(instance, extensions.models.Extension): if isinstance(instance, extensions.models.Extension):
extension = instance extension = instance
elif isinstance(instance, extensions.models.Version):
extension = instance.extension
else: else:
# Some file types (e.g., image or video) have no version associated to them.
# But also files which were created but have not yet being related to the versions.
# Since signals is called very early on, we can't assume file.extension will be available. # Since signals is called very early on, we can't assume file.extension will be available.
if not hasattr(instance, 'version'):
return
extension = instance.extension extension = instance.extension
if not extension:
return
old_is_listed = extension.is_listed old_is_listed = extension.is_listed
new_is_listed = extension_should_be_listed(extension) new_is_listed = extension_should_be_listed(extension)
@ -202,3 +195,22 @@ def _create_approval_activity_for_new_version_if_listed(
extension=instance.extension, extension=instance.extension,
message=f'uploaded new version: {instance.version}', message=f'uploaded new version: {instance.version}',
).save() ).save()
@receiver(post_delete, sender=extensions.models.Version)
@receiver(post_save, sender=extensions.models.Version)
def _update_extension_metadata_from_latest_version(
sender: object,
instance: extensions.models.Version,
**kwargs: object,
):
# this code will also be triggered when an extension is deleted
# and it deletes all related versions
extension = instance.extension
latest_version = extension.latest_version
# should check in case we are deleting the latest version, then no need to update anything
if not latest_version:
return
extension.update_metadata_from_version(latest_version)

View File

@ -41,7 +41,7 @@ function appendImageUploadForm() {
<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="Describe the preview">
</div> </div>
<div class="align-items-center d-flex justify-content-between"> <div class="align-items-center d-flex justify-content-between">
<input accept="image/jpg,image/jpeg,image/png,image/webp,video/mp4" class="form-control 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"> <ul class="pt-0">
<li> <li>
<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 ps-2 pe-0"><i class="i-refresh"></i> Reset</button>
@ -62,6 +62,9 @@ function appendImageUploadForm() {
setTimeout(function() { setTimeout(function() {
// TODO: fix jump coming from grid gap on parent // TODO: fix jump coming from grid gap on parent
formRow.classList.add('show'); formRow.classList.add('show');
// Reinit function clickThumbnail
clickThumbnail();
}, 20); }, 20);
} }
@ -81,6 +84,36 @@ btnAddImage.addEventListener('click', function(ev) {
return false; return false;
}); });
// Create function clickThumbnail
function clickThumbnail() {
const inputImgThumbnail = document.querySelectorAll('.js-input-img-thumbnail');
// Create function to get parent with class
function getClosestParent(item, className) {
let currentElement = item.parentElement;
while (currentElement) {
if (currentElement.classList.contains(className)) {
return currentElement;
}
currentElement = currentElement.parentElement;
}
return null;
}
inputImgThumbnail.forEach(function(item) {
item.addEventListener('click', function() {
const extEditFieldRow = getClosestParent(item, 'js-ext-edit-field-row');
const inputImg = extEditFieldRow.querySelector('.js-input-img');
// Trigger click input file
inputImg.click();
});
});
}
// Create function removeImgUploadForm // Create function removeImgUploadForm
function removeImgUploadForm() { function removeImgUploadForm() {
const btnRemoveImgUploadForm = document.querySelectorAll('.js-btn-remove-img-upload-form'); const btnRemoveImgUploadForm = document.querySelectorAll('.js-btn-remove-img-upload-form');
@ -170,6 +203,7 @@ function setImgUploadFormThumbnail() {
// Create function init // Create function init
function init() { function init() {
addImgUploadFormClasses(); addImgUploadFormClasses();
clickThumbnail();
resetImgUploadForm(); resetImgUploadForm();
setImgUploadFormThumbnail(); setImgUploadFormThumbnail();
} }

View File

@ -23,7 +23,7 @@
title="{{ version.blender_version_max }}">{{ version.blender_version_max|version_without_patch }}</a> title="{{ version.blender_version_max }}">{{ version.blender_version_max|version_without_patch }}</a>
{% endif %} {% endif %}
{% else %} {% else %}
{% trans 'and newer' %} &nbsp;{% trans 'and newer' %}
{% endif %} {% endif %}
<a href="{{ version.extension.get_review_url }}?report_compatibility_issue&version={{ version.version }}#id_message" title="{% trans 'Report compatibility issue' %}"><i class="i-flag"></i></a> <a href="{{ version.extension.get_review_url }}?report_compatibility_issue&version={{ version.version }}#id_message" title="{% trans 'Report compatibility issue' %}"><i class="i-flag"></i></a>
{% endif %} {% endif %}

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 }}">
{% with featured_image=extension.featured_image.thumbnail_360p_url %}
<div class="cards-item-thumbnail"> <div class="cards-item-thumbnail">
<img alt="{{ extension.name }}" src="{{ thumbnail_360p_url }}" title="{{ extension.name }}"> {% 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> </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,15 +57,15 @@
{% 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>
{% if latest.tags.count %} {% if latest.tags.count %}
<ul> <ul class="flex-wrap">
{% for tag in latest.tags.all %} {% for tag in latest.tags.all %}
<li> <li class="mb-1">
{% include "extensions/components/badge_tag.html" with small=True version=latest %} {% include "extensions/components/badge_tag.html" with small=True version=latest %}
</li> </li>
{% endfor %} {% endfor %}

View File

@ -109,7 +109,7 @@
</div> </div>
<div class="dl-row"> <div class="dl-row">
<dd> <dd class="ext-detail-info-tags">
{% if version.tags.count %} {% if version.tags.count %}
{% include "extensions/components/tags.html" with small=True version=version %} {% include "extensions/components/tags.html" with small=True version=version %}
{% else %} {% else %}

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>
@ -219,7 +222,9 @@
{% if latest.tags.count %} {% if latest.tags.count %}
<div class="dl-row"> <div class="dl-row">
<dd>{% include "extensions/components/tags.html" with small=True version=latest %}</dd> <dd class="ext-detail-info-tags">
{% include "extensions/components/tags.html" with small=True version=latest %}
</dd>
</div> </div>
{% endif %} {% endif %}
</dl> </dl>

View File

@ -154,5 +154,3 @@
{% block scripts %} {% block scripts %}
{% javascript "extensions" %} {% javascript "extensions" %}
{% endblock scripts %} {% endblock scripts %}
{% block footer %}{# no footer here #}{% endblock footer %}

View File

@ -46,6 +46,9 @@
{% include "extensions/components/card.html" %} {% include "extensions/components/card.html" %}
{% endfor %} {% endfor %}
</div> </div>
<div class="d-flex d-sm-none justify-content-center pt-4">
<a href="{% url 'extensions:by-type' type_slug='add-ons' %}">See More Add-ons</a>
</div>
<hr class="my-5"> <hr class="my-5">
@ -63,6 +66,9 @@
{% include "extensions/components/card.html" %} {% include "extensions/components/card.html" %}
{% endfor %} {% endfor %}
</div> </div>
<div class="d-flex d-sm-none justify-content-center pt-4">
<a href="{% url 'extensions:by-type' type_slug='add-ons' %}">See More Themes</a>
</div>
<div class="my-5 text-center"> <div class="my-5 text-center">
Got an add-on or theme to share with the community? Got an add-on or theme to share with the community?

View File

@ -1,30 +1,11 @@
{% 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 %}">
{% if tags %} <div class="col-md-12 my-4">
<div class="col-md-2">
<aside class="is-sticky pt-3">
<div class="list-filters">
<h3>Tags</h3>
<ul>
{% for list_tag in tags %}
<li class="{% if tag == list_tag %}is-active{% endif %}">
<a href="{% url "extensions:by-tag" tag_slug=list_tag.slug %}" title="{{ list_tag.name }}">
{{ list_tag.name }}
</a>
</li>
{% endfor %}
</ul>
</div>
</aside>
</div>
{% endif %}
<div class="col-md-{% if tags %}10{% else %}12{% endif %} my-4">
{% if author %} {% if author %}
<h2>{% blocktranslate %}Extensions by{% endblocktranslate %} <em class="search-highlight">{{ author }}</em></h2> <h2>{% blocktranslate %}Extensions by{% endblocktranslate %} <em class="search-highlight">{{ author }}</em></h2>
{% endif %} {% endif %}
@ -44,18 +25,51 @@
<h2>{{ page_obj.paginator.count }} result{{ page_obj.paginator.count | pluralize }} for <em class="search-highlight">{{ request.GET.q }}</em></h2> <h2>{{ page_obj.paginator.count }} result{{ page_obj.paginator.count | pluralize }} for <em class="search-highlight">{{ request.GET.q }}</em></h2>
{% endif %} {% endif %}
{% if tags %}
<div class="row">
<div class="col-md-12">
<div class="box p-2">
<div class="btn-row">
{% 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">All</a>
{% else %}
<a class="btn btn-sm btn-primary" href="{% url 'extensions:by-type' type_slug=type|slugify %}" title="All">All</a>
{% endif %}
{% 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 }}">
<div>
{{ list_tag.name }}
</div>
{# TODO: @back-end add tags count dynamic #}
{% comment %}
<div class="align-items-center bg-primary d-flex h-3 fs-xs justify-content-center ms-2 rounded-circle w-3">
1
</div>
{% endcomment %}
</a>
{% endfor %}
</div>
</div>
</div>
</div>
{% endif %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% if object_list %} {% if object_list %}
<div class="cards cards-3"> <div class="cards cards cards-lg-4 cards-md-3 cards-sm-2 mt-3">
{% for extension in object_list %} {% for extension in object_list %}
{% include "extensions/components/card.html" with show_type=False %} {% include "extensions/components/card.html" with show_type=False %}
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p> <div class="mt-3">
<p class="pt-3 text-center">
{% blocktranslate %}No extensions found.{% endblocktranslate %} {% blocktranslate %}No extensions found.{% endblocktranslate %}
</p> </p>
</div>
{% endif %} {% endif %}
<div class="row"> <div class="row">

View File

@ -20,7 +20,7 @@
{% 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='Caption' placeholder="Describe the preview" %}
</div> </div>
<div class="align-items-center d-flex js-input-img-helper justify-content-between"> <div class="align-items-center d-flex js-input-img-helper justify-content-between">
{% include "common/components/field.html" with field=inlineform.source label='File' %} {% include "common/components/field.html" with classes="form-control-sm" field=inlineform.source label='File' %}
<ul class="pt-0"> <ul class="pt-0">
<li> <li>
<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 ps-2 pe-0"><i class="i-refresh"></i> Reset</button>

View File

@ -2,15 +2,14 @@
{# 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="{{ image_form.prefix }}-preview" <div class="align-items-center d-flex justify-content-center 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>
</div> </div>
{% for field in inlineform %} {% for field in inlineform %}
{% if field.name == "source" %} {% if field.name == "source" %}
<small> {% include "common/components/field.html" with classes="form-control-sm" label=label help_text=help_text %}
{% include "common/components/field.html" with label=label help_text=help_text %}
</small>
{% else %} {% else %}
{% include "common/components/field.html" %} {% include "common/components/field.html" %}
{% endif %} {% endif %}
@ -20,13 +19,39 @@
(function() { (function() {
const input = document.getElementById('id_{{ image_form.prefix }}-source'); const input = document.getElementById('id_{{ image_form.prefix }}-source');
const previewEl = document.getElementsByClassName('{{ image_form.prefix }}-preview')[0]; const previewEl = document.getElementsByClassName('{{ image_form.prefix }}-preview')[0];
const previewElIcon = previewEl.querySelector('.js-i-image');
input.addEventListener('change', function() { input.addEventListener('change', function() {
const curFiles = input.files; const curFiles = input.files;
if (curFiles.length > 0) { if (curFiles.length > 0) {
const dataUrl = URL.createObjectURL(curFiles[0]); const dataUrl = URL.createObjectURL(curFiles[0]);
previewEl.style['background-image'] = `url("${dataUrl}")`; previewEl.style['background-image'] = `url("${dataUrl}")`;
previewElIcon.classList.add('d-none');
} }
}); });
previewEl.addEventListener('click', function() {
// Create function to get next sibling with class
function getSibling(item, className) {
let sibling = item.nextElementSibling;
while (sibling) {
if (sibling.classList.contains(className)) {
return sibling;
}
sibling = sibling.nextElementSibling;
}
return null;
}
const previewElInput = getSibling(this, 'form-control');
// Trigger click input file
previewElInput.click();
});
})(); })();
</script> </script>
{% endwith %} {% endwith %}

View File

@ -25,7 +25,7 @@
<div class="row mt-4"> <div class="row mt-4">
<div class="col"> <div class="col">
{% if object_list %} {% if object_list %}
<div class="cards"> <div class="cards cards-lg-4 cards-md-3 cards-sm-2">
{% for extension in object_list %} {% for extension in object_list %}
{% include "extensions/manage/components/card.html" with show_type=True %} {% include "extensions/manage/components/card.html" with show_type=True %}
{% endfor %} {% endfor %}

View File

@ -121,10 +121,14 @@
<span>{% trans 'Version History' %}</span> <span>{% trans 'Version History' %}</span>
</a> </a>
{% with cannot_be_deleted_reasons=extension.cannot_be_deleted_reasons %}
{% if not cannot_be_deleted_reasons %}
<a href="{{ extension.get_delete_url }}" class="btn btn-danger"> <a href="{{ extension.get_delete_url }}" class="btn btn-danger">
<i class="i-trash"></i> <i class="i-trash"></i>
<span>{% trans 'Delete Extension' %}</span> <span>{% trans 'Delete Extension' %}</span>
</a> </a>
{% endif %}
{% endwith %}
{% if request.user.is_staff %} {% if request.user.is_staff %}
<a href="{% url 'admin:extensions_extension_change' extension.pk %}" class="btn btn-admin"> <a href="{% url 'admin:extensions_extension_change' extension.pk %}" class="btn btn-admin">
@ -135,16 +139,6 @@
</section> </section>
</div> </div>
</div> </div>
{# TODO: remove comment to show btn delete if deletion works #}
{% comment %}
<div class="btn-col justify-content-end">
{# TODO: Make deletion work #}
<a href="{{ extension.get_delete_url }}" class="btn btn-danger btn-link w-100">
<i class="i-trash"></i> {% trans 'Delete Extension' %}
</a>
</div>
{% endcomment %}
</div> </div>
</div> </div>
</div> </div>

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,25 +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('a second ago', 'now') compact_time = compact_time.replace(' ago', '')
compact_time = compact_time.replace(' seconds', ' s') compact_time = compact_time.replace(' seconds', ' s')
compact_time = compact_time.replace('a minute', '1m') compact_time = compact_time.replace(' second', ' s')
compact_time = compact_time.replace(' minutes', ' m') compact_time = compact_time.replace(' minutes', ' m')
compact_time = compact_time.replace('1 hour', '1h') # After "X days, 1 hour" compact_time = compact_time.replace('a minute', '1 m')
compact_time = compact_time.replace('an hour', '1h') # Exactly 1 hour. compact_time = compact_time.replace(' minute', ' m')
compact_time = compact_time.replace(' hours', ' h') compact_time = compact_time.replace(' hours', ' h')
compact_time = compact_time.replace('1 day', '1d') compact_time = compact_time.replace('an hour', '1 h')
compact_time = compact_time.replace(' days ago', 'd ago') compact_time = compact_time.replace(' hour', ' h')
compact_time = compact_time.replace(' days', 'd ago') compact_time = compact_time.replace(' days', ' d')
compact_time = compact_time.replace(' day', ' d') compact_time = compact_time.replace(' day', ' d')
compact_time = compact_time.replace('a week', 'w')
compact_time = compact_time.replace(' weeks', ' w') compact_time = compact_time.replace(' weeks', ' w')
compact_time = compact_time.replace('1 month', '1mo') compact_time = compact_time.replace(' week', ' w')
compact_time = compact_time.replace(' months', ' mo') compact_time = compact_time.replace(' months', ' mo')
compact_time = compact_time.replace(' month', ' mo') compact_time = compact_time.replace(' month', ' mo')
compact_time = compact_time.replace('1 year', '1y')
compact_time = compact_time.replace(' years', 'y')
compact_time = compact_time.replace(' years', ' y') compact_time = compact_time.replace(' years', ' y')
compact_time = compact_time.replace(' year', ' y')
return compact_time return compact_time

Binary file not shown.

View File

@ -54,11 +54,11 @@ class DeleteTest(TestCase):
map( map(
repr, repr,
[ [
preview_file,
version_file, version_file,
file_validation, preview_file,
extension, extension,
approval_activity, approval_activity,
file_validation,
preview_file.preview, preview_file.preview,
version, version,
], ],

View File

@ -1,6 +1,7 @@
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
import factory
from common.tests.factories.extensions import create_approved_version from common.tests.factories.extensions import create_approved_version
from common.tests.factories.files import FileFactory from common.tests.factories.files import FileFactory
@ -63,6 +64,13 @@ class CreateFileTest(TestCase):
file=FileFactory( file=FileFactory(
type=File.TYPES.BPY, type=File.TYPES.BPY,
status=File.STATUSES.APPROVED, status=File.STATUSES.APPROVED,
metadata=factory.Dict(
{
'name': 'Blender Kitsu',
'support': factory.Faker('url'),
'website': factory.Faker('url'),
}
),
), ),
) )
@ -166,6 +174,32 @@ class ValidateManifestTest(CreateFileTest):
self.assertIn('blender_kitsu', error) self.assertIn('blender_kitsu', error)
self.assertIn('already being used', error) self.assertIn('already being used', error)
def test_validation_manifest_name_clash(self):
"""Test if we add two extensions with the same name"""
self.assertEqual(Extension.objects.count(), 0)
self._create_valid_extension('blender_kitsu')
self.assertEqual(Extension.objects.count(), 1)
user = UserFactory()
self.client.force_login(user)
kitsu_1_5 = {
"name": "Blender Kitsu",
"id": "blender_kitsu2",
"version": "0.1.5",
}
extension_file = self._create_file_from_data("theme.zip", kitsu_1_5, self.user)
with open(extension_file, 'rb') as fp:
response = self.client.post(
self._get_submit_url(), {'source': fp, 'agreed_with_terms': True}
)
self.assertEqual(response.status_code, 200)
error = response.context['form'].errors.get('source')[0]
self.assertIn('Blender Kitsu', error)
self.assertIn('already being used', error)
def test_validation_manifest_extension_id_mismatch(self): def test_validation_manifest_extension_id_mismatch(self):
"""Test if we try to add a new version to an extension with a mismatched extension_id""" """Test if we try to add a new version to an extension with a mismatched extension_id"""
self.assertEqual(Extension.objects.count(), 0) self.assertEqual(Extension.objects.count(), 0)
@ -240,6 +274,13 @@ class ValidateManifestTest(CreateFileTest):
file=FileFactory( file=FileFactory(
type=File.TYPES.BPY, type=File.TYPES.BPY,
status=File.STATUSES.APPROVED, status=File.STATUSES.APPROVED,
metadata=factory.Dict(
{
'name': factory.Faker('name'),
'support': factory.Faker('url'),
'website': factory.Faker('url'),
}
),
), ),
) )

View File

@ -22,6 +22,11 @@ class ExtensionTest(TestCase):
extension__name='Extension name', extension__name='Extension name',
extension__status=Extension.STATUSES.INCOMPLETE, extension__status=Extension.STATUSES.INCOMPLETE,
extension__support='https://example.com/', extension__support='https://example.com/',
file__metadata={
'name': 'Extension name',
'support': 'https://example.com/',
'website': 'https://example.com/',
},
).extension ).extension
self.assertEqual(entries_for(self.extension).count(), 0) self.assertEqual(entries_for(self.extension).count(), 0)
self.assertIsNone(self.extension.date_approved) self.assertIsNone(self.extension.date_approved)
@ -127,3 +132,76 @@ class VersionTest(TestCase):
response = self.client.get(path) response = self.client.get(path)
self.assertEqual(response.status_code, 200, path) self.assertEqual(response.status_code, 200, path)
class UpdateMetadataTest(TestCase):
fixtures = ['dev', 'licenses']
def setUp(self):
super().setUp()
self.first_version = create_version(
extension__description='Extension description',
extension__name='name',
extension__status=Extension.STATUSES.INCOMPLETE,
extension__support='https://example.com/',
extension__website='https://example.com/',
file__metadata={
'name': 'name',
'support': 'https://example.com/',
'website': 'https://example.com/',
},
)
self.extension = self.first_version.extension
def test_version_create_and_delete(self):
second_version = create_version(
extension=self.extension,
file__metadata={
'name': 'new name',
'support': 'https://example.com/new',
'website': 'https://example.com/new',
},
)
self.extension.refresh_from_db()
self.assertEqual(self.extension.name, 'new name')
self.assertEqual(self.extension.support, 'https://example.com/new')
self.assertEqual(self.extension.website, 'https://example.com/new')
second_version.delete()
self.extension.refresh_from_db()
self.assertEqual(self.extension.name, 'name')
self.assertEqual(self.extension.support, 'https://example.com/')
self.assertEqual(self.extension.website, 'https://example.com/')
def test_old_name_taken(self):
second_version = create_version(
extension=self.extension,
file__metadata={
'name': 'new name',
'support': 'https://example.com/new',
'website': 'https://example.com/new',
},
)
# another extension uses old name
create_version(
extension__description='Extension description',
extension__extension_id='lalalala',
extension__name='name',
extension__status=Extension.STATUSES.INCOMPLETE,
extension__support='https://example.com/',
extension__website='https://example.com/',
file__metadata={
'name': 'name',
'support': 'https://example.com/',
'website': 'https://example.com/',
},
)
second_version.delete()
self.extension.refresh_from_db()
# couldn't revert the name because it has been taken
self.assertEqual(self.extension.name, 'new name')
# reverted other fields
self.assertEqual(self.extension.support, 'https://example.com/')
self.assertEqual(self.extension.website, 'https://example.com/')

View File

@ -7,7 +7,7 @@ from django.urls import reverse_lazy
from common.tests.factories.extensions import create_version from common.tests.factories.extensions import create_version
from common.tests.factories.files import FileFactory from common.tests.factories.files import FileFactory
from common.tests.factories.users import UserFactory from common.tests.factories.users import UserFactory
from common.tests.utils import _get_all_form_errors from common.tests.utils import _get_all_form_errors, CheckFilePropertiesMixin
from extensions.models import Extension, Version from extensions.models import Extension, Version
from files.models import File from files.models import File
from reviewers.models import ApprovalActivity from reviewers.models import ApprovalActivity
@ -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)
@ -198,6 +203,7 @@ class SubmitFileTest(TestCase):
self.assertEqual(response.status_code, 302, _get_all_form_errors(response)) self.assertEqual(response.status_code, 302, _get_all_form_errors(response))
self.assertEqual(File.objects.count(), 1) self.assertEqual(File.objects.count(), 1)
file = File.objects.first() file = File.objects.first()
self.assertIsNotNone(file.extension_id)
self.assertEqual(response['Location'], file.get_submit_url()) self.assertEqual(response['Location'], file.get_submit_url())
self.assertEqual(file.user, user) self.assertEqual(file.user, user)
self.assertEqual(file.original_name, 'theme.zip') self.assertEqual(file.original_name, 'theme.zip')
@ -252,7 +258,7 @@ for file_name, data in EXPECTED_EXTENSION_DATA.items():
) )
class SubmitFinaliseTest(TestCase): class SubmitFinaliseTest(CheckFilePropertiesMixin, TestCase):
maxDiff = None maxDiff = None
fixtures = ['licenses'] fixtures = ['licenses']
@ -319,7 +325,7 @@ class SubmitFinaliseTest(TestCase):
self.assertEqual(File.objects.count(), 1) self.assertEqual(File.objects.count(), 1)
self.assertEqual(Extension.objects.count(), 1) self.assertEqual(Extension.objects.count(), 1)
self.assertEqual(Version.objects.count(), 1) self.assertEqual(Version.objects.count(), 1)
self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 0) images_count_before = File.objects.filter(type=File.TYPES.IMAGE).count()
self.client.force_login(self.file.user) self.client.force_login(self.file.user)
data = { data = {
@ -372,7 +378,10 @@ class SubmitFinaliseTest(TestCase):
self.assertEqual(File.objects.filter(type=File.TYPES.BPY).count(), 1) self.assertEqual(File.objects.filter(type=File.TYPES.BPY).count(), 1)
self.assertEqual(Extension.objects.count(), 1) self.assertEqual(Extension.objects.count(), 1)
self.assertEqual(Version.objects.count(), 1) self.assertEqual(Version.objects.count(), 1)
self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 4) # Check that 4 new images had been created
self.assertEqual(
File.objects.filter(type=File.TYPES.IMAGE).count(), images_count_before + 4
)
# Check an add-on was created with all given fields # Check an add-on was created with all given fields
extension = Extension.objects.first() extension = Extension.objects.first()
self.assertEqual(extension.get_type_display(), 'Add-on') self.assertEqual(extension.get_type_display(), 'Add-on')
@ -389,9 +398,67 @@ class SubmitFinaliseTest(TestCase):
self.assertEqual(version.blender_version_max, None) self.assertEqual(version.blender_version_max, None)
self.assertEqual(version.schema_version, '1.0.0') self.assertEqual(version.schema_version, '1.0.0')
self.assertEqual(version.release_notes, data['release_notes']) self.assertEqual(version.release_notes, data['release_notes'])
self.assertEqual(version.file.get_status_display(), 'Awaiting Review') # Check version file properties
self._test_file_properties(
version.file,
content_type='application/zip',
get_status_display='Awaiting Review',
get_type_display='Add-on',
hash=version.file.original_hash,
original_hash='sha256:2831385',
)
# We cannot check for the ManyToMany yet (tags, licences, permissions) # We cannot check for the ManyToMany yet (tags, licences, permissions)
# Check icon file properties
self._test_file_properties(
extension.icon,
content_type='image/png',
get_status_display='Awaiting Review',
get_type_display='Image',
hash=extension.icon.original_hash,
name='images/ee/ee3a015',
original_hash='sha256:ee3a015',
original_name='test_icon_0001.png',
size_bytes=30177,
)
# Check featured image file properties
self._test_file_properties(
extension.featured_image,
content_type='image/png',
get_status_display='Awaiting Review',
get_type_display='Image',
hash=extension.featured_image.original_hash,
name='images/a3/a3f445bfadc6a',
original_hash='sha256:a3f445bfadc6a',
original_name='test_featured_image_0001.png',
size_bytes=155684,
)
# Check properties of preview image files
self._test_file_properties(
extension.previews.all()[0],
content_type='image/png',
get_status_display='Awaiting Review',
get_type_display='Image',
hash='sha256:643e15',
name='images/64/643e15',
original_hash='sha256:643e15',
original_name='test_preview_image_0001.png',
size_bytes=1163,
)
self._test_file_properties(
extension.previews.all()[1],
content_type='image/png',
get_status_display='Awaiting Review',
get_type_display='Image',
hash='sha256:f8ef448d',
name='images/f8/f8ef448d',
original_hash='sha256:f8ef448d',
original_name='test_preview_image_0002.png',
size_bytes=1693,
)
# Check that author can access the page they are redirected to # Check that author can access the page they are redirected to
response = self.client.get(response['Location']) response = self.client.get(response['Location'])
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)

View File

@ -1,10 +1,11 @@
from pathlib import Path from pathlib import Path
import io
from django.test import TestCase from django.test import TestCase
from common.tests.factories.extensions import create_approved_version, create_version from common.tests.factories.extensions import create_approved_version, create_version
from common.tests.factories.files import FileFactory from common.tests.factories.files import FileFactory, ImageFactory
from common.tests.utils import _get_all_form_errors from common.tests.utils import _get_all_form_errors, CheckFilePropertiesMixin
from extensions.models import Extension from extensions.models import Extension
from files.models import File from files.models import File
from reviewers.models import ApprovalActivity from reviewers.models import ApprovalActivity
@ -36,7 +37,7 @@ POST_DATA = {
} }
class UpdateTest(TestCase): class UpdateTest(CheckFilePropertiesMixin, TestCase):
fixtures = ['dev', 'licenses'] fixtures = ['dev', 'licenses']
def test_get_manage_page(self): def test_get_manage_page(self):
@ -95,6 +96,45 @@ class UpdateTest(TestCase):
self.assertTrue(file1.source.url.endswith('.png')) self.assertTrue(file1.source.url.endswith('.png'))
self.assertEqual(file1.user_id, user.pk) self.assertEqual(file1.user_id, user.pk)
def test_post_upload_a_preview_video(self):
extension = create_version().extension
self.assertEqual(extension.previews.count(), 0)
url = extension.get_manage_url()
user = extension.authors.first()
self.client.force_login(user)
fp = io.BytesIO(b'\x00\x00\x00\x1Cftypisom\x00\x00\x00\x01isomavc1mp42\x00\x00')
fp.name = 'test_preview_video.mp4'
data = {
**POST_DATA,
'form-TOTAL_FORMS': ['1'],
'form-0-source': fp,
}
response = self.client.post(url, data)
self.assertEqual(
response.status_code,
302,
_get_all_form_errors(response),
)
self.assertEqual(response['Location'], url)
extension.refresh_from_db()
self.assertEqual(extension.previews.count(), 1)
video_file = extension.previews.all()[0]
self.assertEqual(video_file.preview.caption, 'First Preview Caption Text')
self._test_file_properties(
video_file,
content_type='video/mp4',
get_type_display='Video',
hash='sha256:1f1007975eb8',
name='videos/1f/1f1007975eb8',
original_hash='sha256:1f1007975eb8',
original_name='test_preview_video.mp4',
size_bytes=30,
)
self.assertEqual(video_file.user_id, user.pk)
def test_post_upload_multiple_preview_images(self): def test_post_upload_multiple_preview_images(self):
extension = create_approved_version().extension extension = create_approved_version().extension
@ -211,7 +251,10 @@ class UpdateTest(TestCase):
], ],
[ [
{}, {},
{'__all__': ['Please correct the duplicate values below.']}, {
'__all__': ['Please correct the duplicate values below.'],
'source': ['Please select another file instead of the duplicate.'],
},
['Please select another file instead of the duplicate'], ['Please select another file instead of the duplicate'],
], ],
) )
@ -243,6 +286,29 @@ class UpdateTest(TestCase):
{'source': ['File with this Original hash already exists.']}, {'source': ['File with this Original hash already exists.']},
) )
def test_post_upload_validation_error_duplicate_across_forms(self):
extension = create_approved_version().extension
data = {
**POST_DATA,
'form-TOTAL_FORMS': ['1'],
}
url = extension.get_manage_url()
user = extension.authors.first()
self.client.force_login(user)
with open(TEST_FILES_DIR / 'test_icon_0001.png', 'rb') as fp, open(
TEST_FILES_DIR / 'test_icon_0001.png', 'rb'
) as fp1:
files = {'form-0-source': fp, 'icon-source': fp1}
response = self.client.post(url, {**data, **files})
self.assertEqual(response.status_code, 200)
self.maxDiff = None
self.assertEqual(
response.context['icon_form'].errors,
{'source': ['Please select another file instead of the duplicate.']},
)
def test_post_upload_validation_error_unexpected_preview_format_gif(self): def test_post_upload_validation_error_unexpected_preview_format_gif(self):
extension = create_approved_version().extension extension = create_approved_version().extension
@ -320,6 +386,95 @@ class UpdateTest(TestCase):
{'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']}, {'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']},
) )
def test_post_icon_validation_errors_wrong_size(self):
extension = create_version().extension
self.client.force_login(extension.authors.first())
url = extension.get_manage_url()
with open(TEST_FILES_DIR / 'test_preview_image_0001.png', 'rb') as fp:
files = {'icon-source': fp}
response = self.client.post(url, {**POST_DATA, **files})
self.assertEqual(response.status_code, 200)
self.assertEqual(
dict(response.context['icon_form'].errors),
{'source': ['Choose a 256 x 256 PNG image']},
)
def test_update_icon_changes_expected_file_fields(self):
extension = create_approved_version(
extension__icon=ImageFactory(
original_name='old_icon.png',
),
).extension
self._test_file_properties(
extension.icon,
content_type='image/png',
get_type_display='Image',
hash='fakehash:',
name='images/de/deadbeef',
original_hash='fakehash:',
original_name='old_icon.png',
size_bytes=1234,
)
self.client.force_login(extension.authors.first())
url = extension.get_manage_url()
with open(TEST_FILES_DIR / 'test_icon_0001.png', 'rb') as fp:
files = {'icon-source': fp}
response = self.client.post(url, {**POST_DATA, **files})
self.assertEqual(response.status_code, 302)
extension.icon.refresh_from_db()
self._test_file_properties(
extension.icon,
content_type='image/png',
get_status_display='Approved', # auto-approved because extension is approved
get_type_display='Image',
hash='sha256:ee3a015',
name='images/ee/ee3a015',
original_hash='sha256:ee3a015',
original_name='test_icon_0001.png',
size_bytes=30177,
)
def test_update_featured_image_changes_expected_file_fields(self):
extension = create_approved_version(
extension__featured_image=ImageFactory(
original_name='old_featured_image.png',
),
).extension
self._test_file_properties(
extension.featured_image,
content_type='image/png',
get_type_display='Image',
hash='fakehash:',
name='images/de/deadbeef',
original_hash='fakehash:',
original_name='old_featured_image.png',
size_bytes=1234,
)
self.client.force_login(extension.authors.first())
url = extension.get_manage_url()
with open(TEST_FILES_DIR / 'test_featured_image_0001.png', 'rb') as fp:
files = {'featured-image-source': fp}
response = self.client.post(url, {**POST_DATA, **files})
self.assertEqual(response.status_code, 302)
extension.featured_image.refresh_from_db()
self._test_file_properties(
extension.featured_image,
content_type='image/png',
get_status_display='Approved', # auto-approved because extension is approved
get_type_display='Image',
hash='sha256:a3f445bfadc6a',
name='images/a3/a3f445bfadc6a',
original_hash='sha256:a3f445bfadc6a',
original_name='test_featured_image_0001.png',
size_bytes=155684,
)
def test_convert_to_draft(self): def test_convert_to_draft(self):
version = create_version(extension__status=Extension.STATUSES.AWAITING_REVIEW) version = create_version(extension__status=Extension.STATUSES.AWAITING_REVIEW)
extension = version.extension extension = version.extension

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():
@ -19,6 +20,11 @@ def _create_extension():
extension__website='https://example.com/', extension__website='https://example.com/',
extension__status=Extension.STATUSES.INCOMPLETE, extension__status=Extension.STATUSES.INCOMPLETE,
extension__average_score=2.5, extension__average_score=2.5,
file__metadata={
'name': 'Test Add-on',
'support': 'https://example.com/issues/',
'website': 'https://example.com/',
},
).extension ).extension
@ -107,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
@ -139,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_version') self.fail('invalid_blender_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 is_in_version_range( if self.blender_version and not 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
platform_slugs = set(p.slug for p in v.platforms.all())
# empty platforms field matches any platform filter
if self.platform and not (not platform_slugs or self.platform in platform_slugs):
continue
matching_version = v matching_version = v
break break
else:
# same as latest_version, but without triggering a new queryset
matching_version = versions[0]
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):
@ -55,10 +55,8 @@ class HomeView(ListedExtensionsView):
'versions__tags', 'versions__tags',
) )
) )
context['addons'] = q.filter(type=EXTENSION_TYPE_CHOICES.BPY).order_by('-average_score')[:8] context['addons'] = q.filter(type=EXTENSION_TYPE_CHOICES.BPY)[:8]
context['themes'] = q.filter(type=EXTENSION_TYPE_CHOICES.THEME).order_by('-average_score')[ context['themes'] = q.filter(type=EXTENSION_TYPE_CHOICES.THEME)[:8]
:8
]
return context return context
@ -71,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):
@ -97,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

@ -64,6 +64,7 @@ class UploadFileView(LoginRequiredMixin, CreateView):
# Need to save the form to be able to use the file to create the version. # Need to save the form to be able to use the file to create the version.
self.object = self.file = form.save() self.object = self.file = form.save()
self.file.extension = self.extension
Version.objects.update_or_create( Version.objects.update_or_create(
extension=self.extension, file=self.file, **self.file.parsed_version_fields extension=self.extension, file=self.file, **self.file.parsed_version_fields
)[0] )[0]

View File

@ -3,6 +3,8 @@ import logging
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.safestring import mark_safe
import background_task.admin import background_task.admin
import background_task.models import background_task.models
@ -61,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
@ -68,13 +71,25 @@ 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),
)
list_display = (
'original_name',
'extension_link',
'user',
'date_created',
'type',
'status',
'is_ok',
) )
list_display = ('original_name', 'extension', 'user', 'date_created', 'type', 'status', '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',
@ -85,7 +100,6 @@ class FileAdmin(admin.ModelAdmin):
'thumbnails', 'thumbnails',
'thumbnail', 'thumbnail',
'type', 'type',
'user',
'original_hash', 'original_hash',
'original_name', 'original_name',
'hash', 'hash',
@ -99,6 +113,9 @@ class FileAdmin(admin.ModelAdmin):
'original_name', 'original_name',
'hash', 'hash',
'source', 'source',
'user__email',
'user__full_name',
'user__username',
) )
fieldsets = ( fieldsets = (
@ -140,6 +157,20 @@ class FileAdmin(admin.ModelAdmin):
inlines = [FileValidationInlineAdmin] inlines = [FileValidationInlineAdmin]
actions = [schedule_scan, make_thumbnails] actions = [schedule_scan, make_thumbnails]
def extension_link(self, obj):
return (
mark_safe(
'<a href="{}" target="_blank">{}</a>'.format(
reverse('admin:extensions_extension_change', args=(obj.extension_id,)),
obj.extension,
)
)
if obj.extension_id
else '-'
)
extension_link.short_description = 'Extension'
def is_ok(self, obj): def is_ok(self, obj):
return obj.validation.is_ok if hasattr(obj, 'validation') else None return obj.validation.is_ok if hasattr(obj, 'validation') else None

View File

@ -10,6 +10,7 @@ import django.core.exceptions
from .validators import ( from .validators import (
ExtensionIDManifestValidator, ExtensionIDManifestValidator,
ExtensionNameManifestValidator,
FileMIMETypeValidator, FileMIMETypeValidator,
ManifestValidator, ManifestValidator,
) )
@ -38,11 +39,12 @@ 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:
model = files.models.File model = files.models.File
fields = ('source', 'type', 'metadata', 'agreed_with_terms', 'user') fields = ('source', 'type', 'metadata', 'agreed_with_terms', 'user', 'extension')
source = forms.FileField( source = forms.FileField(
allow_empty_file=False, allow_empty_file=False,
@ -121,6 +123,7 @@ class FileForm(forms.ModelForm):
'size_bytes': source.size, 'size_bytes': source.size,
'original_hash': hash_, 'original_hash': hash_,
'hash': hash_, 'hash': hash_,
'extension': self.extension,
} }
) )
@ -141,6 +144,14 @@ 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:
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])) errors.append(forms.ValidationError(self.error_messages[code]))
if errors: if errors:
self.add_error('source', errors) self.add_error('source', errors)
@ -148,6 +159,7 @@ class FileForm(forms.ModelForm):
if manifest: if manifest:
ManifestValidator(manifest) ManifestValidator(manifest)
ExtensionIDManifestValidator(manifest, self.extension) ExtensionIDManifestValidator(manifest, self.extension)
ExtensionNameManifestValidator(manifest, self.extension)
self.cleaned_data['metadata'] = manifest self.cleaned_data['metadata'] = manifest
self.cleaned_data['type'] = EXTENSION_SLUG_TYPES[manifest['type']] self.cleaned_data['type'] = EXTENSION_SLUG_TYPES[manifest['type']]
@ -161,7 +173,7 @@ class BaseMediaFileForm(forms.ModelForm):
fields = ('source', 'original_hash') fields = ('source', 'original_hash')
widgets = {'original_hash': forms.HiddenInput()} widgets = {'original_hash': forms.HiddenInput()}
source = forms.ImageField(widget=forms.FileInput) source = forms.ImageField(widget=forms.FileInput, allow_empty_file=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request') self.request = kwargs.pop('request')
@ -193,8 +205,7 @@ class BaseMediaFileForm(forms.ModelForm):
def clean_original_hash(self, *args, **kwargs): def clean_original_hash(self, *args, **kwargs):
"""Calculate original hash of the uploaded file.""" """Calculate original hash of the uploaded file."""
source = self.cleaned_data.get('source') source = self.cleaned_data.get('source')
if not source: if 'source' in self.changed_data and source:
return
return files.models.File.generate_hash(source) return files.models.File.generate_hash(source)
def add_error(self, field, error): def add_error(self, field, error):
@ -208,10 +219,13 @@ class BaseMediaFileForm(forms.ModelForm):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Save as `to_field` on the parent object (Extension).""" """Save as `to_field` on the parent object (Extension)."""
source = self.cleaned_data['source'] source = self.cleaned_data.get('source')
if 'source' in self.changed_data and source:
self.instance.hash = self.instance.original_hash self.instance.hash = self.instance.original_hash
self.instance.original_name = source.name self.instance.original_name = source.name
self.instance.size_bytes = source.size self.instance.size_bytes = source.size
self.instance.extension = self.extension
instance = super().save(*args, **kwargs) instance = super().save(*args, **kwargs)
if hasattr(self, 'to_field'): if hasattr(self, 'to_field'):

View File

@ -0,0 +1,49 @@
# Generated by Django 4.2.11 on 2024-05-09 14:08
import logging
from django.db import migrations, models
import django.db.models.deletion
logger = logging.getLogger(__name__)
def _set_extension(apps, schema_editor):
File = apps.get_model('files', 'File')
to_update = []
for file in File.objects.all():
if file.extension_id:
continue
if getattr(file, 'featured_image_of', None):
file.extension = file.featured_image_of
elif getattr(file, 'icon_of', None):
file.extension = file.icon_of
elif getattr(file, 'version', None):
file.extension = file.version.extension
elif getattr(file, 'preview', None):
file.extension = file.preview.extension
else:
continue
to_update.append(file)
if to_update:
File.objects.bulk_update(to_update, fields={'extension'})
class Migration(migrations.Migration):
dependencies = [
('extensions', '0028_terms_flatpages_rename'),
('files', '0008_alter_file_thumbnail'),
]
operations = [
migrations.AddField(
model_name='file',
name='extension',
field=models.ForeignKey(
null=True, on_delete=django.db.models.deletion.CASCADE, to='extensions.extension',
blank=True,
),
),
migrations.RunPython(_set_extension),
]

View File

@ -49,6 +49,12 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
date_approved = models.DateTimeField(null=True, blank=True, editable=False) date_approved = models.DateTimeField(null=True, blank=True, editable=False)
date_status_changed = models.DateTimeField(null=True, blank=True, editable=False) date_status_changed = models.DateTimeField(null=True, blank=True, editable=False)
extension = models.ForeignKey(
'extensions.Extension',
null=True,
blank=True,
on_delete=models.CASCADE,
)
source = models.FileField(null=False, blank=False, upload_to=file_upload_to) source = models.FileField(null=False, blank=False, upload_to=file_upload_to)
thumbnail = models.ImageField( thumbnail = models.ImageField(
@ -141,7 +147,6 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
self.original_hash = _hash or self.original_hash self.original_hash = _hash or self.original_hash
self.hash = _hash or self.hash self.hash = _hash or self.hash
self.full_clean()
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@property @property
@ -161,10 +166,6 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
path = Path(self.source.path) path = Path(self.source.path)
return ''.join(path.suffixes) return ''.join(path.suffixes)
@property
def extension(self):
return self.version.extension
@property @property
def parsed_extension_fields(self) -> Dict[str, Any]: def parsed_extension_fields(self) -> Dict[str, Any]:
"""Return Extension-related data that was parsed from file's content.""" """Return Extension-related data that was parsed from file's content."""
@ -211,7 +212,7 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
path = self.metadata['thumbnails'][size_key]['path'] path = self.metadata['thumbnails'][size_key]['path']
return self.thumbnail.storage.url(path) return self.thumbnail.storage.url(path)
except (KeyError, TypeError): except (KeyError, TypeError):
log.exception(f'File pk={self.pk} is missing thumbnail "{size_key}": {self.metadata}') log.warning(f'File pk={self.pk} is missing thumbnail "{size_key}": {self.metadata}')
return self.source.url return self.source.url
@property @property

View File

@ -56,7 +56,6 @@ def make_thumbnails(file_id: int) -> None:
thumbnails = files.utils.make_thumbnails(source_path, file.hash) thumbnails = files.utils.make_thumbnails(source_path, file.hash)
if not thumbnail_field.name:
thumbnail_field.name = thumbnails['1080p']['path'] thumbnail_field.name = thumbnails['1080p']['path']
update_fields = set() update_fields = set()

View File

@ -4,7 +4,7 @@ import logging
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from common.tests.factories.files import FileFactory from common.tests.factories.files import FileFactory, ImageFactory
from files.tasks import make_thumbnails from files.tasks import make_thumbnails
import files.models import files.models
@ -12,16 +12,26 @@ TEST_MEDIA_DIR = Path(__file__).resolve().parent / 'media'
@override_settings(MEDIA_ROOT=TEST_MEDIA_DIR, REQUIRE_FILE_VALIDATION=True) @override_settings(MEDIA_ROOT=TEST_MEDIA_DIR, REQUIRE_FILE_VALIDATION=True)
@patch(
'django.core.files.storage.base.Storage.get_available_name',
# Make storage return the path with a non-random suffix
lambda _, name, max_length=None: '{}_random7.{}'.format(*name.split('.')),
)
@patch(
'django.core.files.storage.filesystem.FileSystemStorage._save',
# Skip the actual writing of the file
lambda _, name, __: name,
)
class TasksTest(TestCase): class TasksTest(TestCase):
def test_make_thumbnails_fails_when_no_validation(self): def test_make_thumbnails_fails_when_no_validation(self):
file = FileFactory(original_hash='foobar', source='file/original_image_source.jpg') file = ImageFactory()
with self.assertRaises(files.models.File.validation.RelatedObjectDoesNotExist): with self.assertRaises(files.models.File.validation.RelatedObjectDoesNotExist):
make_thumbnails.task_function(file_id=file.pk) make_thumbnails.task_function(file_id=file.pk)
@patch('files.utils.make_thumbnails') @patch('files.utils.make_thumbnails')
def test_make_thumbnails_fails_when_validation_not_ok(self, mock_make_thumbnails): def test_make_thumbnails_fails_when_validation_not_ok(self, mock_make_thumbnails):
file = FileFactory(original_hash='foobar', source='file/original_image_source.jpg') file = ImageFactory()
files.models.FileValidation.objects.create(file=file, is_ok=False, results={}) files.models.FileValidation.objects.create(file=file, is_ok=False, results={})
with self.assertLogs(level=logging.ERROR) as logs: with self.assertLogs(level=logging.ERROR) as logs:
@ -36,9 +46,7 @@ class TasksTest(TestCase):
@patch('files.utils.make_thumbnails') @patch('files.utils.make_thumbnails')
def test_make_thumbnails_fails_when_not_image_or_video(self, mock_make_thumbnails): def test_make_thumbnails_fails_when_not_image_or_video(self, mock_make_thumbnails):
file = FileFactory( file = FileFactory(type=files.models.File.TYPES.THEME)
original_hash='foobar', source='file/source.zip', type=files.models.File.TYPES.THEME
)
with self.assertLogs(level=logging.ERROR) as logs: with self.assertLogs(level=logging.ERROR) as logs:
make_thumbnails.task_function(file_id=file.pk) make_thumbnails.task_function(file_id=file.pk)
@ -54,7 +62,7 @@ class TasksTest(TestCase):
@patch('files.utils.resize_image') @patch('files.utils.resize_image')
@patch('files.utils.Image') @patch('files.utils.Image')
def test_make_thumbnails_for_image(self, mock_image, mock_resize_image): def test_make_thumbnails_for_image(self, mock_image, mock_resize_image):
file = FileFactory(original_hash='foobar', source='file/original_image_source.jpg') file = ImageFactory(hash='foobar', source='file/original_image_source.jpg')
files.models.FileValidation.objects.create(file=file, is_ok=True, results={}) files.models.FileValidation.objects.create(file=file, is_ok=True, results={})
self.assertIsNone(file.thumbnail.name) self.assertIsNone(file.thumbnail.name)
self.assertEqual(file.metadata, {}) self.assertEqual(file.metadata, {})
@ -67,13 +75,19 @@ class TasksTest(TestCase):
mock_image.open.return_value.close.assert_called_once() mock_image.open.return_value.close.assert_called_once()
file.refresh_from_db() file.refresh_from_db()
self.assertEqual(file.thumbnail.name, 'thumbnails/fo/foobar_1920x1080.png') self.assertEqual(file.thumbnail.name, 'thumbnails/fo/foobar_1920x1080_random7.webp')
self.assertEqual( self.assertEqual(
file.metadata, file.metadata,
{ {
'thumbnails': { 'thumbnails': {
'1080p': {'path': 'thumbnails/fo/foobar_1920x1080.png', 'size': [1920, 1080]}, '1080p': {
'360p': {'path': 'thumbnails/fo/foobar_640x360.png', 'size': [640, 360]}, 'path': 'thumbnails/fo/foobar_1920x1080_random7.webp',
'size': [1920, 1080],
},
'360p': {
'path': 'thumbnails/fo/foobar_640x360_random7.webp',
'size': [640, 360],
},
}, },
}, },
) )
@ -83,7 +97,7 @@ class TasksTest(TestCase):
@patch('files.utils.FFmpeg') @patch('files.utils.FFmpeg')
def test_make_thumbnails_for_video(self, mock_ffmpeg, mock_image, mock_resize_image): def test_make_thumbnails_for_video(self, mock_ffmpeg, mock_image, mock_resize_image):
file = FileFactory( file = FileFactory(
original_hash='deadbeef', source='file/path.mp4', type=files.models.File.TYPES.VIDEO hash='deadbeef', source='file/path.mp4', type=files.models.File.TYPES.VIDEO
) )
files.models.FileValidation.objects.create(file=file, is_ok=True, results={}) files.models.FileValidation.objects.create(file=file, is_ok=True, results={})
self.assertIsNone(file.thumbnail.name) self.assertIsNone(file.thumbnail.name)
@ -93,20 +107,26 @@ class TasksTest(TestCase):
mock_ffmpeg.assert_called_once_with() mock_ffmpeg.assert_called_once_with()
mock_image.open.assert_called_once_with( mock_image.open.assert_called_once_with(
str(TEST_MEDIA_DIR / 'thumbnails' / 'de' / 'deadbeef.png') str(TEST_MEDIA_DIR / 'thumbnails' / 'de' / 'deadbeef.webp')
) )
mock_image.open.return_value.close.assert_called_once() mock_image.open.return_value.close.assert_called_once()
file.refresh_from_db() file.refresh_from_db()
# Check that the extracted frame is stored instead of the large thumbnail self.assertEqual(file.thumbnail.name, 'thumbnails/de/deadbeef_1920x1080_random7.webp')
self.assertEqual(file.thumbnail.name, 'thumbnails/de/deadbeef.png')
# Check that File metadata and thumbnail fields were updated # Check that File metadata and thumbnail fields were updated
self.maxDiff = None
self.assertEqual( self.assertEqual(
file.metadata, file.metadata,
{ {
'thumbnails': { 'thumbnails': {
'1080p': {'path': 'thumbnails/de/deadbeef_1920x1080.png', 'size': [1920, 1080]}, '1080p': {
'360p': {'path': 'thumbnails/de/deadbeef_640x360.png', 'size': [640, 360]}, 'path': 'thumbnails/de/deadbeef_1920x1080_random7.webp',
'size': [1920, 1080],
},
'360p': {
'path': 'thumbnails/de/deadbeef_640x360_random7.webp',
'size': [640, 360],
},
}, },
}, },
) )

View File

@ -17,6 +17,16 @@ from files.utils import (
TEST_FILES_DIR = Path(__file__).resolve().parent.parent.parent / 'extensions' / 'tests' / 'files' TEST_FILES_DIR = Path(__file__).resolve().parent.parent.parent / 'extensions' / 'tests' / 'files'
@patch(
'django.core.files.storage.base.Storage.get_available_name',
# Make storage return the path with a non-random suffix
lambda _, name, max_length=None: '{}_random7.{}'.format(*name.split('.')),
)
@patch(
'django.core.files.storage.filesystem.FileSystemStorage._save',
# Skip the actual writing of the file
lambda _, name, __: name,
)
class UtilsTest(TestCase): class UtilsTest(TestCase):
manifest = 'blender_manifest.toml' manifest = 'blender_manifest.toml'
@ -115,9 +125,9 @@ class UtilsTest(TestCase):
def test_get_thumbnail_upload_to(self): def test_get_thumbnail_upload_to(self):
for file_hash, kwargs, expected in ( for file_hash, kwargs, expected in (
('foobar', {}, 'thumbnails/fo/foobar.png'), ('foobar', {}, 'thumbnails/fo/foobar.webp'),
('deadbeef', {'width': None, 'height': None}, 'thumbnails/de/deadbeef.png'), ('deadbeef', {'width': None, 'height': None}, 'thumbnails/de/deadbeef.webp'),
('deadbeef', {'width': 640, 'height': 360}, 'thumbnails/de/deadbeef_640x360.png'), ('deadbeef', {'width': 640, 'height': 360}, 'thumbnails/de/deadbeef_640x360.webp'),
): ):
with self.subTest(file_hash=file_hash, kwargs=kwargs): with self.subTest(file_hash=file_hash, kwargs=kwargs):
self.assertEqual(get_thumbnail_upload_to(file_hash, **kwargs), expected) self.assertEqual(get_thumbnail_upload_to(file_hash, **kwargs), expected)
@ -126,8 +136,11 @@ class UtilsTest(TestCase):
def test_make_thumbnails(self, mock_resize_image): def test_make_thumbnails(self, mock_resize_image):
self.assertEqual( self.assertEqual(
{ {
'1080p': {'path': 'thumbnails/fo/foobar_1920x1080.png', 'size': [1920, 1080]}, '1080p': {
'360p': {'path': 'thumbnails/fo/foobar_640x360.png', 'size': [640, 360]}, 'path': 'thumbnails/fo/foobar_1920x1080_random7.webp',
'size': [1920, 1080],
},
'360p': {'path': 'thumbnails/fo/foobar_640x360_random7.webp', 'size': [640, 360]},
}, },
make_thumbnails(TEST_FILES_DIR / 'test_preview_image_0001.png', 'foobar'), make_thumbnails(TEST_FILES_DIR / 'test_preview_image_0001.png', 'foobar'),
) )
@ -139,7 +152,7 @@ class UtilsTest(TestCase):
ANY, ANY,
expected_size, expected_size,
ANY, ANY,
output_format='PNG', output_format='WEBP',
quality=83, quality=83,
optimize=True, optimize=True,
progressive=True, progressive=True,

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,9 +132,20 @@ 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()
except Exception as e:
logger.error(f"Error extracting from archive: {e}")
error_codes.append('invalid_zip_archive')
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 # If manifest was parsed successfully, do additional type-specific validation
type_slug = toml_content['type'] type_slug = toml_content['type']
@ -146,18 +160,18 @@ def read_manifest_from_zip(archive_path):
if not init_filepath: if not init_filepath:
error_codes.append('invalid_missing_init') 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 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:
logger.error(f"Error extracting from archive: {e}")
error_codes.append('invalid_zip_archive')
return None, error_codes
def guess_mimetype_from_ext(file_name: str) -> str: def guess_mimetype_from_ext(file_name: str) -> str:
"""Guess MIME-type from the extension of the given file name.""" """Guess MIME-type from the extension of the given file name."""
@ -259,12 +273,8 @@ def make_thumbnails(
optimize=True, optimize=True,
progressive=True, progressive=True,
) )
logger.info('Saving a thumbnail to %s', output_path) output_path = default_storage.save(output_path, f)
# Overwrite files instead of allowing storage generate a deduplicating suffix logger.info('Saved a thumbnail to %s', output_path)
if default_storage.exists(output_path):
logger.warning('%s exists, overwriting', output_path)
default_storage.delete(output_path)
default_storage.save(output_path, f)
thumbnails[size_key] = {'size': size, 'path': output_path} thumbnails[size_key] = {'size': size, 'path': output_path}
image.close() image.close()
@ -287,7 +297,7 @@ def extract_frame(source_path: str, output_path: str, at_time: str = '00:00:00.0
) )
output_dir = os.path.dirname(abs_path) output_dir = os.path.dirname(abs_path)
if not os.path.isdir(output_dir): if not os.path.isdir(output_dir):
os.mkdir(output_dir) os.makedirs(output_dir)
ffmpeg.execute() ffmpeg.execute()
end_t = datetime.datetime.now() end_t = datetime.datetime.now()

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
@ -132,6 +138,32 @@ class ExtensionIDManifestValidator:
) )
class ExtensionNameManifestValidator:
"""Validates name uniqueness across extensions."""
def __init__(self, manifest, extension_to_be_updated):
name = manifest.get('name')
extension = Extension.objects.filter(name=name).first()
if extension:
if (
extension_to_be_updated is None
or extension_to_be_updated.extension_id != extension.extension_id
):
raise ValidationError(
{
'source': [
mark_safe(
f'The extension <code>name</code> in the manifest '
f'("{escape(name)}") '
f'is already being used by another extension.'
),
],
},
code='invalid',
)
class ManifestFieldValidator: class ManifestFieldValidator:
@classmethod @classmethod
def validate(cls, *, name: str, value: object, manifest: dict) -> str: def validate(cls, *, name: str, value: object, manifest: dict) -> str:
@ -175,10 +207,12 @@ class LicenseValidator(ListValidator):
if type(value) != list: if type(value) != list:
is_error = True is_error = True
unknown_value = None
for license in value: for license in value:
if License.get_by_slug(license): if License.get_by_slug(license):
continue continue
is_error = True is_error = True
unknown_value = license
break break
if not is_error: if not is_error:
@ -189,6 +223,8 @@ class LicenseValidator(ListValidator):
f'<a href="https://docs.blender.org/manual/en/dev/extensions/licenses.html">' f'<a href="https://docs.blender.org/manual/en/dev/extensions/licenses.html">'
f'supported licenses</a>. e.g., {cls.example}.' f'supported licenses</a>. e.g., {cls.example}.'
) )
if unknown_value:
error_message += mark_safe(f' Unknown value: {escape(unknown_value)}.')
return error_message return error_message
@ -200,6 +236,7 @@ class TagsValidatorBase:
is_error = False is_error = False
type_name = EXTENSION_TYPE_SLUGS_SINGULAR[cls.type] type_name = EXTENSION_TYPE_SLUGS_SINGULAR[cls.type]
unknown_value = None
if type(value) != list: if type(value) != list:
is_error = True is_error = True
else: else:
@ -207,18 +244,21 @@ class TagsValidatorBase:
if Tag.objects.filter(name=tag, type=cls.type): if Tag.objects.filter(name=tag, type=cls.type):
continue continue
is_error = True is_error = True
unknown_value = tag
type_slug = manifest.get('type') type_slug = manifest.get('type')
logger.info(f'Tag unavailable for {type_slug}: {tag}') logger.info(f'Tag unavailable for {type_slug}: {tag}')
if not is_error: if not is_error:
return return
error_message = ( 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/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}. '
) )
return mark_safe(error_message) if unknown_value:
error_message += mark_safe(f' Unknown value: {escape(unknown_value)}.')
return error_message
class TagsAddonsValidator(TagsValidatorBase): class TagsAddonsValidator(TagsValidatorBase):
@ -327,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'
@ -455,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

@ -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: "300m"
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

@ -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

@ -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',

View File

@ -10,7 +10,7 @@ from django.test import TestCase, override_settings, TransactionTestCase
from django.urls import reverse from django.urls import reverse
import dateutil.parser import dateutil.parser
from common.tests.factories.users import UserFactory from common.tests.factories.users import OAuthUserFactory
import users.tests.util as util import users.tests.util as util
import users.views.webhooks as webhooks import users.views.webhooks as webhooks
@ -56,7 +56,7 @@ class TestBlenderIDWebhook(TestCase):
util.mock_blender_id_responses() util.mock_blender_id_responses()
# Prepare a user # Prepare a user
self.user = UserFactory( self.user = OAuthUserFactory(
email='mail@example.com', email='mail@example.com',
oauth_info__oauth_user_id='2', oauth_info__oauth_user_id='2',
oauth_tokens__oauth_user_id='2', oauth_tokens__oauth_user_id='2',
@ -240,7 +240,7 @@ class TestIntegrityErrors(TransactionTestCase):
util.mock_blender_id_responses() util.mock_blender_id_responses()
# Prepare a user # Prepare a user
self.user = UserFactory( self.user = OAuthUserFactory(
email='mail@example.com', email='mail@example.com',
oauth_info__oauth_user_id='2', oauth_info__oauth_user_id='2',
oauth_tokens__oauth_user_id='2', oauth_tokens__oauth_user_id='2',
@ -251,7 +251,7 @@ class TestIntegrityErrors(TransactionTestCase):
@responses.activate @responses.activate
def test_user_modified_does_not_allow_duplicate_email(self): def test_user_modified_does_not_allow_duplicate_email(self):
# Same email as in the webhook payload for another user # Same email as in the webhook payload for another user
another_user = UserFactory(email='jane@example.com') another_user = OAuthUserFactory(email='jane@example.com')
body = { body = {
**self.webhook_payload, **self.webhook_payload,
'email': 'jane@example.com', 'email': 'jane@example.com',