Intitial teams support #147
@ -17,6 +17,8 @@ Blender Extensions platform, heavily inspired by Mozilla's https://github.com/mo
|
||||
# Requirements
|
||||
|
||||
* Python 3.10
|
||||
* `libmagic`: `sudo apt-get install libmagic1` in Debian/Ubuntu, `brew install libmagic` on OSX.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
|
@ -1 +1 @@
|
||||
Subproject commit 1126f102d8542ffb76af0269854048f276d9e50b
|
||||
Subproject commit ffcc72b5cb153fc2a409c795adca82e350655aa2
|
@ -2,6 +2,7 @@ import logging
|
||||
import random
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
import faker
|
||||
|
||||
from common.tests.factories.extensions import create_approved_version, create_version
|
||||
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 extensions.models import Extension, Tag
|
||||
|
||||
_faker = faker.Faker()
|
||||
|
||||
FILE_SOURCES = {
|
||||
"blender-kitsu": {
|
||||
"file": 'files/ed/ed656b177b01999e6fcd0e37c34ced471ef88c89db578f337e40958553dca5d2.zip',
|
||||
@ -94,6 +97,7 @@ class Command(BaseCommand):
|
||||
original_hash=FILE_SOURCES["blender-kitsu"]["hash"],
|
||||
size_bytes=FILE_SOURCES["blender-kitsu"]["size"],
|
||||
status=File.STATUSES.APPROVED,
|
||||
metadata={'name': 'Blender Kitsu'},
|
||||
),
|
||||
extension__previews=[
|
||||
FileFactory(
|
||||
@ -111,9 +115,12 @@ class Command(BaseCommand):
|
||||
# Create a few publicly listed extensions
|
||||
for i in range(10):
|
||||
extension__type = random.choice(Extension.TYPES)[0]
|
||||
name = _faker.catch_phrase()
|
||||
version = create_approved_version(
|
||||
file__status=File.STATUSES.APPROVED,
|
||||
file__metadata={'name': name},
|
||||
# extension__status=Extension.STATUSES.APPROVED,
|
||||
extension__name=name,
|
||||
extension__type=extension__type,
|
||||
tags=random.sample(tags[extension__type], k=1),
|
||||
extension__previews=[
|
||||
|
141
common/management/commands/generate_more_fake_data.py
Normal file
141
common/management/commands/generate_more_fake_data.py
Normal 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)
|
@ -22,12 +22,14 @@ function galleriaSetLargePreview(item) {
|
||||
const galleryItem = item.firstElementChild;
|
||||
const galleriaIndex = item.dataset.galleriaIndex;
|
||||
const galleriaContentType = item.dataset.galleriaContentType;
|
||||
const galleriaVideoUrl = item.dataset.galleriaVideoUrl;
|
||||
|
||||
previewLarge.classList = item.classList;
|
||||
previewLarge.firstElementChild.src = galleryItem.src;
|
||||
previewLarge.firstElementChild.alt = galleryItem.alt;
|
||||
previewLarge.dataset.galleriaIndex = galleriaIndex;
|
||||
previewLarge.dataset.galleriaContentType = galleriaContentType;
|
||||
previewLarge.dataset.galleriaVideoUrl = galleriaVideoUrl;
|
||||
|
||||
/* Scroll the container as we click on items. */
|
||||
previewsContainer.scrollLeft = item.offsetLeft;
|
||||
|
@ -5,6 +5,20 @@
|
||||
display: flex
|
||||
+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
|
||||
--badge-color: var(--color-text-secondary)
|
||||
--badge-bg: var(--color-text-tertiary)
|
||||
|
@ -13,7 +13,7 @@
|
||||
.cards-item-content
|
||||
overflow: hidden
|
||||
|
||||
.crads-item-excerpt
|
||||
.cards-item-excerpt
|
||||
line-height: calc(24 / 18)
|
||||
|
||||
.cards-item-extra
|
||||
@ -25,5 +25,24 @@
|
||||
.stars
|
||||
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
|
||||
+padding(0, y)
|
||||
|
||||
.is-row-add-ons,
|
||||
.is-row-themes
|
||||
.cards-item-headline
|
||||
display: none
|
||||
|
@ -87,6 +87,10 @@
|
||||
text-overflow: ellipsis
|
||||
white-space: nowrap
|
||||
|
||||
&.ext-detail-info-tags
|
||||
text-overflow: clip
|
||||
white-space: wrap
|
||||
|
||||
dl,
|
||||
dd:last-child
|
||||
margin-bottom: 0
|
||||
@ -134,10 +138,10 @@
|
||||
padding: 0
|
||||
|
||||
strong
|
||||
font-size: var(--fs-lg)
|
||||
font-size: var(--fs-h4)
|
||||
|
||||
i
|
||||
font-size: var(--fs-lg)
|
||||
font-size: var(--fs-h4)
|
||||
+margin(3, right)
|
||||
|
||||
.ext-detail-download
|
||||
@ -207,6 +211,9 @@
|
||||
+margin(2, y)
|
||||
width: var(--preview-thumbnail-max-size)
|
||||
|
||||
&:hover
|
||||
cursor: pointer
|
||||
|
||||
.previews-list-item-thumbnail-img
|
||||
background-color: var(--color-bg)
|
||||
background-position: center
|
||||
@ -242,7 +249,6 @@
|
||||
|
||||
.form-control
|
||||
&[type="file"]
|
||||
font-size: var(--fs-xs)
|
||||
max-width: 50%
|
||||
|
||||
.ext-version-history
|
||||
@ -387,6 +393,9 @@ a
|
||||
background-color: var(--color-bg)
|
||||
border-radius: var(--border-radius)
|
||||
|
||||
&:hover
|
||||
cursor: pointer
|
||||
|
||||
.icon-preview
|
||||
width: 9rem
|
||||
|
||||
|
@ -9,10 +9,10 @@
|
||||
.form-check-label
|
||||
+margin(2, left)
|
||||
|
||||
.form-control
|
||||
.form-control-sm
|
||||
&[type="file"]
|
||||
// TODO: @web-assets improve component style
|
||||
height: calc(var(--spacer) * 3.5)
|
||||
font-size: var(--fs-xs)
|
||||
height: calc(var(--spacer) * 2)
|
||||
|
||||
.invalid-feedback
|
||||
ul
|
||||
|
@ -9,29 +9,3 @@
|
||||
|
||||
&:first-child
|
||||
+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
|
||||
|
@ -2,14 +2,16 @@
|
||||
--nav-global-border-radius: var(--border-radius)
|
||||
--nav-global-border-radius-lg: var(--border-radius-lg)
|
||||
--nav-global-button-height: calc(var(--spacer) * 2.5)
|
||||
--nav-global-font-size: var(--fs-sm)
|
||||
--nav-global-font-size: var(--fs-base)
|
||||
--nav-global-link-padding-y: var(--nav-global-spacer-xs);
|
||||
--nav-global-navbar-height: var(--navbar-primary-height, var(--spacer-6));
|
||||
--nav-global-spacer: var(--spacer)
|
||||
--nav-global-spacer: var(--spacer-2)
|
||||
--nav-global-spacer-sm: var(--spacer-2)
|
||||
--nav-global-spacer-xs: var(--spacer-1)
|
||||
|
||||
.btn
|
||||
line-height: calc(var(--spacer) * 2)
|
||||
|
||||
&:hover
|
||||
background-color: var(--nav-global-color-button-bg-hover)
|
||||
color: var(--nav-global-color-text-hover) !important
|
||||
@ -20,3 +22,23 @@
|
||||
input,
|
||||
.form-control
|
||||
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
|
||||
|
@ -40,6 +40,10 @@
|
||||
.style-rich-text
|
||||
+style-rich-text
|
||||
|
||||
// TODO: @web-assets move style pre to web-assets
|
||||
pre
|
||||
+margin(3, bottom)
|
||||
|
||||
.text-accent
|
||||
color: var(--color-accent)
|
||||
|
||||
|
@ -39,18 +39,14 @@ $container-width: map-get($container-max-widths, 'xl')
|
||||
\:root
|
||||
--z-index-galleria: 1050
|
||||
|
||||
.nav-global button.nav-global-logo
|
||||
+media-xs
|
||||
width: 60px
|
||||
.navbar-search-helper
|
||||
max-width: 16.0rem
|
||||
min-width: 6.0rem
|
||||
|
||||
|
||||
/* TODO: temporarily here until it can be moved to web-assets v2. */
|
||||
.nav-global-links-right
|
||||
gap: 0 var(--spacer-2)
|
||||
.navbar-search
|
||||
margin: 0
|
||||
|
||||
.navbar-search
|
||||
width: 160px
|
||||
|
||||
.profile-avatar
|
||||
border-radius: 50%
|
||||
|
@ -31,19 +31,15 @@
|
||||
</head>
|
||||
|
||||
<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" %}
|
||||
<div class="site-announcement-beta">
|
||||
The website will be officially released together with Blender 4.2.
|
||||
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>
|
||||
This platform is currently in beta.
|
||||
<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>
|
||||
{% endswitch %}
|
||||
{% endswitch %}
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
<div class="whoosh-container">
|
||||
<a href="{% url 'admin:index' %}" title='Admin' class="whoosh">
|
||||
@ -52,10 +48,11 @@
|
||||
{% block admin_button_page %}{% endblock %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{# TODO: improve nav-global layout for small screens #}
|
||||
<div class="nav-global">
|
||||
<div class="nav-global-container">
|
||||
<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 %}">
|
||||
<svg fill-rule="nonzero" viewBox="0 0 200 162.05">
|
||||
<path
|
||||
@ -72,18 +69,21 @@
|
||||
{% endswitch %}
|
||||
</div>
|
||||
|
||||
<button class="nav-global-logo js-dropdown-toggle" data-toggle-menu-id="nav-global-nav-links">
|
||||
<svg fill-rule="nonzero" viewBox="0 0 850.2 162.05">
|
||||
<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 class="me-2" fill-rule="nonzero" viewBox="0 0 200 162.05">
|
||||
<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" />
|
||||
</svg>
|
||||
<svg class="nav-global-icon nav-global-icon-dropdown-toggle" height="100px" width="100px" viewBox="0 0 1000 1000">
|
||||
<path
|
||||
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>
|
||||
<strong class="fs-base me-1">Extensions</strong>
|
||||
<i class="i-chevron-down"></i>
|
||||
</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>
|
||||
<a href="{% url 'extensions:by-type' type_slug='add-ons' %}" class="{% if '/add-ons/' in request.get_full_path %}is-active{% endif %}">
|
||||
Add-ons
|
||||
@ -106,13 +106,13 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="nav-global-links-right">
|
||||
<ul class="flex-grow-1 flex-nowrap justify-content-end ms-0 nav-global-links-right">
|
||||
<li>
|
||||
<button class="js-toggle-theme-btn px-2"><i class="js-toggle-theme-btn-icon i-adjust"></i></button>
|
||||
</li>
|
||||
<li>
|
||||
<search>
|
||||
<form action="{% url "extensions:search" %}" method="GET" class="navbar-search">
|
||||
<li class="flex-grow-1 navbar-search-helper">
|
||||
<search class="w-100">
|
||||
<form action="{% url "extensions:search" %}" method="GET" class="me-0 ms-0 navbar-search">
|
||||
<input type="text" name="q" class="form-control"
|
||||
{% if request.GET.q %}
|
||||
value="{{ request.GET.q }}"
|
||||
@ -129,7 +129,7 @@
|
||||
</li>
|
||||
|
||||
{% block nav-upload %}
|
||||
<li>
|
||||
<li class="d-none d-xl-flex">
|
||||
<a href="{% url 'extensions:submit' %}" class="btn btn-primary">
|
||||
<i class="i-upload"></i>
|
||||
<span>Upload Extension</span>
|
||||
@ -138,9 +138,14 @@
|
||||
{% endblock nav-upload %}
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<li>
|
||||
<a class="btn btn-link px-2" href="{% url 'notifications:notifications' %}">
|
||||
<i class="i-bell {% if user|unread_notification_count %}text-accent{% endif %}"></i>
|
||||
<li class="nav-item">
|
||||
<a class="btn btn-link position-relative px-2" href="{% url 'notifications:notifications' %}">
|
||||
{% 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>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
@ -165,6 +170,12 @@
|
||||
<li class="dropdown-divider"></li>
|
||||
{% 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>
|
||||
<a href="{% url 'extensions:manage-list' %}" class="dropdown-item">
|
||||
<i class="i-puzzle"></i> {% trans 'My Extensions' %}
|
||||
|
@ -1,38 +1,40 @@
|
||||
{% load i18n common %}
|
||||
{% if num_pages > 1 %}
|
||||
<ol class="pagination">
|
||||
<ul class="pagination">
|
||||
{% if pager.has_previous %}
|
||||
<li>
|
||||
<a rel="prev" href="{{ pager.url|urlparams:pager.previous_page_number }}">
|
||||
{{ _('Prev') }}
|
||||
</a>
|
||||
<li class="page-item page-first">
|
||||
<a href="?page=1">{% trans "First" %}</a>
|
||||
</li>
|
||||
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if pager.dotted_lower %}
|
||||
<li><a href="{{ pager.url|urlparams:1 }}">{{ 1 }}</a></li>
|
||||
<li class="skip">…</li>
|
||||
{% endif %}
|
||||
|
||||
{% for x in pager.page_range %}
|
||||
<li {{ x|class_selected:pager.number }}>
|
||||
<a href="{{ pager.url|urlparams:x }}">{{ x }}</a>
|
||||
<li class="page-item {{ x|class_selected:pager.number }}">
|
||||
<a href="?page={{ x }}">{{ x }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if pager.dotted_upper %}
|
||||
<li class="skip">…</li>
|
||||
<li><a href="{{ pager.url|urlparams:num_pages }}">{{ num_pages }}</a></li>
|
||||
{% endif %}
|
||||
|
||||
{% if pager.has_next %}
|
||||
<li>
|
||||
<a rel="next" href="{{ pager.url|urlparams:pager.next_page_number }}">
|
||||
{{ _('Next') }}
|
||||
</a>
|
||||
<li class="page-item page-next">
|
||||
<a href="?page={{ pager.next_page_number }}" rel="next">{% trans "Next" %} <i class="i-chevron-right"></i></a>
|
||||
</li>
|
||||
|
||||
<li class="page-item page-last">
|
||||
<a href="?page={{ num_pages }}">{% trans "Last" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ol>
|
||||
</ul>
|
||||
|
||||
{# TODO: add paginator page count if needed #}
|
||||
{% comment %}
|
||||
<div class="num-results">
|
||||
{% blocktranslate with begin=pager.start_index end=pager.end_index count=count %}
|
||||
Results <strong>{{ begin }}</strong>–<strong>{{ end }}</strong>
|
||||
of <strong>{{ count }}</strong>
|
||||
{% endblocktranslate %}
|
||||
</div>
|
||||
{% endcomment %}
|
||||
{% endif %}
|
||||
|
@ -76,7 +76,7 @@ def urlparams(url, page, *args, **kwargs):
|
||||
@register.filter
|
||||
def class_selected(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
|
||||
|
@ -6,7 +6,7 @@ from mdgen import MarkdownPostProvider
|
||||
import factory
|
||||
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
|
||||
|
||||
fake_markdown = Faker()
|
||||
@ -19,6 +19,7 @@ class ExtensionFactory(DjangoModelFactory):
|
||||
|
||||
name = factory.Faker('catch_phrase')
|
||||
extension_id = factory.Faker('slug')
|
||||
slug = factory.Faker('slug')
|
||||
description = factory.LazyAttribute(
|
||||
lambda _: fake_markdown.post(size=random.choice(('medium', 'large')))
|
||||
)
|
||||
@ -68,11 +69,31 @@ class VersionFactory(DjangoModelFactory):
|
||||
download_count = factory.Faker('random_int')
|
||||
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(
|
||||
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
|
||||
def tags(self, create, extracted, **kwargs):
|
||||
if not create:
|
||||
@ -87,7 +108,10 @@ class VersionFactory(DjangoModelFactory):
|
||||
|
||||
def create_version(**kwargs) -> 'Version':
|
||||
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
|
||||
|
||||
|
||||
|
@ -1,27 +1,27 @@
|
||||
from factory.django import DjangoModelFactory
|
||||
import factory
|
||||
import factory.fuzzy
|
||||
import secrets
|
||||
import hashlib
|
||||
|
||||
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 Meta:
|
||||
model = File
|
||||
|
||||
original_name = factory.LazyAttribute(lambda x: x.source)
|
||||
original_hash = factory.LazyFunction(lambda: generate_random_sha256())
|
||||
hash = factory.LazyAttribute(lambda x: x.original_hash)
|
||||
original_hash = factory.Faker('lexify', text='fakehash:??????????????????', letters='deadbeef')
|
||||
hash = factory.Faker('lexify', text='fakehash:??????????????????', letters='deadbeef')
|
||||
size_bytes = factory.Faker('random_int')
|
||||
source = factory.Faker('file_name', extension='zip')
|
||||
|
||||
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
|
||||
|
@ -14,7 +14,7 @@ class OAuthUserInfoFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
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')
|
||||
|
||||
@ -23,7 +23,7 @@ class OAuthUserTokenFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
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')
|
||||
|
||||
@ -32,7 +32,17 @@ class UserFactory(DjangoModelFactory):
|
||||
class Meta:
|
||||
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')
|
||||
username = factory.LazyAttribute(
|
||||
|
@ -87,3 +87,25 @@ def _get_all_form_errors(response):
|
||||
if response.context
|
||||
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'))
|
||||
|
@ -107,5 +107,5 @@ ABUSE_TYPE = Choices(
|
||||
# thumbnails of existing images must exist in MEDIA_ROOT before
|
||||
# the code expecting thumbnails of new dimensions can be deployed!
|
||||
THUMBNAIL_SIZES = {'1080p': [1920, 1080], '360p': [640, 360]}
|
||||
THUMBNAIL_FORMAT = 'PNG'
|
||||
THUMBNAIL_FORMAT = 'WEBP'
|
||||
THUMBNAIL_QUALITY = 83
|
||||
|
@ -13,13 +13,13 @@ log = logging.getLogger(__name__)
|
||||
|
||||
class MaintainerInline(admin.TabularInline):
|
||||
model = Maintainer
|
||||
raw_id_fields = ('user',)
|
||||
autocomplete_fields = ('user',)
|
||||
extra = 0
|
||||
|
||||
|
||||
class PreviewInline(NoAddDeleteMixin, admin.TabularInline):
|
||||
model = Extension.previews.through
|
||||
raw_id_fields = ('file',)
|
||||
autocomplete_fields = ('file',)
|
||||
show_change_link = True
|
||||
can_add = False
|
||||
extra = 0
|
||||
@ -34,21 +34,41 @@ class VersionInline(NoAddDeleteMixin, admin.TabularInline):
|
||||
|
||||
|
||||
class ExtensionAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = 'date_created'
|
||||
list_display = (
|
||||
'__str__',
|
||||
'type',
|
||||
'status',
|
||||
'date_created',
|
||||
'download_count',
|
||||
'view_count',
|
||||
'average_score',
|
||||
)
|
||||
list_filter = ('type', 'status')
|
||||
search_fields = ('id', '^slug', 'name')
|
||||
list_filter = (
|
||||
'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)
|
||||
readonly_fields = (
|
||||
'id',
|
||||
'type',
|
||||
'name',
|
||||
'slug',
|
||||
'date_created',
|
||||
'date_status_changed',
|
||||
@ -60,9 +80,10 @@ class ExtensionAdmin(admin.ModelAdmin):
|
||||
'download_count',
|
||||
'view_count',
|
||||
'website',
|
||||
'icon',
|
||||
'featured_image',
|
||||
)
|
||||
raw_id_fields = ('team',)
|
||||
autocomplete_fields = ('icon', 'featured_image')
|
||||
autocomplete_fields = ('team',)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
@ -70,7 +91,7 @@ class ExtensionAdmin(admin.ModelAdmin):
|
||||
{
|
||||
'fields': (
|
||||
('team',),
|
||||
('id', 'type'),
|
||||
('id', 'type', 'extension_id'),
|
||||
(
|
||||
'date_created',
|
||||
'date_status_changed',
|
||||
@ -114,6 +135,7 @@ class ExtensionAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
class VersionAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = 'date_created'
|
||||
list_display = (
|
||||
'__str__',
|
||||
'extension',
|
||||
@ -124,12 +146,23 @@ class VersionAdmin(admin.ModelAdmin):
|
||||
'file__status',
|
||||
'blender_version_min',
|
||||
'blender_version_max',
|
||||
'permissions',
|
||||
'date_created',
|
||||
'date_modified',
|
||||
'licenses',
|
||||
'tags',
|
||||
'permissions',
|
||||
'platforms',
|
||||
)
|
||||
search_fields = ('id', 'extension__slug', 'extension__name')
|
||||
raw_id_fields = ('extension', 'file')
|
||||
search_fields = (
|
||||
'id',
|
||||
'extension__slug',
|
||||
'extension__name',
|
||||
'extension__extension_id',
|
||||
'file__user__email',
|
||||
'file__user__full_name',
|
||||
'file__user__username',
|
||||
)
|
||||
autocomplete_fields = ('extension', 'file')
|
||||
readonly_fields = (
|
||||
'id',
|
||||
'tagline',
|
||||
@ -156,6 +189,7 @@ class VersionAdmin(admin.ModelAdmin):
|
||||
'tags',
|
||||
'file',
|
||||
'permissions',
|
||||
'platforms',
|
||||
),
|
||||
},
|
||||
),
|
||||
@ -192,6 +226,10 @@ class LicenseAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'slug', 'url')
|
||||
|
||||
|
||||
class PlatformAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'slug')
|
||||
|
||||
|
||||
class TagAdmin(admin.ModelAdmin):
|
||||
model = Tag
|
||||
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.Maintainer, MaintainerAdmin)
|
||||
admin.site.register(models.License, LicenseAdmin)
|
||||
admin.site.register(models.Platform, PlatformAdmin)
|
||||
admin.site.register(models.Tag, TagAdmin)
|
||||
admin.site.register(models.VersionPermission, VersionPermissionAdmin)
|
||||
|
@ -1,12 +1,14 @@
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from constants.base import (
|
||||
ALLOWED_FEATURED_IMAGE_MIMETYPES,
|
||||
ALLOWED_ICON_MIMETYPES,
|
||||
ALLOWED_PREVIEW_MIMETYPES,
|
||||
FILE_STATUS_CHOICES,
|
||||
)
|
||||
|
||||
import extensions.models
|
||||
@ -48,12 +50,15 @@ class AddPreviewFileForm(files.forms.BaseMediaFileForm):
|
||||
class Meta(files.forms.BaseMediaFileForm.Meta):
|
||||
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)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.base_fields['source'].required = True
|
||||
self.base_fields['caption'].widget.attrs.update({'placeholder': 'Describe the preview'})
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['source'].allow_empty_file = False
|
||||
self.fields['source'].required = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""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'
|
||||
)
|
||||
msg_need_previews = _('Please add at least one preview.')
|
||||
msg_duplicate_file = _('Please select another file instead of the duplicate.')
|
||||
|
||||
class Meta:
|
||||
model = extensions.models.Extension
|
||||
@ -175,6 +181,21 @@ class ExtensionUpdateForm(forms.ModelForm):
|
||||
self.icon_form.is_valid(),
|
||||
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)
|
||||
|
||||
def clean(self):
|
||||
@ -202,8 +223,14 @@ class ExtensionUpdateForm(forms.ModelForm):
|
||||
# Featured image and icon are only required when ready for review,
|
||||
# and can be empty or unchanged.
|
||||
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()
|
||||
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()
|
||||
|
||||
if getattr(self.instance, 'converted_to_draft', False):
|
||||
@ -266,4 +293,20 @@ class IconForm(files.forms.BaseMediaFileForm):
|
||||
prefix = 'icon'
|
||||
to_field = 'icon'
|
||||
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
|
||||
|
@ -24,6 +24,7 @@ addons_tags = (
|
||||
'Add Mesh',
|
||||
'Add Curve',
|
||||
'Animation',
|
||||
'Bake',
|
||||
'Compositing',
|
||||
'Development',
|
||||
'Game Engine',
|
||||
@ -40,6 +41,7 @@ addons_tags = (
|
||||
'Render',
|
||||
'Rigging',
|
||||
'Scene',
|
||||
'Sculpt',
|
||||
'Sequencer',
|
||||
'System',
|
||||
'Text Editor',
|
||||
|
@ -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',
|
||||
),
|
||||
]
|
38
extensions/migrations/0030_platform_version_platforms.py
Normal file
38
extensions/migrations/0030_platform_version_platforms.py
Normal 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),
|
||||
]
|
@ -1,12 +1,11 @@
|
||||
from typing import List
|
||||
from statistics import median
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
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.models import F, Q, Count
|
||||
from django.db.models import Q, Count
|
||||
from django.urls import reverse
|
||||
|
||||
from common.fields import FilterableManyToManyField
|
||||
@ -100,6 +99,23 @@ class License(CreatedModifiedMixin, models.Model):
|
||||
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):
|
||||
@property
|
||||
def listed(self):
|
||||
@ -200,25 +216,28 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
|
||||
def status_slug(self) -> str:
|
||||
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):
|
||||
self.clean()
|
||||
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
|
||||
def approve(self, reviewer=None):
|
||||
"""TODO: Approve an extension which is currently in review."""
|
||||
@ -439,8 +458,9 @@ class VersionManager(models.Manager):
|
||||
|
||||
def update_or_create(self, *args, **kwargs):
|
||||
# Stash the ManyToMany to be created after the Version has a valid ID already
|
||||
permissions = kwargs.pop('permissions', [])
|
||||
licenses = kwargs.pop('licenses', [])
|
||||
permissions = kwargs.pop('permissions', [])
|
||||
platforms = kwargs.pop('platforms', [])
|
||||
tags = kwargs.pop('tags', [])
|
||||
|
||||
version, result = super().update_or_create(*args, **kwargs)
|
||||
@ -448,6 +468,7 @@ class VersionManager(models.Manager):
|
||||
# Add the ManyToMany to the already initialized Version
|
||||
version.set_initial_licenses(licenses)
|
||||
version.set_initial_permissions(permissions)
|
||||
version.set_initial_platforms(platforms)
|
||||
version.set_initial_tags(tags)
|
||||
return version, result
|
||||
|
||||
@ -516,6 +537,7 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@ -544,6 +566,14 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
|
||||
permission = VersionPermission.get_by_slug(permission_name)
|
||||
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):
|
||||
if not _licenses:
|
||||
return
|
||||
@ -588,20 +618,6 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
|
||||
reasons.append('version_has_ratings')
|
||||
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
|
||||
def download_name(self) -> str:
|
||||
"""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 []
|
||||
|
||||
|
||||
class ExtensionReviewerFlags(models.Model):
|
||||
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
|
||||
def __str__(self) -> str:
|
||||
return f'Preview {self.pk} of extension {self.extension_id}: {self.file}'
|
||||
|
@ -16,6 +16,22 @@ logger = logging.getLogger(__name__)
|
||||
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.Preview)
|
||||
@receiver(pre_delete, sender=extensions.models.Version)
|
||||
@ -29,29 +45,6 @@ def _log_deletion(
|
||||
instance.record_deletion()
|
||||
|
||||
|
||||
def _delete_file(f, sender, instance, rel):
|
||||
source = f.source.name
|
||||
args = {'f_id': f.pk, 'h': f.hash, 'pk': instance.pk, 'sender': sender, 's': source, 'r': rel}
|
||||
logger.info('Deleting %(r)s file pk=%(f_id)s s=%(s)s hash=%(h)s of %(sender)s pk=%(pk)s', args)
|
||||
f.delete()
|
||||
|
||||
|
||||
@receiver(post_delete, sender=extensions.models.Preview)
|
||||
@receiver(post_delete, sender=extensions.models.Version)
|
||||
def _delete_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.Version)
|
||||
def _record_changes(
|
||||
@ -89,20 +82,20 @@ def extension_should_be_listed(extension):
|
||||
def _set_is_listed(
|
||||
sender: object,
|
||||
instance: Union[extensions.models.Extension, extensions.models.Version, files.models.File],
|
||||
raw: bool,
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> None:
|
||||
if raw:
|
||||
return
|
||||
|
||||
if isinstance(instance, extensions.models.Extension):
|
||||
extension = instance
|
||||
elif isinstance(instance, extensions.models.Version):
|
||||
extension = instance.extension
|
||||
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.
|
||||
if not hasattr(instance, 'version'):
|
||||
return
|
||||
extension = instance.extension
|
||||
if not extension:
|
||||
return
|
||||
|
||||
old_is_listed = extension.is_listed
|
||||
new_is_listed = extension_should_be_listed(extension)
|
||||
@ -202,3 +195,22 @@ def _create_approval_activity_for_new_version_if_listed(
|
||||
extension=instance.extension,
|
||||
message=f'uploaded new version: {instance.version}',
|
||||
).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)
|
||||
|
@ -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">
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
@ -62,6 +62,9 @@ function appendImageUploadForm() {
|
||||
setTimeout(function() {
|
||||
// TODO: fix jump coming from grid gap on parent
|
||||
formRow.classList.add('show');
|
||||
|
||||
// Reinit function clickThumbnail
|
||||
clickThumbnail();
|
||||
}, 20);
|
||||
}
|
||||
|
||||
@ -81,6 +84,36 @@ btnAddImage.addEventListener('click', function(ev) {
|
||||
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
|
||||
function removeImgUploadForm() {
|
||||
const btnRemoveImgUploadForm = document.querySelectorAll('.js-btn-remove-img-upload-form');
|
||||
@ -170,6 +203,7 @@ function setImgUploadFormThumbnail() {
|
||||
// Create function init
|
||||
function init() {
|
||||
addImgUploadFormClasses();
|
||||
clickThumbnail();
|
||||
resetImgUploadForm();
|
||||
setImgUploadFormThumbnail();
|
||||
}
|
||||
|
@ -23,7 +23,7 @@
|
||||
title="{{ version.blender_version_max }}">{{ version.blender_version_max|version_without_patch }}</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% trans 'and newer' %}
|
||||
{% trans 'and newer' %}
|
||||
{% endif %}
|
||||
<a href="{{ version.extension.get_review_url }}?report_compatibility_issue&version={{ version.version }}#id_message" title="{% trans 'Report compatibility issue' %}"><i class="i-flag"></i></a>
|
||||
{% endif %}
|
||||
|
@ -1,14 +1,23 @@
|
||||
{% load common filters static %}
|
||||
{% static "common/images/no-image_640x360.png" as featured_image_missing %}
|
||||
{% with latest=extension.latest_version %}
|
||||
{% firstof extension.featured_image.thumbnail_360p_url featured_image_missing as thumbnail_360p_url %}
|
||||
{% with latest=extension.latest_version type_display=extension.get_type_display %}
|
||||
<div class="cards-item">
|
||||
<div class="cards-item-content">
|
||||
<a href="{{ extension.get_absolute_url }}">
|
||||
{% with featured_image=extension.featured_image.thumbnail_360p_url %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
</a>
|
||||
<div class="cards-item-headline">
|
||||
{{ type_display }}
|
||||
</div>
|
||||
<h3 class="cards-item-title">
|
||||
<a href="{{ extension.get_absolute_url }}">{{ extension.name }}</a>
|
||||
</h3>
|
||||
@ -48,15 +57,15 @@
|
||||
|
||||
{% if show_type %}
|
||||
<li class="ms-auto">
|
||||
{{ extension.get_type_display }}
|
||||
{{ type_display }}
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
{% if latest.tags.count %}
|
||||
<ul>
|
||||
<ul class="flex-wrap">
|
||||
{% for tag in latest.tags.all %}
|
||||
<li>
|
||||
<li class="mb-1">
|
||||
{% include "extensions/components/badge_tag.html" with small=True version=latest %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
@ -109,7 +109,7 @@
|
||||
</div>
|
||||
|
||||
<div class="dl-row">
|
||||
<dd>
|
||||
<dd class="ext-detail-info-tags">
|
||||
{% if version.tags.count %}
|
||||
{% include "extensions/components/tags.html" with small=True version=version %}
|
||||
{% else %}
|
||||
|
10
extensions/templates/extensions/components/platforms.html
Normal file
10
extensions/templates/extensions/components/platforms.html
Normal 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 %}
|
@ -180,7 +180,10 @@
|
||||
<div class="dl-row">
|
||||
<div class="dl-col">
|
||||
<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>
|
||||
|
||||
@ -219,7 +222,9 @@
|
||||
|
||||
{% if latest.tags.count %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
@ -154,5 +154,3 @@
|
||||
{% block scripts %}
|
||||
{% javascript "extensions" %}
|
||||
{% endblock scripts %}
|
||||
|
||||
{% block footer %}{# no footer here #}{% endblock footer %}
|
||||
|
@ -46,6 +46,9 @@
|
||||
{% include "extensions/components/card.html" %}
|
||||
{% endfor %}
|
||||
</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">
|
||||
|
||||
@ -63,6 +66,9 @@
|
||||
{% include "extensions/components/card.html" %}
|
||||
{% endfor %}
|
||||
</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">
|
||||
Got an add-on or theme to share with the community?
|
||||
|
@ -1,30 +1,11 @@
|
||||
{% extends "common/base.html" %}
|
||||
{% 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 %}
|
||||
<div class="row">
|
||||
{% if tags %}
|
||||
<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">
|
||||
<div class="row {% if type == 'Add-ons' %}is-row-add-ons{% elif type == 'Themes' %}is-row-themes{% endif %}">
|
||||
<div class="col-md-12 my-4">
|
||||
{% if author %}
|
||||
<h2>{% blocktranslate %}Extensions by{% endblocktranslate %} <em class="search-highlight">{{ author }}</em></h2>
|
||||
{% 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>
|
||||
{% 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="col">
|
||||
{% 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 %}
|
||||
{% include "extensions/components/card.html" with show_type=False %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>
|
||||
<div class="mt-3">
|
||||
<p class="pt-3 text-center">
|
||||
{% blocktranslate %}No extensions found.{% endblocktranslate %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
|
@ -20,7 +20,7 @@
|
||||
{% include "common/components/field.html" with field=inlineform.caption label='Caption' placeholder="Describe the preview" %}
|
||||
</div>
|
||||
<div class="align-items-center d-flex js-input-img-helper justify-content-between">
|
||||
{% include "common/components/field.html" with field=inlineform.source label='File' %}
|
||||
{% include "common/components/field.html" with classes="form-control-sm" field=inlineform.source label='File' %}
|
||||
<ul class="pt-0">
|
||||
<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>
|
||||
|
@ -2,15 +2,14 @@
|
||||
{# Handles displaying and editing the featured image #}
|
||||
{% with inlineform=image_form|add_form_classes %}
|
||||
{% with current_file=inlineform.instance.source %}
|
||||
<div class="{{ image_form.prefix }}-preview"
|
||||
<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 %}');"
|
||||
title="{{ label }} of the extension">
|
||||
<i class="i-image js-i-image"></i>
|
||||
</div>
|
||||
{% for field in inlineform %}
|
||||
{% if field.name == "source" %}
|
||||
<small>
|
||||
{% include "common/components/field.html" with label=label help_text=help_text %}
|
||||
</small>
|
||||
{% include "common/components/field.html" with classes="form-control-sm" label=label help_text=help_text %}
|
||||
{% else %}
|
||||
{% include "common/components/field.html" %}
|
||||
{% endif %}
|
||||
@ -20,13 +19,39 @@
|
||||
(function() {
|
||||
const input = document.getElementById('id_{{ image_form.prefix }}-source');
|
||||
const previewEl = document.getElementsByClassName('{{ image_form.prefix }}-preview')[0];
|
||||
const previewElIcon = previewEl.querySelector('.js-i-image');
|
||||
|
||||
input.addEventListener('change', function() {
|
||||
const curFiles = input.files;
|
||||
if (curFiles.length > 0) {
|
||||
const dataUrl = URL.createObjectURL(curFiles[0]);
|
||||
|
||||
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>
|
||||
{% endwith %}
|
||||
|
@ -25,7 +25,7 @@
|
||||
<div class="row mt-4">
|
||||
<div class="col">
|
||||
{% if object_list %}
|
||||
<div class="cards">
|
||||
<div class="cards cards-lg-4 cards-md-3 cards-sm-2">
|
||||
{% for extension in object_list %}
|
||||
{% include "extensions/manage/components/card.html" with show_type=True %}
|
||||
{% endfor %}
|
||||
|
@ -121,10 +121,14 @@
|
||||
<span>{% trans 'Version History' %}</span>
|
||||
</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">
|
||||
<i class="i-trash"></i>
|
||||
<span>{% trans 'Delete Extension' %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
<a href="{% url 'admin:extensions_extension_change' extension.pk %}" class="btn btn-admin">
|
||||
@ -135,16 +139,6 @@
|
||||
</section>
|
||||
</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>
|
||||
|
@ -56,7 +56,10 @@
|
||||
<div class="dl-row">
|
||||
<div class="dl-col">
|
||||
<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 class="dl-row">
|
||||
@ -94,7 +97,7 @@
|
||||
{% endif %}
|
||||
</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">
|
||||
<i class="i-download"></i>
|
||||
<span>{% trans 'Download' %} v{{ version.version }}</span>
|
||||
|
@ -15,25 +15,23 @@ def naturaltime_compact(time):
|
||||
# Take only the first part, e.g. "3 days, 2h ago", becomes " 3d ago"
|
||||
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('a minute', '1m')
|
||||
compact_time = compact_time.replace(' second', ' s')
|
||||
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('an hour', '1h') # Exactly 1 hour.
|
||||
compact_time = compact_time.replace('a minute', '1 m')
|
||||
compact_time = compact_time.replace(' minute', ' m')
|
||||
compact_time = compact_time.replace(' hours', ' h')
|
||||
compact_time = compact_time.replace('1 day', '1d')
|
||||
compact_time = compact_time.replace(' days ago', 'd ago')
|
||||
compact_time = compact_time.replace(' days', 'd ago')
|
||||
compact_time = compact_time.replace('an hour', '1 h')
|
||||
compact_time = compact_time.replace(' hour', ' h')
|
||||
compact_time = compact_time.replace(' days', ' 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('1 month', '1mo')
|
||||
compact_time = compact_time.replace(' week', ' w')
|
||||
compact_time = compact_time.replace(' months', ' 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(' year', ' y')
|
||||
|
||||
return compact_time
|
||||
|
||||
|
BIN
extensions/tests/files/invalid-missing-wheels.zip
Normal file
BIN
extensions/tests/files/invalid-missing-wheels.zip
Normal file
Binary file not shown.
@ -54,11 +54,11 @@ class DeleteTest(TestCase):
|
||||
map(
|
||||
repr,
|
||||
[
|
||||
preview_file,
|
||||
version_file,
|
||||
file_validation,
|
||||
preview_file,
|
||||
extension,
|
||||
approval_activity,
|
||||
file_validation,
|
||||
preview_file.preview,
|
||||
version,
|
||||
],
|
||||
|
@ -1,6 +1,7 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.core.exceptions import ValidationError
|
||||
import factory
|
||||
|
||||
from common.tests.factories.extensions import create_approved_version
|
||||
from common.tests.factories.files import FileFactory
|
||||
@ -63,6 +64,13 @@ class CreateFileTest(TestCase):
|
||||
file=FileFactory(
|
||||
type=File.TYPES.BPY,
|
||||
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('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):
|
||||
"""Test if we try to add a new version to an extension with a mismatched extension_id"""
|
||||
self.assertEqual(Extension.objects.count(), 0)
|
||||
@ -240,6 +274,13 @@ class ValidateManifestTest(CreateFileTest):
|
||||
file=FileFactory(
|
||||
type=File.TYPES.BPY,
|
||||
status=File.STATUSES.APPROVED,
|
||||
metadata=factory.Dict(
|
||||
{
|
||||
'name': factory.Faker('name'),
|
||||
'support': factory.Faker('url'),
|
||||
'website': factory.Faker('url'),
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
@ -22,6 +22,11 @@ class ExtensionTest(TestCase):
|
||||
extension__name='Extension name',
|
||||
extension__status=Extension.STATUSES.INCOMPLETE,
|
||||
extension__support='https://example.com/',
|
||||
file__metadata={
|
||||
'name': 'Extension name',
|
||||
'support': 'https://example.com/',
|
||||
'website': 'https://example.com/',
|
||||
},
|
||||
).extension
|
||||
self.assertEqual(entries_for(self.extension).count(), 0)
|
||||
self.assertIsNone(self.extension.date_approved)
|
||||
@ -127,3 +132,76 @@ class VersionTest(TestCase):
|
||||
response = self.client.get(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/')
|
||||
|
@ -7,7 +7,7 @@ from django.urls import reverse_lazy
|
||||
from common.tests.factories.extensions import create_version
|
||||
from common.tests.factories.files import FileFactory
|
||||
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 files.models import File
|
||||
from reviewers.models import ApprovalActivity
|
||||
@ -84,6 +84,11 @@ EXPECTED_VALIDATION_ERRORS = {
|
||||
},
|
||||
'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-missing-wheels.zip': {
|
||||
'source': [
|
||||
'A declared wheel is missing in the zip file, expected path: addon/./wheels/test-wheel-whatever.whl'
|
||||
]
|
||||
},
|
||||
}
|
||||
POST_DATA = {
|
||||
'preview_set-TOTAL_FORMS': ['0'],
|
||||
@ -169,13 +174,13 @@ class SubmitFileTest(TestCase):
|
||||
user = UserFactory()
|
||||
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 open(TEST_FILES_DIR / test_archive, 'rb') as fp:
|
||||
response = self.client.post(self.url, {'source': fp, 'agreed_with_terms': True})
|
||||
|
||||
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):
|
||||
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(File.objects.count(), 1)
|
||||
file = File.objects.first()
|
||||
self.assertIsNotNone(file.extension_id)
|
||||
self.assertEqual(response['Location'], file.get_submit_url())
|
||||
self.assertEqual(file.user, user)
|
||||
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
|
||||
fixtures = ['licenses']
|
||||
|
||||
@ -319,7 +325,7 @@ class SubmitFinaliseTest(TestCase):
|
||||
self.assertEqual(File.objects.count(), 1)
|
||||
self.assertEqual(Extension.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)
|
||||
data = {
|
||||
@ -372,7 +378,10 @@ class SubmitFinaliseTest(TestCase):
|
||||
self.assertEqual(File.objects.filter(type=File.TYPES.BPY).count(), 1)
|
||||
self.assertEqual(Extension.objects.count(), 1)
|
||||
self.assertEqual(Version.objects.count(), 1)
|
||||
self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 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
|
||||
extension = Extension.objects.first()
|
||||
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.schema_version, '1.0.0')
|
||||
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)
|
||||
|
||||
# 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
|
||||
response = self.client.get(response['Location'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
@ -1,10 +1,11 @@
|
||||
from pathlib import Path
|
||||
import io
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from common.tests.factories.extensions import create_approved_version, create_version
|
||||
from common.tests.factories.files import FileFactory
|
||||
from common.tests.utils import _get_all_form_errors
|
||||
from common.tests.factories.files import FileFactory, ImageFactory
|
||||
from common.tests.utils import _get_all_form_errors, CheckFilePropertiesMixin
|
||||
from extensions.models import Extension
|
||||
from files.models import File
|
||||
from reviewers.models import ApprovalActivity
|
||||
@ -36,7 +37,7 @@ POST_DATA = {
|
||||
}
|
||||
|
||||
|
||||
class UpdateTest(TestCase):
|
||||
class UpdateTest(CheckFilePropertiesMixin, TestCase):
|
||||
fixtures = ['dev', 'licenses']
|
||||
|
||||
def test_get_manage_page(self):
|
||||
@ -95,6 +96,45 @@ class UpdateTest(TestCase):
|
||||
self.assertTrue(file1.source.url.endswith('.png'))
|
||||
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):
|
||||
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'],
|
||||
],
|
||||
)
|
||||
@ -243,6 +286,29 @@ class UpdateTest(TestCase):
|
||||
{'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):
|
||||
extension = create_approved_version().extension
|
||||
|
||||
@ -320,6 +386,95 @@ class UpdateTest(TestCase):
|
||||
{'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):
|
||||
version = create_version(extension__status=Extension.STATUSES.AWAITING_REVIEW)
|
||||
extension = version.extension
|
||||
|
@ -7,6 +7,7 @@ from common.tests.factories.extensions import create_version, create_approved_ve
|
||||
from common.tests.factories.users import UserFactory
|
||||
from extensions.models import Extension, Version
|
||||
from files.models import File
|
||||
from teams.models import Team
|
||||
|
||||
|
||||
def _create_extension():
|
||||
@ -19,6 +20,11 @@ def _create_extension():
|
||||
extension__website='https://example.com/',
|
||||
extension__status=Extension.STATUSES.INCOMPLETE,
|
||||
extension__average_score=2.5,
|
||||
file__metadata={
|
||||
'name': 'Test Add-on',
|
||||
'support': 'https://example.com/issues/',
|
||||
'website': 'https://example.com/',
|
||||
},
|
||||
).extension
|
||||
|
||||
|
||||
@ -107,6 +113,18 @@ class ApiViewsTest(_BaseTestCase):
|
||||
).json()
|
||||
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):
|
||||
version = create_approved_version(blender_version_min='4.0.1')
|
||||
version.date_created
|
||||
@ -139,6 +157,20 @@ class ApiViewsTest(_BaseTestCase):
|
||||
# we are expecting the latest matching, not the maximum version
|
||||
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):
|
||||
def test_cannot_view_unlisted_extension_anonymously(self):
|
||||
|
@ -7,7 +7,7 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -20,7 +20,10 @@ log = logging.getLogger(__name__)
|
||||
|
||||
class ListedExtensionsSerializer(serializers.ModelSerializer):
|
||||
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:
|
||||
@ -30,16 +33,22 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.request = kwargs.pop('request', None)
|
||||
self.blender_version = kwargs.pop('blender_version', None)
|
||||
self.platform = kwargs.pop('platform', None)
|
||||
self._validate()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _validate(self):
|
||||
if self.blender_version is None:
|
||||
return
|
||||
if self.blender_version:
|
||||
try:
|
||||
version(self.blender_version)
|
||||
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):
|
||||
matching_version = None
|
||||
@ -52,18 +61,19 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
|
||||
if not versions:
|
||||
return None
|
||||
versions = sorted(versions, key=lambda v: v.date_created, reverse=True)
|
||||
if self.blender_version:
|
||||
for v in versions:
|
||||
if is_in_version_range(
|
||||
if self.blender_version and not is_in_version_range(
|
||||
self.blender_version,
|
||||
v.blender_version_min,
|
||||
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
|
||||
break
|
||||
else:
|
||||
# same as latest_version, but without triggering a new queryset
|
||||
matching_version = versions[0]
|
||||
|
||||
if not matching_version:
|
||||
return None
|
||||
@ -82,9 +92,10 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
|
||||
'blender_version_max': matching_version.blender_version_max,
|
||||
'website': self.request.build_absolute_uri(instance.get_absolute_url()),
|
||||
# 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()],
|
||||
'permissions': [permission.slug for permission in matching_version.permissions.all()],
|
||||
'platforms': [platform.slug for platform in matching_version.platforms.all()],
|
||||
# TODO: handle copyright
|
||||
'tags': [str(tag) for tag in matching_version.tags.all()],
|
||||
}
|
||||
@ -101,21 +112,33 @@ class ExtensionsAPIView(APIView):
|
||||
name="blender_version",
|
||||
description=("Blender version to check for compatibility"),
|
||||
type=str,
|
||||
)
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="platform",
|
||||
description=("Platform to check for compatibility"),
|
||||
type=str,
|
||||
),
|
||||
]
|
||||
)
|
||||
def get(self, request):
|
||||
blender_version = request.GET.get('blender_version')
|
||||
platform = request.GET.get('platform')
|
||||
qs = Extension.objects.listed.prefetch_related(
|
||||
'authors',
|
||||
'team',
|
||||
'versions',
|
||||
'versions__file',
|
||||
'versions__licenses',
|
||||
'versions__permissions',
|
||||
'versions__platforms',
|
||||
'versions__tags',
|
||||
).all()
|
||||
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]
|
||||
return Response(
|
||||
|
@ -45,6 +45,7 @@ class ExtensionDetailView(ExtensionQuerysetMixin, DetailView):
|
||||
'versions__file',
|
||||
'versions__file__validation',
|
||||
'versions__permissions',
|
||||
'versions__platforms',
|
||||
)
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
|
@ -29,7 +29,7 @@ class ListedExtensionsView(ListView):
|
||||
|
||||
|
||||
class HomeView(ListedExtensionsView):
|
||||
paginate_by = 15
|
||||
paginate_by = 16
|
||||
template_name = 'extensions/home.html'
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
@ -55,10 +55,8 @@ class HomeView(ListedExtensionsView):
|
||||
'versions__tags',
|
||||
)
|
||||
)
|
||||
context['addons'] = q.filter(type=EXTENSION_TYPE_CHOICES.BPY).order_by('-average_score')[:8]
|
||||
context['themes'] = q.filter(type=EXTENSION_TYPE_CHOICES.THEME).order_by('-average_score')[
|
||||
:8
|
||||
]
|
||||
context['addons'] = q.filter(type=EXTENSION_TYPE_CHOICES.BPY)[:8]
|
||||
context['themes'] = q.filter(type=EXTENSION_TYPE_CHOICES.THEME)[:8]
|
||||
return context
|
||||
|
||||
|
||||
@ -71,7 +69,7 @@ def extension_version_download(request, type_slug, slug, version):
|
||||
|
||||
|
||||
class SearchView(ListedExtensionsView):
|
||||
paginate_by = 15
|
||||
paginate_by = 16
|
||||
template_name = 'extensions/list.html'
|
||||
|
||||
def _get_type_id_by_slug(self):
|
||||
@ -97,7 +95,7 @@ class SearchView(ListedExtensionsView):
|
||||
qs = self.request.GET['q'].split()
|
||||
search_query = Q()
|
||||
for token in qs:
|
||||
search_query |= (
|
||||
search_query &= (
|
||||
Q(slug__icontains=token)
|
||||
| Q(name__icontains=token)
|
||||
| Q(description__icontains=token)
|
||||
|
@ -64,6 +64,7 @@ class UploadFileView(LoginRequiredMixin, CreateView):
|
||||
# Need to save the form to be able to use the file to create the version.
|
||||
self.object = self.file = form.save()
|
||||
|
||||
self.file.extension = self.extension
|
||||
Version.objects.update_or_create(
|
||||
extension=self.extension, file=self.file, **self.file.parsed_version_fields
|
||||
)[0]
|
||||
|
@ -3,6 +3,8 @@ import logging
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
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.models
|
||||
|
||||
@ -61,6 +63,7 @@ class FileAdmin(admin.ModelAdmin):
|
||||
kwargs.update({'help_texts': {'metadata': help_text}})
|
||||
return super().get_form(request, obj, **kwargs)
|
||||
|
||||
date_hierarchy = 'date_created'
|
||||
view_on_site = False
|
||||
save_on_top = True
|
||||
|
||||
@ -68,13 +71,25 @@ class FileAdmin(admin.ModelAdmin):
|
||||
'validation__is_ok',
|
||||
'type',
|
||||
'status',
|
||||
'date_status_changed',
|
||||
'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 = (
|
||||
'id',
|
||||
'date_created',
|
||||
@ -85,7 +100,6 @@ class FileAdmin(admin.ModelAdmin):
|
||||
'thumbnails',
|
||||
'thumbnail',
|
||||
'type',
|
||||
'user',
|
||||
'original_hash',
|
||||
'original_name',
|
||||
'hash',
|
||||
@ -99,6 +113,9 @@ class FileAdmin(admin.ModelAdmin):
|
||||
'original_name',
|
||||
'hash',
|
||||
'source',
|
||||
'user__email',
|
||||
'user__full_name',
|
||||
'user__username',
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
@ -140,6 +157,20 @@ class FileAdmin(admin.ModelAdmin):
|
||||
inlines = [FileValidationInlineAdmin]
|
||||
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):
|
||||
return obj.validation.is_ok if hasattr(obj, 'validation') else None
|
||||
|
||||
|
@ -10,6 +10,7 @@ import django.core.exceptions
|
||||
|
||||
from .validators import (
|
||||
ExtensionIDManifestValidator,
|
||||
ExtensionNameManifestValidator,
|
||||
FileMIMETypeValidator,
|
||||
ManifestValidator,
|
||||
)
|
||||
@ -38,11 +39,12 @@ class FileForm(forms.ModelForm):
|
||||
'missing_or_multiple_theme_xml': _('A theme should have exactly one XML file.'),
|
||||
'invalid_zip_archive': msg_only_zip_files,
|
||||
'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:
|
||||
model = files.models.File
|
||||
fields = ('source', 'type', 'metadata', 'agreed_with_terms', 'user')
|
||||
fields = ('source', 'type', 'metadata', 'agreed_with_terms', 'user', 'extension')
|
||||
|
||||
source = forms.FileField(
|
||||
allow_empty_file=False,
|
||||
@ -121,6 +123,7 @@ class FileForm(forms.ModelForm):
|
||||
'size_bytes': source.size,
|
||||
'original_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)
|
||||
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]))
|
||||
if errors:
|
||||
self.add_error('source', errors)
|
||||
@ -148,6 +159,7 @@ class FileForm(forms.ModelForm):
|
||||
if manifest:
|
||||
ManifestValidator(manifest)
|
||||
ExtensionIDManifestValidator(manifest, self.extension)
|
||||
ExtensionNameManifestValidator(manifest, self.extension)
|
||||
|
||||
self.cleaned_data['metadata'] = manifest
|
||||
self.cleaned_data['type'] = EXTENSION_SLUG_TYPES[manifest['type']]
|
||||
@ -161,7 +173,7 @@ class BaseMediaFileForm(forms.ModelForm):
|
||||
fields = ('source', 'original_hash')
|
||||
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):
|
||||
self.request = kwargs.pop('request')
|
||||
@ -193,8 +205,7 @@ class BaseMediaFileForm(forms.ModelForm):
|
||||
def clean_original_hash(self, *args, **kwargs):
|
||||
"""Calculate original hash of the uploaded file."""
|
||||
source = self.cleaned_data.get('source')
|
||||
if not source:
|
||||
return
|
||||
if 'source' in self.changed_data and source:
|
||||
return files.models.File.generate_hash(source)
|
||||
|
||||
def add_error(self, field, error):
|
||||
@ -208,10 +219,13 @@ class BaseMediaFileForm(forms.ModelForm):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""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.original_name = source.name
|
||||
self.instance.size_bytes = source.size
|
||||
|
||||
self.instance.extension = self.extension
|
||||
instance = super().save(*args, **kwargs)
|
||||
|
||||
if hasattr(self, 'to_field'):
|
||||
|
49
files/migrations/0009_file_extension.py
Normal file
49
files/migrations/0009_file_extension.py
Normal 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),
|
||||
]
|
@ -49,6 +49,12 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
|
||||
|
||||
date_approved = 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)
|
||||
thumbnail = models.ImageField(
|
||||
@ -141,7 +147,6 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
|
||||
self.original_hash = _hash or self.original_hash
|
||||
self.hash = _hash or self.hash
|
||||
|
||||
self.full_clean()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
@ -161,10 +166,6 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
|
||||
path = Path(self.source.path)
|
||||
return ''.join(path.suffixes)
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
return self.version.extension
|
||||
|
||||
@property
|
||||
def parsed_extension_fields(self) -> Dict[str, Any]:
|
||||
"""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']
|
||||
return self.thumbnail.storage.url(path)
|
||||
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
|
||||
|
||||
@property
|
||||
|
@ -56,7 +56,6 @@ def make_thumbnails(file_id: int) -> None:
|
||||
|
||||
thumbnails = files.utils.make_thumbnails(source_path, file.hash)
|
||||
|
||||
if not thumbnail_field.name:
|
||||
thumbnail_field.name = thumbnails['1080p']['path']
|
||||
|
||||
update_fields = set()
|
||||
|
@ -4,7 +4,7 @@ import logging
|
||||
|
||||
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
|
||||
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)
|
||||
@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):
|
||||
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):
|
||||
make_thumbnails.task_function(file_id=file.pk)
|
||||
|
||||
@patch('files.utils.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={})
|
||||
|
||||
with self.assertLogs(level=logging.ERROR) as logs:
|
||||
@ -36,9 +46,7 @@ class TasksTest(TestCase):
|
||||
|
||||
@patch('files.utils.make_thumbnails')
|
||||
def test_make_thumbnails_fails_when_not_image_or_video(self, mock_make_thumbnails):
|
||||
file = FileFactory(
|
||||
original_hash='foobar', source='file/source.zip', type=files.models.File.TYPES.THEME
|
||||
)
|
||||
file = FileFactory(type=files.models.File.TYPES.THEME)
|
||||
|
||||
with self.assertLogs(level=logging.ERROR) as logs:
|
||||
make_thumbnails.task_function(file_id=file.pk)
|
||||
@ -54,7 +62,7 @@ class TasksTest(TestCase):
|
||||
@patch('files.utils.resize_image')
|
||||
@patch('files.utils.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={})
|
||||
self.assertIsNone(file.thumbnail.name)
|
||||
self.assertEqual(file.metadata, {})
|
||||
@ -67,13 +75,19 @@ class TasksTest(TestCase):
|
||||
mock_image.open.return_value.close.assert_called_once()
|
||||
|
||||
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(
|
||||
file.metadata,
|
||||
{
|
||||
'thumbnails': {
|
||||
'1080p': {'path': 'thumbnails/fo/foobar_1920x1080.png', 'size': [1920, 1080]},
|
||||
'360p': {'path': 'thumbnails/fo/foobar_640x360.png', 'size': [640, 360]},
|
||||
'1080p': {
|
||||
'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')
|
||||
def test_make_thumbnails_for_video(self, mock_ffmpeg, mock_image, mock_resize_image):
|
||||
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={})
|
||||
self.assertIsNone(file.thumbnail.name)
|
||||
@ -93,20 +107,26 @@ class TasksTest(TestCase):
|
||||
|
||||
mock_ffmpeg.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()
|
||||
|
||||
file.refresh_from_db()
|
||||
# Check that the extracted frame is stored instead of the large thumbnail
|
||||
self.assertEqual(file.thumbnail.name, 'thumbnails/de/deadbeef.png')
|
||||
self.assertEqual(file.thumbnail.name, 'thumbnails/de/deadbeef_1920x1080_random7.webp')
|
||||
# Check that File metadata and thumbnail fields were updated
|
||||
self.maxDiff = None
|
||||
self.assertEqual(
|
||||
file.metadata,
|
||||
{
|
||||
'thumbnails': {
|
||||
'1080p': {'path': 'thumbnails/de/deadbeef_1920x1080.png', 'size': [1920, 1080]},
|
||||
'360p': {'path': 'thumbnails/de/deadbeef_640x360.png', 'size': [640, 360]},
|
||||
'1080p': {
|
||||
'path': 'thumbnails/de/deadbeef_1920x1080_random7.webp',
|
||||
'size': [1920, 1080],
|
||||
},
|
||||
'360p': {
|
||||
'path': 'thumbnails/de/deadbeef_640x360_random7.webp',
|
||||
'size': [640, 360],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
@ -17,6 +17,16 @@ from files.utils import (
|
||||
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):
|
||||
manifest = 'blender_manifest.toml'
|
||||
|
||||
@ -115,9 +125,9 @@ class UtilsTest(TestCase):
|
||||
|
||||
def test_get_thumbnail_upload_to(self):
|
||||
for file_hash, kwargs, expected in (
|
||||
('foobar', {}, 'thumbnails/fo/foobar.png'),
|
||||
('deadbeef', {'width': None, 'height': None}, 'thumbnails/de/deadbeef.png'),
|
||||
('deadbeef', {'width': 640, 'height': 360}, 'thumbnails/de/deadbeef_640x360.png'),
|
||||
('foobar', {}, 'thumbnails/fo/foobar.webp'),
|
||||
('deadbeef', {'width': None, 'height': None}, 'thumbnails/de/deadbeef.webp'),
|
||||
('deadbeef', {'width': 640, 'height': 360}, 'thumbnails/de/deadbeef_640x360.webp'),
|
||||
):
|
||||
with self.subTest(file_hash=file_hash, kwargs=kwargs):
|
||||
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):
|
||||
self.assertEqual(
|
||||
{
|
||||
'1080p': {'path': 'thumbnails/fo/foobar_1920x1080.png', 'size': [1920, 1080]},
|
||||
'360p': {'path': 'thumbnails/fo/foobar_640x360.png', 'size': [640, 360]},
|
||||
'1080p': {
|
||||
'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'),
|
||||
)
|
||||
@ -139,7 +152,7 @@ class UtilsTest(TestCase):
|
||||
ANY,
|
||||
expected_size,
|
||||
ANY,
|
||||
output_format='PNG',
|
||||
output_format='WEBP',
|
||||
quality=83,
|
||||
optimize=True,
|
||||
progressive=True,
|
||||
|
@ -108,6 +108,9 @@ def read_manifest_from_zip(archive_path):
|
||||
"""
|
||||
manifest_name = 'blender_manifest.toml'
|
||||
error_codes = []
|
||||
file_list = []
|
||||
manifest_content = None
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as myzip:
|
||||
bad_file = myzip.testzip()
|
||||
@ -129,9 +132,20 @@ def read_manifest_from_zip(archive_path):
|
||||
error_codes.append('invalid_manifest_path')
|
||||
return None, error_codes
|
||||
|
||||
# Extract the 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
|
||||
type_slug = toml_content['type']
|
||||
@ -146,18 +160,18 @@ def read_manifest_from_zip(archive_path):
|
||||
if not init_filepath:
|
||||
error_codes.append('invalid_missing_init')
|
||||
|
||||
wheels = toml_content.get('wheels')
|
||||
if wheels:
|
||||
for wheel in wheels:
|
||||
expected_wheel_path = os.path.join(os.path.dirname(manifest_filepath), wheel)
|
||||
wheel_filepath = find_exact_path(file_list, expected_wheel_path)
|
||||
if not wheel_filepath:
|
||||
error_codes.append(
|
||||
{'code': 'missing_wheel', 'params': {'path': expected_wheel_path}}
|
||||
)
|
||||
|
||||
return toml_content, error_codes
|
||||
|
||||
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:
|
||||
"""Guess MIME-type from the extension of the given file name."""
|
||||
@ -259,12 +273,8 @@ def make_thumbnails(
|
||||
optimize=True,
|
||||
progressive=True,
|
||||
)
|
||||
logger.info('Saving a thumbnail to %s', output_path)
|
||||
# Overwrite files instead of allowing storage generate a deduplicating suffix
|
||||
if default_storage.exists(output_path):
|
||||
logger.warning('%s exists, overwriting', output_path)
|
||||
default_storage.delete(output_path)
|
||||
default_storage.save(output_path, f)
|
||||
output_path = default_storage.save(output_path, f)
|
||||
logger.info('Saved a thumbnail to %s', output_path)
|
||||
thumbnails[size_key] = {'size': size, 'path': output_path}
|
||||
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)
|
||||
if not os.path.isdir(output_dir):
|
||||
os.mkdir(output_dir)
|
||||
os.makedirs(output_dir)
|
||||
ffmpeg.execute()
|
||||
|
||||
end_t = datetime.datetime.now()
|
||||
|
@ -7,7 +7,13 @@ from django.utils.deconstruct import deconstructible
|
||||
from django.utils.html import escape
|
||||
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 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:
|
||||
@classmethod
|
||||
def validate(cls, *, name: str, value: object, manifest: dict) -> str:
|
||||
@ -175,10 +207,12 @@ class LicenseValidator(ListValidator):
|
||||
if type(value) != list:
|
||||
is_error = True
|
||||
|
||||
unknown_value = None
|
||||
for license in value:
|
||||
if License.get_by_slug(license):
|
||||
continue
|
||||
is_error = True
|
||||
unknown_value = license
|
||||
break
|
||||
|
||||
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'supported licenses</a>. e.g., {cls.example}.'
|
||||
)
|
||||
if unknown_value:
|
||||
error_message += mark_safe(f' Unknown value: {escape(unknown_value)}.')
|
||||
|
||||
return error_message
|
||||
|
||||
@ -200,6 +236,7 @@ class TagsValidatorBase:
|
||||
is_error = False
|
||||
type_name = EXTENSION_TYPE_SLUGS_SINGULAR[cls.type]
|
||||
|
||||
unknown_value = None
|
||||
if type(value) != list:
|
||||
is_error = True
|
||||
else:
|
||||
@ -207,18 +244,21 @@ class TagsValidatorBase:
|
||||
if Tag.objects.filter(name=tag, type=cls.type):
|
||||
continue
|
||||
is_error = True
|
||||
unknown_value = tag
|
||||
type_slug = manifest.get('type')
|
||||
logger.info(f'Tag unavailable for {type_slug}: {tag}')
|
||||
|
||||
if not is_error:
|
||||
return
|
||||
|
||||
error_message = (
|
||||
error_message = mark_safe(
|
||||
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'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):
|
||||
@ -327,6 +367,53 @@ class PermissionsValidator:
|
||||
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:
|
||||
example = '1.0.0'
|
||||
|
||||
@ -455,10 +542,12 @@ class ManifestValidator:
|
||||
}
|
||||
optional_fields = {
|
||||
'blender_version_max': VersionMaxValidator,
|
||||
'website': StringValidator,
|
||||
'copyright': ListValidator,
|
||||
'permissions': PermissionsValidator,
|
||||
'platforms': PlatformsValidator,
|
||||
'tags': TagsValidator,
|
||||
'website': StringValidator,
|
||||
'wheels': WheelsValidator,
|
||||
}
|
||||
all_fields = {**mandatory_fields, **optional_fields}
|
||||
|
||||
|
@ -64,4 +64,6 @@
|
||||
{% trans 'You have no notifications' %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{{ page_obj|paginator }}
|
||||
{% endblock content %}
|
||||
|
@ -13,7 +13,7 @@ from notifications.models import Notification
|
||||
|
||||
class NotificationsView(LoginRequiredMixin, ListView):
|
||||
model = Notification
|
||||
paginate_by = 10
|
||||
paginate_by = 20
|
||||
|
||||
def get_queryset(self):
|
||||
return Notification.objects.filter(recipient=self.request.user).order_by('-id')
|
||||
|
@ -9,7 +9,7 @@ max_requests: 1000
|
||||
max_requests_jitter: 50
|
||||
port: 8200
|
||||
workers: 2
|
||||
client_max_body_size: "50m"
|
||||
client_max_body_size: "300m"
|
||||
python_version: "3.10"
|
||||
delete_venv: false # set to true if venv has to be re-created from scratch
|
||||
|
||||
|
@ -101,31 +101,8 @@
|
||||
<ul class="activity-list">
|
||||
{% for activity in review_activity %}
|
||||
<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">
|
||||
<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>
|
||||
<a href="{% url "extensions:by-author" user_id=activity.user.pk %}">
|
||||
{% 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 %}">
|
||||
{{ activity.user }}
|
||||
</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 class="ms-auto">
|
||||
<a href="#activity-{{ activity.id }}" title="{{ activity.date_created }}">
|
||||
@ -146,10 +129,10 @@
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
<div>{{ activity.message|markdown }}</div>
|
||||
<div>
|
||||
{{ activity.message|markdown }}</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
@ -1,5 +1,5 @@
|
||||
{% extends "common/base.html" %}
|
||||
{% load i18n humanize filters %}
|
||||
{% load i18n humanize filters common %}
|
||||
|
||||
{% block page_title %}Approval queue{% endblock page_title %}
|
||||
|
||||
@ -45,5 +45,6 @@
|
||||
{% else %}
|
||||
<p>{% trans "No extensions to review." %}</p>
|
||||
{% endif %}
|
||||
{{ page_obj|paginator }}
|
||||
</section>
|
||||
{% endblock content %}
|
||||
|
@ -13,6 +13,7 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
|
||||
list_display_links = ['username']
|
||||
list_filter = auth_admin.UserAdmin.list_filter + (
|
||||
'groups__name',
|
||||
'date_joined',
|
||||
'confirmed_email_at',
|
||||
'date_deletion_requested',
|
||||
|
@ -10,7 +10,7 @@ from django.test import TestCase, override_settings, TransactionTestCase
|
||||
from django.urls import reverse
|
||||
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.views.webhooks as webhooks
|
||||
|
||||
@ -56,7 +56,7 @@ class TestBlenderIDWebhook(TestCase):
|
||||
util.mock_blender_id_responses()
|
||||
|
||||
# Prepare a user
|
||||
self.user = UserFactory(
|
||||
self.user = OAuthUserFactory(
|
||||
email='mail@example.com',
|
||||
oauth_info__oauth_user_id='2',
|
||||
oauth_tokens__oauth_user_id='2',
|
||||
@ -240,7 +240,7 @@ class TestIntegrityErrors(TransactionTestCase):
|
||||
util.mock_blender_id_responses()
|
||||
|
||||
# Prepare a user
|
||||
self.user = UserFactory(
|
||||
self.user = OAuthUserFactory(
|
||||
email='mail@example.com',
|
||||
oauth_info__oauth_user_id='2',
|
||||
oauth_tokens__oauth_user_id='2',
|
||||
@ -251,7 +251,7 @@ class TestIntegrityErrors(TransactionTestCase):
|
||||
@responses.activate
|
||||
def test_user_modified_does_not_allow_duplicate_email(self):
|
||||
# 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 = {
|
||||
**self.webhook_payload,
|
||||
'email': 'jane@example.com',
|
||||
|
Loading…
Reference in New Issue
Block a user