Implement Web Assets' theme system and selection, and add 'light' theme #118
@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
from actstream import action
|
||||
from django.db.models.signals import post_save
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from abuse.models import AbuseReport
|
||||
@ -45,3 +45,8 @@ def _create_action_from_report(
|
||||
target=instance.extension,
|
||||
action_object=instance,
|
||||
)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=AbuseReport)
|
||||
def _log_deletion(sender: object, instance: AbuseReport, **kwargs: object) -> None:
|
||||
instance.record_deletion()
|
||||
|
@ -88,7 +88,7 @@
|
||||
<div class="dl-row">
|
||||
<div class="dl-col">
|
||||
<dt>Status</dt>
|
||||
<dd>{% include "common/components/status.html" with class="d-block" %}</dd>
|
||||
<dd>{% include "common/components/status.html" %}</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dl-row">
|
||||
|
@ -1 +1 @@
|
||||
Subproject commit 69ac038afd6b9312a9c715b44b7814b0cbdcf572
|
||||
Subproject commit af61a962e1a30898279b4efdbb07a2dcb230a257
|
@ -1,16 +0,0 @@
|
||||
"""
|
||||
ASGI config for blender_extensions project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'blender_extensions.settings')
|
||||
|
||||
application = get_asgi_application()
|
@ -120,6 +120,7 @@ WSGI_APPLICATION = 'blender_extensions.wsgi.application'
|
||||
DATABASES = {
|
||||
'default': dj_database_url.config(default='sqlite:///{}'.format(BASE_DIR / 'db.sqlite3')),
|
||||
}
|
||||
DATABASES['default']['CONN_MAX_AGE'] = None
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
|
||||
@ -325,3 +326,7 @@ EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD')
|
||||
ACTSTREAM_SETTINGS = {
|
||||
'MANAGER': 'actstream.managers.ActionManager',
|
||||
}
|
||||
|
||||
# Require file validation for other file processing (e.g. thumbnails).
|
||||
# Should be set for staging/production.
|
||||
REQUIRE_FILE_VALIDATION = os.getenv('REQUIRE_FILE_VALIDATION', False)
|
||||
|
@ -1,8 +1,9 @@
|
||||
from typing import Set, Tuple, Mapping, Any
|
||||
import copy
|
||||
import logging
|
||||
|
||||
from django.contrib.admin.models import DELETION
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core import serializers
|
||||
from django.db import models
|
||||
from django.shortcuts import reverse
|
||||
from django.utils import timezone
|
||||
@ -19,6 +20,13 @@ See TrackChangesMixin.pre_save_record().
|
||||
"""
|
||||
|
||||
|
||||
def _get_object_state(obj: object, fields=None, include_pk=False) -> dict:
|
||||
data = serializers.serialize('python', [obj], fields=fields)[0]
|
||||
if include_pk:
|
||||
data['fields']['pk'] = data['pk']
|
||||
return data['fields']
|
||||
|
||||
|
||||
class CreatedModifiedMixin(models.Model):
|
||||
"""Add standard date fields to a model."""
|
||||
|
||||
@ -45,7 +53,31 @@ class CreatedModifiedMixin(models.Model):
|
||||
return f'{path}?{query}'
|
||||
|
||||
|
||||
class TrackChangesMixin(models.Model):
|
||||
class RecordDeletionMixin:
|
||||
def record_deletion(self):
|
||||
"""Create a LogEntry describing a deletion of this object."""
|
||||
msg_args = {'type': type(self), 'pk': self.pk}
|
||||
logger.info('Deleting %(type)s pk=%(pk)s', msg_args)
|
||||
if hasattr(self, 'cannot_be_deleted_reasons'):
|
||||
cannot_be_deleted_reasons = self.cannot_be_deleted_reasons
|
||||
if len(cannot_be_deleted_reasons) > 0:
|
||||
# This shouldn't happen: prior validation steps should have taken care of this.
|
||||
msg_args['reasons'] = cannot_be_deleted_reasons
|
||||
logger.error("%(type)s pk=%(pk)s is being deleted but it %(reasons)s", msg_args)
|
||||
state = _get_object_state(self, include_pk=True)
|
||||
message = [
|
||||
{
|
||||
'deleted': {
|
||||
'name': str(self._meta.verbose_name),
|
||||
'object': repr(self),
|
||||
'old_state': state,
|
||||
},
|
||||
}
|
||||
]
|
||||
attach_log_entry(self, message, action_flag=DELETION)
|
||||
|
||||
|
||||
class TrackChangesMixin(RecordDeletionMixin, models.Model):
|
||||
"""Tracks changes of Django models.
|
||||
|
||||
Tracks which fields have changed in the save() function, so that
|
||||
@ -92,9 +124,7 @@ class TrackChangesMixin(models.Model):
|
||||
|
||||
update_fields = kwargs.get('update_fields')
|
||||
was_modified = self._was_modified(db_instance, update_fields=update_fields)
|
||||
old_instance_data = {
|
||||
attr: copy.deepcopy(getattr(db_instance, attr)) for attr in self.track_changes_to_fields
|
||||
}
|
||||
old_instance_data = _get_object_state(db_instance, fields=self.track_changes_to_fields)
|
||||
return was_modified, old_instance_data
|
||||
|
||||
def record_status_change(self, was_changed, old_state, **kwargs):
|
||||
@ -120,8 +150,9 @@ class TrackChangesMixin(models.Model):
|
||||
if not was_changed or not self.pk:
|
||||
return
|
||||
|
||||
new_state = _get_object_state(self, fields=self.track_changes_to_fields)
|
||||
changed_fields = {
|
||||
field for field in old_state.keys() if getattr(self, field) != old_state[field]
|
||||
field for field in old_state.keys() if new_state.get(field) != old_state.get(field)
|
||||
}
|
||||
message = [
|
||||
{
|
||||
|
@ -16,7 +16,7 @@
|
||||
&:last-child
|
||||
/* Remove bottom half of the vertical line for last item. */
|
||||
.comment-card:before
|
||||
height: calc(50% - 1.6rem)
|
||||
height: calc(50% - var(--spacer))
|
||||
.activity-status-change:before
|
||||
height: calc(50% + var(--border-width))
|
||||
|
||||
@ -50,9 +50,12 @@
|
||||
left: var(--border-width)
|
||||
position: absolute
|
||||
top: 50%
|
||||
width: 3.2rem
|
||||
width: calc(var(--spacer) * 2)
|
||||
z-index: -1
|
||||
|
||||
.activity-icon
|
||||
top: 1.2rem
|
||||
|
||||
.profile-avatar
|
||||
border: var(--border-width) solid var(--border-color)
|
||||
background-color: var(--border-color)
|
||||
@ -60,8 +63,9 @@
|
||||
.comment-card
|
||||
&:after
|
||||
top: 3.2rem
|
||||
|
||||
.activity-icon
|
||||
top: 2.0rem
|
||||
top: 2.2rem
|
||||
|
||||
.activity-status-change
|
||||
color: var(--color-text-tertiary)
|
||||
@ -83,7 +87,7 @@
|
||||
&.activity-status-approved
|
||||
.activity-icon
|
||||
border-color: var(--color-success)
|
||||
box-shadow: 0 0 1.6rem var(--color-success-bg)
|
||||
box-shadow: 0 0 var(--spacer) var(--color-success-bg)
|
||||
color: var(--color-success-text)
|
||||
|
||||
.activity-icon
|
||||
@ -94,11 +98,11 @@
|
||||
color: var(--color-text-tertiary)
|
||||
display: flex
|
||||
font-size: var(--fs-sm)
|
||||
height: 1.8rem
|
||||
height: var(--spacer-4)
|
||||
justify-content: center
|
||||
left: -.66rem
|
||||
position: absolute
|
||||
width: 1.8rem
|
||||
width: var(--spacer-4)
|
||||
|
||||
/* Comment form */
|
||||
.comment-form
|
||||
@ -112,9 +116,6 @@
|
||||
select
|
||||
width: auto
|
||||
|
||||
button[type="submit"]
|
||||
min-width: 50%
|
||||
|
||||
textarea
|
||||
height: calc(var(--spacer) * 8)
|
||||
max-height: 0
|
||||
|
@ -1,3 +1,10 @@
|
||||
.badge-card
|
||||
+border-radius(lg)
|
||||
border-bottom-left-radius: 0
|
||||
border-bottom-right-radius: 0
|
||||
display: flex
|
||||
+padding(2, y)
|
||||
|
||||
a.badge-tag
|
||||
--badge-color: var(--color-text-secondary)
|
||||
--badge-bg: var(--color-text-tertiary)
|
||||
@ -24,10 +31,3 @@ a.badge-tag
|
||||
&-disabled-by-staff,
|
||||
&-disabled-by-author
|
||||
@extend .badge-secondary
|
||||
|
||||
.card-badge
|
||||
+border-radius(lg)
|
||||
border-bottom-left-radius: 0
|
||||
border-bottom-right-radius: 0
|
||||
display: flex
|
||||
+padding(2, y)
|
||||
|
@ -1,3 +1,29 @@
|
||||
.card
|
||||
@extend .box
|
||||
|
||||
+media-sm
|
||||
--cards-items-per-row: 2
|
||||
|
||||
+media-md
|
||||
--cards-items-per-row: 3
|
||||
|
||||
+media-lg
|
||||
--cards-items-per-row: 4
|
||||
|
||||
.cards-item-content
|
||||
overflow: hidden
|
||||
|
||||
.crads-item-excerpt
|
||||
line-height: calc(24 / 18)
|
||||
|
||||
.cards-item-extra
|
||||
text-transform: none
|
||||
|
||||
.cards-item-extra-rating-stars
|
||||
margin-bottom: .2rem
|
||||
|
||||
.stars
|
||||
font-size: 1.4rem
|
||||
|
||||
.cards-item-title
|
||||
+padding(0, y)
|
||||
|
@ -15,12 +15,12 @@
|
||||
border-radius: .4rem
|
||||
content: ''
|
||||
display: block
|
||||
height: 1.6rem
|
||||
height: var(--spacer)
|
||||
left: -.33rem
|
||||
position: absolute
|
||||
rotate: 45deg
|
||||
top: 1.6rem
|
||||
width: 1.6rem
|
||||
top: var(--spacer)
|
||||
width: var(--spacer)
|
||||
z-index: -1
|
||||
|
||||
p:last-child
|
||||
@ -35,9 +35,12 @@
|
||||
align-items: center
|
||||
display: flex
|
||||
+list-unstyled
|
||||
gap: 1.6rem
|
||||
gap: var(--spacer)
|
||||
margin: 0
|
||||
|
||||
li
|
||||
line-height: var(--lh-sm)
|
||||
|
||||
aside
|
||||
+margin(2, top)
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
\:root
|
||||
--lh-sm: 2.4rem
|
||||
|
||||
.hero.extension-detail
|
||||
--hero-max-height: 0
|
||||
--hero-min-height: 24.0rem
|
||||
@ -166,114 +169,6 @@
|
||||
color: var(--color-text-secondary)
|
||||
+fw-normal
|
||||
|
||||
.cards
|
||||
+media-sm
|
||||
--cards-items-per-row: 2
|
||||
+media-md
|
||||
--cards-items-per-row: 3
|
||||
+media-lg
|
||||
--cards-items-per-row: 4
|
||||
|
||||
.ext-card
|
||||
+box-card
|
||||
display: flex
|
||||
flex-direction: column
|
||||
height: 100%
|
||||
overflow: hidden
|
||||
transition: box-shadow ease-in-out 1s
|
||||
|
||||
&:hover
|
||||
box-shadow: 1.0rem 1.0rem 2.0rem 0px rgba(0, 0, 0, .04), -1.0rem 0 2.0rem 0px rgba(0, 0, 0, .04)
|
||||
|
||||
&.is-bg-blur
|
||||
background-color: hsl(213, 10%, 21%)
|
||||
border: thin solid hsl(213, 10%, 20%)
|
||||
position: relative
|
||||
|
||||
.ext-card-body
|
||||
--color-text-secondary: hsla(213, 40%, 90%, .6)
|
||||
|
||||
border-bottom-left-radius: var(--border-radius-lg)
|
||||
border-bottom-right-radius: var(--border-radius-lg)
|
||||
+padding(1, top)
|
||||
mix-blend-mode: screen
|
||||
position: relative
|
||||
z-index: 1
|
||||
|
||||
&:has(.ext-card-admin)
|
||||
.ext-card-body
|
||||
border-radius: 0
|
||||
|
||||
.ext-card-thumbnail-img
|
||||
-webkit-mask-img: -webkit-gradient(linear, left 60%, left bottom, from(rgba(0,0,0,1)), to(rgba(0,0,0,0)))
|
||||
|
||||
.ext-card-thumbnail:hover
|
||||
&+.ext-card-body .ext-card-title
|
||||
color: var(--color-text-primary)
|
||||
|
||||
&.ext-card-row
|
||||
flex-direction: row
|
||||
+margin(3, bottom)
|
||||
|
||||
.ext-card-thumbnail
|
||||
--card-thumbnail-width: 24.0rem
|
||||
border-top-right-radius: 0
|
||||
border-bottom-left-radius: var(--border-radius-lg)
|
||||
height: 100%
|
||||
|
||||
img
|
||||
border-top-right-radius: 0
|
||||
border-bottom-left-radius: var(--border-radius-lg)
|
||||
|
||||
.ext-blender-version
|
||||
display: inline-block
|
||||
|
||||
.ext-card-thumbnail-blur
|
||||
bottom: 0
|
||||
filter: blur(5.0rem)
|
||||
left: 0
|
||||
position: absolute
|
||||
right: 0
|
||||
transform: scale(1.25)
|
||||
top: 0
|
||||
z-index: 0
|
||||
opacity: .5
|
||||
|
||||
.ext-card-thumbnail
|
||||
--card-thumbnail-width: 100%
|
||||
|
||||
align-items: center
|
||||
border-top-left-radius: var(--border-radius-lg)
|
||||
border-top-right-radius: var(--border-radius-lg)
|
||||
display: block
|
||||
justify-content: center
|
||||
max-width: var(--card-thumbnail-width)
|
||||
overflow: hidden
|
||||
|
||||
.ext-card-thumbnail-img
|
||||
background-position: center
|
||||
background-size: cover
|
||||
+make-aspect-ratio('16x9')
|
||||
transition: transform ease-out var(--transition-speed)
|
||||
|
||||
.ext-card-body
|
||||
display: flex
|
||||
flex: 1
|
||||
flex-direction: column
|
||||
justify-content: space-between
|
||||
+padding(3)
|
||||
|
||||
p
|
||||
line-height: calc(24 / 18)
|
||||
|
||||
.ext-card-title
|
||||
font-size: var(--fs-lg)
|
||||
+margin(3, bottom)
|
||||
transition: color var(--transition-speed)
|
||||
|
||||
a
|
||||
text-decoration: none
|
||||
|
||||
.ext-list-details
|
||||
@extend .list-inline
|
||||
|
||||
@ -286,12 +181,6 @@
|
||||
&+.ext-list-details
|
||||
+margin(2, top)
|
||||
|
||||
.ext-card-tags
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: 3.2rem
|
||||
justify-content: flex-start
|
||||
|
||||
/* Show only on row list view.*/
|
||||
.ext-blender-version
|
||||
display: none
|
||||
@ -299,19 +188,6 @@
|
||||
.ext-edit-field-row
|
||||
+margin(2, top)
|
||||
|
||||
.ext-card-admin
|
||||
align-items: center
|
||||
background-color: hsla(213, 80%, 1%, .33)
|
||||
border-bottom-left-radius: var(--border-radius-lg)
|
||||
border-bottom-right-radius: var(--border-radius-lg)
|
||||
border-top: var(--border-width) solid var(--border-color)
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
z-index: 1
|
||||
|
||||
dd
|
||||
font-family: var(--font-body)
|
||||
|
||||
.previews-upload
|
||||
+box-card
|
||||
+padding(3)
|
||||
@ -323,9 +199,9 @@
|
||||
gap: .8rem
|
||||
|
||||
.previews-list-item
|
||||
--preview-thumbnail-max-size: 180px
|
||||
--preview-thumbnail-max-size: calc(12.4rem * 16 / 9)
|
||||
|
||||
align-items: center
|
||||
align-items: start
|
||||
background-color:
|
||||
border-radius: var(--border-radius-lg)
|
||||
border: var(--border-width) solid var(--border-color)
|
||||
@ -334,6 +210,7 @@
|
||||
|
||||
.previews-list-item-thumbnail
|
||||
margin: 0
|
||||
+margin(2, y)
|
||||
width: var(--preview-thumbnail-max-size)
|
||||
|
||||
.previews-list-item-thumbnail-img
|
||||
@ -341,6 +218,7 @@
|
||||
background-position: center
|
||||
background-size: cover
|
||||
border-radius: var(--border-radius)
|
||||
height: 12.4rem
|
||||
+make-aspect-ratio('16x9')
|
||||
|
||||
.details
|
||||
@ -349,6 +227,7 @@
|
||||
|
||||
label
|
||||
font-size: var(--fs-sm)
|
||||
line-height: var(--lh-sm)
|
||||
|
||||
ul
|
||||
+list-unstyled
|
||||
@ -389,7 +268,10 @@
|
||||
+fw-normal
|
||||
+margin(3, left)
|
||||
|
||||
details[open]
|
||||
details
|
||||
padding: 0
|
||||
|
||||
&[open]
|
||||
.show-on-collapse
|
||||
display: none
|
||||
|
||||
@ -460,3 +342,36 @@
|
||||
.ext-review-list-activity
|
||||
display: flex
|
||||
+padding(0, x)
|
||||
|
||||
.rating-form
|
||||
select
|
||||
color: var(--color-warning)
|
||||
|
||||
&:active,
|
||||
&:hover,
|
||||
&:focus
|
||||
color: var(--color-warning)
|
||||
|
||||
// TODO: consider adding component boxed nav generic to web-assets, and make variants on top of that
|
||||
.nav-pills
|
||||
@extend .dropdown-menu
|
||||
|
||||
box-shadow: none
|
||||
display: block
|
||||
position: relative
|
||||
|
||||
.nav-pills-item
|
||||
@extend .dropdown-item
|
||||
|
||||
+margin(1, bottom)
|
||||
|
||||
&.active
|
||||
background-color: var(--color-accent-bg)
|
||||
|
||||
&:last-child
|
||||
+margin(0, bottom)
|
||||
|
||||
.nav-pills-divider
|
||||
@extend .dropdown-divider
|
||||
|
||||
+margin(0, top)
|
||||
|
@ -12,7 +12,7 @@
|
||||
|
||||
.galleria-items
|
||||
display: flex
|
||||
gap: .8rem
|
||||
gap: var(--spacer-2)
|
||||
overflow-x: auto
|
||||
+padding(2, top)
|
||||
scroll-behavior: smooth
|
||||
@ -43,7 +43,7 @@
|
||||
|
||||
.galleria-item-type-video
|
||||
&::after
|
||||
font-size: 3.2rem
|
||||
font-size: calc(var(--spacer) * 2)
|
||||
|
||||
.galleria-item
|
||||
+border-radius
|
||||
@ -69,7 +69,7 @@
|
||||
transform: translate(-50%, -50%)
|
||||
width: initial
|
||||
height: initial
|
||||
gap: 1.6rem
|
||||
gap: var(--spacer)
|
||||
|
||||
|
||||
&.is-active
|
||||
@ -174,7 +174,7 @@
|
||||
|
||||
.indicator
|
||||
background-color: rgba(black, .5)
|
||||
bottom: 1.6rem
|
||||
bottom: var(--spacer)
|
||||
color: white
|
||||
+fw-bold
|
||||
min-width: 3ch
|
||||
@ -187,7 +187,7 @@
|
||||
backdrop-filter: blur(2.8rem)
|
||||
background-color: rgba(black, .5)
|
||||
border-radius: var(--border-radius)
|
||||
bottom: 1.6rem
|
||||
bottom: var(--spacer)
|
||||
color: white
|
||||
+font-weight(500)
|
||||
line-height: 1.5
|
||||
|
@ -7,7 +7,8 @@
|
||||
h4,
|
||||
h5,
|
||||
h6
|
||||
+margin(4, top)
|
||||
+margin(3, bottom)
|
||||
+padding(3, top)
|
||||
|
||||
img
|
||||
+border-radius(lg)
|
||||
@ -19,9 +20,4 @@
|
||||
|
||||
& >
|
||||
h1:first-of-type,
|
||||
h2:first-of-type,
|
||||
h3:first-of-type,
|
||||
h4:first-of-type,
|
||||
h5:first-of-type,
|
||||
h6:first-of-type
|
||||
+margin(0, top)
|
||||
+padding(0, top)
|
||||
|
@ -17,6 +17,7 @@
|
||||
&:hover
|
||||
cursor: move !important
|
||||
|
||||
// TODO: move utilities 'fade' and 'show' to web-assets
|
||||
.fade
|
||||
opacity: 0
|
||||
// TODO: make variable 'transition-speed-slow' work
|
||||
|
@ -2,7 +2,7 @@
|
||||
$font-path: '/static/fonts'
|
||||
|
||||
/* Import variables.*/
|
||||
$grid-breakpoints: (xs: 0,sm: 768px,md: 1020px,lg: 1220px,xl: 1380px,xxl: 1680px) !default
|
||||
$grid-breakpoints: (xs: 0,sm: 768px,md: 1020px,lg: 1220px,xl: 1380px,xxl: 1680px)
|
||||
|
||||
$container-max-widths: (sm: 760px, md: 1020px, lg: 1070px, xl: 1320px, xxl: 1600px)
|
||||
$container-width: map-get($container-max-widths, 'xl')
|
||||
@ -41,7 +41,7 @@ $container-width: map-get($container-max-widths, 'xl')
|
||||
+media-xs
|
||||
width: 60px
|
||||
|
||||
/* Temporarily here until it can be moved to web-assets v2. */
|
||||
/* TODO: temporarily here until it can be moved to web-assets v2. */
|
||||
.nav-global-links-right
|
||||
gap: 0 var(--spacer-2)
|
||||
.navbar-search
|
||||
@ -52,8 +52,8 @@ $container-width: map-get($container-max-widths, 'xl')
|
||||
|
||||
.profile-avatar
|
||||
border-radius: 50%
|
||||
height: var(--spacer-4)
|
||||
pointer-events: none
|
||||
width: 2.6rem
|
||||
|
||||
.search-highlight
|
||||
background-color: var(--btn-color-bg)
|
||||
@ -76,7 +76,7 @@ $container-width: map-get($container-max-widths, 'xl')
|
||||
font-size: var(--fs-xs)
|
||||
justify-content: center
|
||||
padding-block: .1rem
|
||||
padding-inline: .4rem
|
||||
padding-inline: var(--spacer-1)
|
||||
position: absolute
|
||||
top: calc(var(--spacer-2) * -1)
|
||||
transition: background-color var(--transition-speed), box-shadow 500ms, color var(--transition-speed)
|
||||
|
@ -1,3 +1,4 @@
|
||||
{% load common %}
|
||||
{% load pipeline %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
@ -137,6 +138,9 @@
|
||||
</li>
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'notifications:notifications' %}">
|
||||
<i class="i-bell {% if user|unread_notification_count %}text-primary{% endif %}"></i>
|
||||
</a>
|
||||
<li class="nav-item dropdown">
|
||||
<button id="navbarDropdown" aria-expanded="false" aria-haspopup="true" data-toggle-menu-id="nav-account-dropdown" role="button" class="nav-link dropdown-toggle js-dropdown-toggle">
|
||||
<i class="i-user"></i>
|
||||
|
@ -1,3 +1,4 @@
|
||||
{# TODO: check if template is used and needed #}
|
||||
<li class="nav-item {% include "common/components/_nav_item_active" %}">
|
||||
{% include "common/components/nav_link.html" %}
|
||||
</li>
|
||||
|
@ -1,5 +1,5 @@
|
||||
{% if name %}
|
||||
<a class="nav-link {% include "common/components/_nav_item_active" %} {% if classes %}{{ classes }}{% endif %}" href="{% url name %}">{{ title }}</a>
|
||||
<a class="nav-pills-item {% include "common/components/_nav_item_active" %} {% if classes %}{{ classes }}{% endif %}" href="{% url name %}">{{ title }}</a>
|
||||
{% elif path %}
|
||||
<a class="nav-link {% include "common/components/_nav_item_active" %} {% if classes %}{{ classes }}{% endif %}" href="{{ path }}">{{ title }}</a>
|
||||
<a class="nav-pills-item {% include "common/components/_nav_item_active" %} {% if classes %}{{ classes }}{% endif %}" href="{{ path }}">{{ title }}</a>
|
||||
{% endif %}
|
||||
|
@ -14,7 +14,7 @@
|
||||
{% endblock hero %}
|
||||
|
||||
{% block content %}
|
||||
<div class="box p-5 my-3 is-flatpage">
|
||||
<div class="box my-3 is-flatpage">
|
||||
{{ flatpage.content|markdown }}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
@ -13,6 +13,7 @@ from common.markdown import (
|
||||
render_as_text as render_markdown_as_text,
|
||||
)
|
||||
from extensions.models import Tag
|
||||
from notifications.models import Notification
|
||||
|
||||
import utils
|
||||
|
||||
@ -160,3 +161,8 @@ def replace(value, old_char_new_char):
|
||||
"""Replaces occurrences of old_char with new_char in the given value."""
|
||||
old_char, new_char = old_char_new_char.split(',')
|
||||
return value.replace(old_char, new_char)
|
||||
|
||||
|
||||
@register.filter(name='unread_notification_count')
|
||||
def unread_notification_count(user):
|
||||
return Notification.objects.filter(recipient=user, read_at__isnull=True).count()
|
||||
|
@ -6,7 +6,7 @@ from mdgen import MarkdownPostProvider
|
||||
import factory
|
||||
import factory.fuzzy
|
||||
|
||||
from extensions.models import Extension, Version, Tag
|
||||
from extensions.models import Extension, Version, Tag, Preview
|
||||
from ratings.models import Rating
|
||||
|
||||
fake_markdown = Faker()
|
||||
@ -35,7 +35,7 @@ class ExtensionFactory(DjangoModelFactory):
|
||||
|
||||
if extracted:
|
||||
for _ in extracted:
|
||||
_.extension_preview.create(caption='Media Caption', extension=self)
|
||||
Preview.objects.create(file=_, caption='Media Caption', extension=self)
|
||||
|
||||
@factory.post_generation
|
||||
def process_extension_id(self, created, extracted, **kwargs):
|
||||
|
@ -10,6 +10,7 @@ class Verb:
|
||||
REPORTED_RATING = 'reported rating'
|
||||
REQUESTED_CHANGES = 'requested changes'
|
||||
REQUESTED_REVIEW = 'requested review'
|
||||
UPLOADED_NEW_VERSION = 'uploaded new version'
|
||||
|
||||
|
||||
class Flag:
|
||||
|
@ -100,3 +100,10 @@ ABUSE_TYPE = Choices(
|
||||
('ABUSE_USER', ABUSE_TYPE_USER, "User"),
|
||||
('ABUSE_RATING', ABUSE_TYPE_RATING, "Rating"),
|
||||
)
|
||||
|
||||
# **N.B.**: thumbnail sizes are not intended to be changed on the fly:
|
||||
# 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_QUALITY = 83
|
||||
|
@ -1,3 +1,4 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from semantic_version.django_fields import VersionField as SemanticVersionField
|
||||
from semantic_version import Version
|
||||
import json
|
||||
@ -11,7 +12,10 @@ class VersionStringField(SemanticVersionField):
|
||||
return value
|
||||
if value is None:
|
||||
return value
|
||||
try:
|
||||
return str(Version(value))
|
||||
except Exception as e:
|
||||
raise ValidationError(e)
|
||||
|
||||
def from_db_value(self, value, expression, connection):
|
||||
return self.to_python(value)
|
||||
@ -22,7 +26,7 @@ class VersionStringField(SemanticVersionField):
|
||||
return str(value)
|
||||
|
||||
def value_to_string(self, obj):
|
||||
value = self._get_val_from_obj(obj)
|
||||
value = self.value_from_object(obj)
|
||||
return self.get_prep_value(value)
|
||||
|
||||
def from_json(self, json_str):
|
||||
|
@ -66,24 +66,14 @@ class AddPreviewFileForm(forms.ModelForm):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Save Preview from the cleaned form data."""
|
||||
# If file with this hash was already uploaded by the same user, return it
|
||||
hash_ = self.instance.generate_hash(self.instance.source)
|
||||
model = self.instance.__class__
|
||||
existing_image = model.objects.filter(original_hash=hash_, user=self.request.user).first()
|
||||
if (
|
||||
existing_image
|
||||
and not existing_image.extension_preview.filter(extension_id=self.extension.id).count()
|
||||
):
|
||||
logger.warning('Found an existing %s pk=%s', model, existing_image.pk)
|
||||
self.instance = existing_image
|
||||
|
||||
# Fill in missing fields from request and the source file
|
||||
self.instance.user = self.request.user
|
||||
|
||||
instance = super().save(*args, **kwargs)
|
||||
|
||||
# Create extension preview and save caption to it
|
||||
instance.extension_preview.create(
|
||||
extensions.models.Preview.objects.create(
|
||||
file=instance,
|
||||
caption=self.cleaned_data['caption'],
|
||||
extension=self.extension,
|
||||
)
|
||||
|
20
extensions/migrations/0027_unique_preview_files.py
Normal file
20
extensions/migrations/0027_unique_preview_files.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.11 on 2024-04-23 11:56
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('files', '0007_alter_file_status'),
|
||||
('extensions', '0026_remove_extension_date_deleted_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='preview',
|
||||
name='file',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='files.file'),
|
||||
),
|
||||
]
|
@ -10,7 +10,7 @@ from django.db.models import F, Q, Count
|
||||
from django.urls import reverse
|
||||
|
||||
from common.fields import FilterableManyToManyField
|
||||
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin
|
||||
from common.model_mixins import CreatedModifiedMixin, RecordDeletionMixin, TrackChangesMixin
|
||||
from constants.base import (
|
||||
AUTHOR_ROLE_CHOICES,
|
||||
AUTHOR_ROLE_DEV,
|
||||
@ -33,7 +33,13 @@ log = logging.getLogger(__name__)
|
||||
class RatingMixin:
|
||||
@property
|
||||
def text_ratings_count(self) -> int:
|
||||
return self.ratings.listed_texts.count()
|
||||
return len(
|
||||
[
|
||||
r
|
||||
for r in self.ratings.all()
|
||||
if r.text is not None and r.is_listed and r.reply_to is None
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def total_ratings_count(self) -> int:
|
||||
@ -274,10 +280,9 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
|
||||
def get_previews(self):
|
||||
"""Get preview files, sorted by Preview.position.
|
||||
|
||||
TODO: Might be better to query Previews directly instead of going
|
||||
for the reverse relationship.
|
||||
Avoid triggering additional querysets, rely on prefetch_related in the view.
|
||||
"""
|
||||
return self.previews.listed.order_by('extension_preview__position')
|
||||
return [p.file for p in self.preview_set.all() if p.file.is_listed]
|
||||
|
||||
@property
|
||||
def valid_file_statuses(self) -> List[int]:
|
||||
@ -288,14 +293,13 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
|
||||
@property
|
||||
def latest_version(self):
|
||||
"""Retrieve the latest version."""
|
||||
return (
|
||||
self.versions.filter(
|
||||
file__status__in=self.valid_file_statuses,
|
||||
file__isnull=False,
|
||||
)
|
||||
.order_by('date_created')
|
||||
.last()
|
||||
)
|
||||
versions = [
|
||||
v for v in self.versions.all() if v.file and v.file.status in self.valid_file_statuses
|
||||
]
|
||||
if not versions:
|
||||
return None
|
||||
versions = sorted(versions, key=lambda v: v.date_created, reverse=True)
|
||||
return versions[0]
|
||||
|
||||
@property
|
||||
def current_version(self):
|
||||
@ -349,7 +353,7 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
|
||||
"""Return True if given user is listed as a maintainer."""
|
||||
if user is None or user.is_anonymous:
|
||||
return False
|
||||
return self.authors.filter(maintainer__user_id=user.pk).exists()
|
||||
return user in self.authors.all()
|
||||
|
||||
def can_rate(self, user) -> bool:
|
||||
"""Return True if given user can rate this extension.
|
||||
@ -364,6 +368,10 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
|
||||
).exists()
|
||||
)
|
||||
|
||||
def suspicious_files(self):
|
||||
versions = self.versions.all()
|
||||
return [v.file for v in versions if not v.file.validation.is_ok]
|
||||
|
||||
@classmethod
|
||||
def get_lookup_field(cls, identifier):
|
||||
lookup_field = 'pk'
|
||||
@ -651,11 +659,9 @@ class Maintainer(CreatedModifiedMixin, models.Model):
|
||||
]
|
||||
|
||||
|
||||
class Preview(CreatedModifiedMixin, models.Model):
|
||||
class Preview(CreatedModifiedMixin, RecordDeletionMixin, models.Model):
|
||||
extension = models.ForeignKey(Extension, on_delete=models.CASCADE)
|
||||
file = models.ForeignKey(
|
||||
'files.File', related_name='extension_preview', on_delete=models.CASCADE
|
||||
)
|
||||
file = models.OneToOneField('files.File', on_delete=models.CASCADE)
|
||||
caption = models.CharField(max_length=255, default='', null=False, blank=True)
|
||||
position = models.IntegerField(default=0)
|
||||
|
||||
|
@ -8,6 +8,7 @@ from django.db.models.signals import m2m_changed, pre_save, post_save, pre_delet
|
||||
from django.dispatch import receiver
|
||||
|
||||
from constants.activity import Flag
|
||||
from reviewers.models import ApprovalActivity
|
||||
import extensions.models
|
||||
import files.models
|
||||
|
||||
@ -18,15 +19,14 @@ User = get_user_model()
|
||||
@receiver(pre_delete, sender=extensions.models.Extension)
|
||||
@receiver(pre_delete, sender=extensions.models.Preview)
|
||||
@receiver(pre_delete, sender=extensions.models.Version)
|
||||
def _log_extension_delete(sender: object, instance: object, **kwargs: object) -> None:
|
||||
cannot_be_deleted_reasons = instance.cannot_be_deleted_reasons
|
||||
if len(cannot_be_deleted_reasons) > 0:
|
||||
# This shouldn't happen: prior validation steps should have taken care of this.
|
||||
# raise ValidationError({'__all__': cannot_be_deleted_reasons})
|
||||
args = {'sender': sender, 'pk': instance.pk, 'reasons': cannot_be_deleted_reasons}
|
||||
logger.error("%(sender)s pk=%(pk)s is being deleted but it %(reasons)s", args)
|
||||
|
||||
logger.info('Deleting %s pk=%s "%s"', sender, instance.pk, str(instance))
|
||||
def _log_deletion(
|
||||
sender: object,
|
||||
instance: Union[
|
||||
extensions.models.Extension, extensions.models.Version, extensions.models.Preview
|
||||
],
|
||||
**kwargs: object,
|
||||
) -> None:
|
||||
instance.record_deletion()
|
||||
|
||||
|
||||
@receiver(post_delete, sender=extensions.models.Preview)
|
||||
@ -166,3 +166,26 @@ def _auto_approve_subsequent_uploads(
|
||||
args = {'f_id': file.pk, 'pk': instance.pk, 'sender': sender, 's': file.source.name}
|
||||
logger.info('Auto-approving file pk=%(f_id)s of %(sender)s pk=%(pk)s source=%(s)s', args)
|
||||
file.save(update_fields={'status', 'date_modified'})
|
||||
|
||||
|
||||
@receiver(post_save, sender=extensions.models.Version)
|
||||
def _create_approval_activity_for_new_version_if_listed(
|
||||
sender: object,
|
||||
instance: extensions.models.Version,
|
||||
created: bool,
|
||||
raw: bool,
|
||||
**kwargs: object,
|
||||
):
|
||||
if raw:
|
||||
return
|
||||
if not created:
|
||||
return
|
||||
extension = instance.extension
|
||||
if not extension.is_listed or not instance.file:
|
||||
return
|
||||
ApprovalActivity(
|
||||
type=ApprovalActivity.ActivityType.UPLOADED_NEW_VERSION,
|
||||
user=instance.file.user,
|
||||
extension=instance.extension,
|
||||
message=f'uploaded new version: {instance.version}',
|
||||
).save()
|
||||
|
@ -31,7 +31,7 @@ function appendImageUploadForm() {
|
||||
<div class="align-items-center d-flex previews-list-item-thumbnail ps-3">
|
||||
<div class="js-input-img-thumbnail previews-list-item-thumbnail-img" title="Preview">
|
||||
<div class="align-items-center d-flex js-input-img-thumbnail-icon justify-content-center">
|
||||
<i class="i-img"></i>
|
||||
<i class="i-image"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,13 +3,26 @@
|
||||
<a
|
||||
href="https://www.blender.org/download/releases/{{ version.blender_version_min|version_without_patch|replace:".,-" }}/"
|
||||
title="{{ version.blender_version_min }}">Blender {{ version.blender_version_min|version_without_patch }}</a>
|
||||
{% if version.blender_version_max %}
|
||||
{% if version.blender_version_max|version_without_patch != version.blender_version_min|version_without_patch %}
|
||||
{% if is_editable %}
|
||||
—
|
||||
<input name="blender_version_max" class="form-control-sm"
|
||||
value="{{version.blender_version_max|default_if_none:''}}"
|
||||
placeholder="{% trans 'maximum Blender version' %}"
|
||||
pattern="^([0-9]+\.[0-9]+\.[0-9]+)?$"
|
||||
title="{% trans 'Blender version, e.g. 4.1.0' %}"
|
||||
/>
|
||||
{% for error in form.errors.blender_version_max %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% if version.blender_version_max %}
|
||||
{% if version.blender_version_max|version_without_patch != version.blender_version_min|version_without_patch %}
|
||||
—
|
||||
<a
|
||||
href="https://www.blender.org/download/releases/{{ version.blender_version_max|version_without_patch|replace:".,-" }}/"
|
||||
title="{{ version.blender_version_max }}">{{ version.blender_version_max|version_without_patch }}</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% else %}
|
||||
{% trans 'and newer' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
@ -1,25 +1,23 @@
|
||||
{% load common filters %}
|
||||
{% with latest=extension.latest_version %}
|
||||
|
||||
<div class="ext-card {% if blur %}is-bg-blur{% endif %}">
|
||||
{% if blur %}
|
||||
<div class="ext-card-thumbnail-blur" style="background-image: url({{ extension.previews.listed.first.source.url }});"></div>
|
||||
{% endif %}
|
||||
|
||||
<a class="ext-card-thumbnail" href="{{ extension.get_absolute_url }}">
|
||||
<div class="ext-card-thumbnail-img" style="background-image: url({{ extension.previews.listed.first.source.url }});" title="{{ extension.name }}"></div>
|
||||
{% with latest=extension.latest_version thumbnail_360p_url=extension.previews.listed.first.thumbnail_360p_url %}
|
||||
<div class="cards-item">
|
||||
<div class="cards-item-content">
|
||||
<a href="{{ extension.get_absolute_url }}">
|
||||
<div class="cards-item-thumbnail">
|
||||
<img alt="{{ extension.name }}" src="{{ thumbnail_360p_url }}" title="{{ extension.name }}">
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="ext-card-body">
|
||||
<h3 class="ext-card-title">
|
||||
<h3 class="cards-item-title">
|
||||
<a href="{{ extension.get_absolute_url }}">{{ extension.name }}</a>
|
||||
</h3>
|
||||
<div class="cards-item-excerpt">
|
||||
<p>
|
||||
{{ latest.tagline }}
|
||||
</p>
|
||||
|
||||
<ul class="ext-list-details">
|
||||
<li class="ext-card-author">
|
||||
</div>
|
||||
<div class="cards-item-extra">
|
||||
<ul>
|
||||
<li>
|
||||
{% if extension.team %}
|
||||
{% with team=extension.team %}
|
||||
<a href="{{ team.get_absolute_url }}" title="{{ team.name }}">{{ team.name }}</a>
|
||||
@ -30,7 +28,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="ext-list-details mt-1">
|
||||
<ul class="cards-item-extra-rating-stars">
|
||||
{% if extension.average_score %}
|
||||
<li>
|
||||
<a class="align-items-center d-flex" href="{{ extension.get_ratings_url }}">
|
||||
@ -54,15 +52,18 @@
|
||||
</ul>
|
||||
|
||||
{% if latest.tags.count %}
|
||||
<ul class="ext-list-details ext-card-tags">
|
||||
<li class="ext-card-tags">
|
||||
{% include "extensions/components/tags.html" with small=True version=latest %}
|
||||
<ul>
|
||||
{% for tag in latest.tags.all %}
|
||||
<li>
|
||||
{% include "extensions/components/badge_tag.html" with small=True version=latest %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{# Author/admin tools can be added here in an extending template #}
|
||||
{% block admin %}{% endblock admin %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
|
@ -4,7 +4,7 @@
|
||||
<section class="ext-detail-info">
|
||||
<div class="card p-0">
|
||||
{% if is_initial %}
|
||||
<div class="badge badge-info card-badge">
|
||||
<div class="badge badge-card badge-info">
|
||||
Information retrieved from manifest
|
||||
</div>
|
||||
{% else %}
|
||||
@ -54,7 +54,7 @@
|
||||
<div class="dl-row">
|
||||
<div class="dl-col">
|
||||
<dt>{% trans 'Tagline' %}</dt>
|
||||
<dd title="{{ latest.tagline }}">{{ latest.tagline }}</dd>
|
||||
<dd title="{{ version.tagline }}">{{ version.tagline }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -63,20 +63,20 @@
|
||||
<dt>{% trans 'Version' %}</dt>
|
||||
<dd>
|
||||
<a href="{{ extension.get_versions_url }}">
|
||||
{{ latest.version }}
|
||||
{{ version.version }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="dl-col">
|
||||
<dt>{% trans 'Size' %}</dt>
|
||||
<dd>{{ latest.file.size_bytes|filesizeformat }}</dd>
|
||||
<dd>{{ version.file.size_bytes|filesizeformat }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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=version is_editable=is_editable form=form %}</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -91,8 +91,8 @@
|
||||
|
||||
<div class="dl-row">
|
||||
<div class="dl-col">
|
||||
<dt>License{{ latest.licenses.count|pluralize }}</dt>
|
||||
{% for license in latest.licenses.all %}
|
||||
<dt>License{{ version.licenses.count|pluralize }}</dt>
|
||||
{% for license in version.licenses.all %}
|
||||
<dd>
|
||||
{% include "common/components/external_link.html" with url=license.url title=license %}
|
||||
</dd>
|
||||
@ -102,14 +102,14 @@
|
||||
|
||||
<div class="dl-row">
|
||||
<div class="dl-col">
|
||||
{% include "extensions/components/detail_card_version_permissions.html" with version=latest %}
|
||||
{% include "extensions/components/detail_card_version_permissions.html" with version=version %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dl-row">
|
||||
<dd>
|
||||
{% if latest.tags.count %}
|
||||
{% include "extensions/components/tags.html" with small=True version=latest %}
|
||||
{% if version.tags.count %}
|
||||
{% include "extensions/components/tags.html" with small=True version=version %}
|
||||
{% else %}
|
||||
No tags.
|
||||
{% endif %}
|
||||
|
@ -3,19 +3,17 @@
|
||||
{% if previews %}
|
||||
<div class="galleria-items{% if previews.count > 5 %} is-many{% endif %}{% if previews.count == 1 %} is-single{% endif %}" id="galleria-items">
|
||||
{% for preview in previews %}
|
||||
{% with thumbnail_1080p_url=preview.thumbnail_1080p_url %}
|
||||
<a
|
||||
class="galleria-item js-galleria-item-preview galleria-item-type-{{ preview.content_type|slugify|slice:5 }}{% if forloop.first %} is-active{% endif %}"
|
||||
href="{{ preview.source.url }}"
|
||||
href="{{ thumbnail_1080p_url }}"
|
||||
{% if 'video' in preview.content_type %}data-galleria-video-url="{{ preview.source.url }}"{% endif %}
|
||||
data-galleria-content-type="{{ preview.content_type }}"
|
||||
data-galleria-index="{{ forloop.counter }}">
|
||||
|
||||
{% if 'video' in preview.content_type and preview.thumbnail %}
|
||||
<img src="{{ preview.thumbnail.url }}" alt="{{ preview.extension_preview.first.caption }}">
|
||||
{% else %}
|
||||
<img src="{{ preview.source.url }}" alt="{{ preview.extension_preview.first.caption }}">
|
||||
{% endif %}
|
||||
<img src="{{ thumbnail_1080p_url }}" alt="{{ preview.preview.caption }}">
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
@ -5,11 +5,7 @@
|
||||
{% block page_title %}{{ extension.name }}{% endblock page_title %}
|
||||
|
||||
{% block content %}
|
||||
{% if extension.latest_version %}
|
||||
{% with latest=extension.latest_version %}
|
||||
{% include "files/components/scan_details.html" with file=latest.file %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% include "files/components/scan_details.html" with suspicious_files=extension.suspicious_files %}
|
||||
|
||||
{% has_maintainer extension as is_maintainer %}
|
||||
{% with latest=extension.latest_version %}
|
||||
@ -23,11 +19,13 @@
|
||||
|
||||
{# Description #}
|
||||
{% block extension_description %}
|
||||
{% if extension.description %}
|
||||
<section id="about" class="mt-3">
|
||||
<div class="box ext-detail-description">
|
||||
{{ extension.description|markdown }}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock extension_description %}
|
||||
|
||||
{# What's New #}
|
||||
@ -55,7 +53,7 @@
|
||||
{% if extension.versions.listed|length > 1 %}
|
||||
<p>
|
||||
<a href="{{ extension.get_versions_url }}" class="d-block mt-3">
|
||||
See all changelogs
|
||||
See all versions
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
@ -78,7 +78,7 @@
|
||||
<div class="is-sticky py-3">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% include "extensions/components/extension_edit_detail_card.html" with extension=form.instance.extension latest=form.instance is_initial=True %}
|
||||
{% include "extensions/components/extension_edit_detail_card.html" with extension=form.instance.extension version=form.instance is_initial=True %}
|
||||
|
||||
<section class="card p-3 mt-3">
|
||||
<div class="btn-col">
|
||||
|
@ -1,5 +1,5 @@
|
||||
{% extends "common/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load cache i18n %}
|
||||
|
||||
{% block page_title %}Extensions{% endblock page_title %}
|
||||
|
||||
@ -32,6 +32,7 @@
|
||||
{% endblock hero %}
|
||||
|
||||
{% block content %}
|
||||
{% cache 60 home %}
|
||||
<section class="mt-3">
|
||||
<div class="d-flex">
|
||||
<h2>
|
||||
@ -40,7 +41,7 @@
|
||||
<a href="{% url 'extensions:by-type' type_slug='add-ons' %}" class="ms-auto">See all</a>
|
||||
</div>
|
||||
<p>Extend Blender capabilities with these add-ons by the community.</p>
|
||||
<div class="cards mt-3">
|
||||
<div class="cards cards-lg-4 cards-md-3 cards-sm-2 mt-3">
|
||||
{% for extension in addons %}
|
||||
{% include "extensions/components/card.html" %}
|
||||
{% endfor %}
|
||||
@ -57,7 +58,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<p>Blender themes to your liking. Dark, light, flat, colorful, and everything in between.</p>
|
||||
<div class="cards mt-3">
|
||||
<div class="cards cards-lg-4 cards-md-3 cards-sm-2 mt-3">
|
||||
{% for extension in themes %}
|
||||
{% include "extensions/components/card.html" %}
|
||||
{% endfor %}
|
||||
@ -71,4 +72,5 @@
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
{% endcache %}
|
||||
{% endblock content %}
|
||||
|
@ -12,13 +12,11 @@
|
||||
<h3>Tags</h3>
|
||||
<ul>
|
||||
{% for list_tag in tags %}
|
||||
{% if list_tag.versions.all|length %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@ -49,7 +47,7 @@
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% if object_list %}
|
||||
<div class="cards card-layout-horizontal cards-3">
|
||||
<div class="cards cards-3">
|
||||
{% for extension in object_list %}
|
||||
{% include "extensions/components/card.html" with show_type=False %}
|
||||
{% endfor %}
|
||||
|
@ -8,10 +8,10 @@
|
||||
{% with inlineform=newform|add_form_classes %}
|
||||
<div class="ext-edit-field-row js-ext-edit-field-row">
|
||||
<div class="previews-list-item">
|
||||
<div class="align-items-center d-flex previews-list-item-thumbnail ps-3">
|
||||
<div class="d-flex previews-list-item-thumbnail ps-3">
|
||||
<div class="js-input-img-thumbnail previews-list-item-thumbnail-img" title="Preview">
|
||||
<div class="align-items-center d-flex js-input-img-thumbnail-icon justify-content-center">
|
||||
<i class="i-img"></i>
|
||||
<i class="i-image"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,16 +2,17 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block admin %}
|
||||
<div class="ext-card-admin p-3">
|
||||
<div>
|
||||
<div class="bg-secondary cards-item-extra pt-3">
|
||||
<ul class="w-100">
|
||||
<li class="d-flex justify-content-between me-0 w-100">
|
||||
<a href="{{ extension.get_manage_url }}" class="btn btn-sm">
|
||||
<i class="i-edit"></i>
|
||||
<span>{% trans 'Edit' %}</span>
|
||||
</a>
|
||||
<div class="align-items-center d-flex">
|
||||
{% include "common/components/status.html" with object=extension class="badge-tag" %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% include "common/components/status.html" with object=extension %}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock admin %}
|
||||
|
@ -4,7 +4,7 @@
|
||||
{% block page_title %}{{ extension.name }}{% endblock page_title %}
|
||||
|
||||
{% block content %}
|
||||
{% with latest=extension.latest_version author=extension.latest_version.file.user form=form|add_form_classes %}
|
||||
{% with author=extension.latest_version.file.user form=form|add_form_classes %}
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h2>{{ extension.get_type_display }} {% trans 'details' %}</h2>
|
||||
@ -93,7 +93,7 @@
|
||||
<div class="is-sticky py-3">
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
{% include "extensions/components/extension_edit_detail_card.html" with extension=extension latest=latest %}
|
||||
{% include "extensions/components/extension_edit_detail_card.html" with extension=extension version=extension.latest_version %}
|
||||
|
||||
<section class="card p-3 mt-3">
|
||||
<div class="btn-col">
|
||||
|
@ -44,8 +44,7 @@
|
||||
<div class="is-sticky">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% include "extensions/components/extension_edit_detail_card.html" with extension=form.instance.extension latest=form.instance %}
|
||||
|
||||
{% include "extensions/components/extension_edit_detail_card.html" with extension=form.instance.extension version=form.instance is_editable=True form=form %}
|
||||
<section class="card p-3 mt-3">
|
||||
<div class="btn-col">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
|
@ -1,16 +1,26 @@
|
||||
from django.test import TestCase
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from django.contrib.admin.models import LogEntry, DELETION
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from common.tests.factories.extensions import create_approved_version, create_version
|
||||
from common.tests.factories.files import FileFactory
|
||||
from common.tests.factories.users import UserFactory
|
||||
from common.tests.factories.users import UserFactory, create_moderator
|
||||
import extensions.models
|
||||
import files.models
|
||||
import reviewers.models
|
||||
|
||||
TEST_MEDIA_DIR = Path(__file__).resolve().parent / 'media'
|
||||
|
||||
|
||||
# Media file are physically deleted when files records are deleted, hence the override
|
||||
@override_settings(MEDIA_ROOT=TEST_MEDIA_DIR)
|
||||
class DeleteTest(TestCase):
|
||||
fixtures = ['dev', 'licenses']
|
||||
|
||||
def test_unlisted_unrated_extension_can_be_deleted_by_author(self):
|
||||
self.maxDiff = None
|
||||
version = create_version(
|
||||
file__status=files.models.File.STATUSES.AWAITING_REVIEW,
|
||||
ratings=[],
|
||||
@ -29,6 +39,31 @@ class DeleteTest(TestCase):
|
||||
self.assertEqual(extension.cannot_be_deleted_reasons, [])
|
||||
preview_file = extension.previews.first()
|
||||
self.assertIsNotNone(preview_file)
|
||||
# Create some ApprovalActivity as well
|
||||
moderator = create_moderator()
|
||||
approval_activity = reviewers.models.ApprovalActivity.objects.create(
|
||||
extension=extension,
|
||||
user=moderator,
|
||||
message='This is a message in approval activity',
|
||||
)
|
||||
# Create a file validation record
|
||||
file_validation = files.models.FileValidation.objects.create(
|
||||
file=version_file, results={'deadbeef': 'foobar'}
|
||||
)
|
||||
object_reprs = list(
|
||||
map(
|
||||
repr,
|
||||
[
|
||||
preview_file,
|
||||
version_file,
|
||||
file_validation,
|
||||
extension,
|
||||
approval_activity,
|
||||
preview_file.preview,
|
||||
version,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
url = extension.get_delete_url()
|
||||
user = extension.authors.first()
|
||||
@ -49,6 +84,47 @@ class DeleteTest(TestCase):
|
||||
self.assertIsNone(extensions.models.Version.objects.filter(pk=version.pk).first())
|
||||
self.assertIsNone(files.models.File.objects.filter(pk=version_file.pk).first())
|
||||
self.assertIsNone(files.models.File.objects.filter(pk=preview_file.pk).first())
|
||||
|
||||
# Check that each of the deleted records was logged
|
||||
deletion_log_entries_q = LogEntry.objects.filter(action_flag=DELETION)
|
||||
self.assertEqual(deletion_log_entries_q.count(), 7)
|
||||
self.assertEqual(
|
||||
[_.object_repr for _ in deletion_log_entries_q],
|
||||
object_reprs,
|
||||
)
|
||||
log_entry = deletion_log_entries_q.filter(object_repr__contains='Extension').first()
|
||||
change_message_data = json.loads(log_entry.change_message)
|
||||
self.assertEqual(
|
||||
change_message_data[0]['deleted']['object'], f'<Extension: Add-on "{extension.name}">'
|
||||
)
|
||||
self.assertEqual(
|
||||
set(change_message_data[0]['deleted']['old_state'].keys()),
|
||||
{
|
||||
'average_score',
|
||||
'date_approved',
|
||||
'date_created',
|
||||
'date_modified',
|
||||
'date_status_changed',
|
||||
'description',
|
||||
'download_count',
|
||||
'extension_id',
|
||||
'is_listed',
|
||||
'name',
|
||||
'pk',
|
||||
'slug',
|
||||
'status',
|
||||
'support',
|
||||
'team',
|
||||
'type',
|
||||
'view_count',
|
||||
'website',
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
log_entry.get_change_message(),
|
||||
f'Deleted extension “<Extension: Add-on "{extension.name}">”.',
|
||||
)
|
||||
|
||||
# TODO: check that files were deleted from storage (create a temp one prior to the check)
|
||||
|
||||
def test_publicly_listed_extension_cannot_be_deleted(self):
|
||||
|
@ -10,6 +10,7 @@ from common.tests.factories.users import UserFactory
|
||||
from common.tests.utils import _get_all_form_errors
|
||||
from extensions.models import Extension, Version
|
||||
from files.models import File
|
||||
from reviewers.models import ApprovalActivity
|
||||
import utils
|
||||
|
||||
|
||||
@ -425,6 +426,14 @@ class NewVersionTest(TestCase):
|
||||
f'/add-ons/{self.extension.slug}/manage/versions/new/{file.pk}/',
|
||||
)
|
||||
self.assertEqual(self.extension.versions.count(), 1)
|
||||
self.extension.approve()
|
||||
self.assertEqual(
|
||||
ApprovalActivity.objects.filter(
|
||||
extension=self.extension,
|
||||
type=ApprovalActivity.ActivityType.UPLOADED_NEW_VERSION,
|
||||
).count(),
|
||||
0,
|
||||
)
|
||||
|
||||
# Check step 2: finalise new version and send to review
|
||||
url = response['Location']
|
||||
@ -444,3 +453,10 @@ class NewVersionTest(TestCase):
|
||||
self.assertEqual(new_version.schema_version, '1.0.0')
|
||||
self.assertEqual(new_version.release_notes, 'new version')
|
||||
self.assertEqual(new_version.file.get_status_display(), 'Approved')
|
||||
self.assertEqual(
|
||||
ApprovalActivity.objects.filter(
|
||||
extension=self.extension,
|
||||
type=ApprovalActivity.ActivityType.UPLOADED_NEW_VERSION,
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
@ -74,7 +74,7 @@ class UpdateTest(TestCase):
|
||||
self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 1)
|
||||
self.assertEqual(extension.previews.count(), 1)
|
||||
file1 = extension.previews.all()[0]
|
||||
self.assertEqual(file1.extension_preview.first().caption, 'First Preview Caption Text')
|
||||
self.assertEqual(file1.preview.caption, 'First Preview Caption Text')
|
||||
self.assertEqual(
|
||||
file1.original_hash,
|
||||
'sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
|
||||
@ -123,8 +123,8 @@ class UpdateTest(TestCase):
|
||||
self.assertEqual(extension.previews.count(), 2)
|
||||
file1 = extension.previews.all()[0]
|
||||
file2 = extension.previews.all()[1]
|
||||
self.assertEqual(file1.extension_preview.first().caption, 'First Preview Caption Text')
|
||||
self.assertEqual(file2.extension_preview.first().caption, 'Second Preview Caption Text')
|
||||
self.assertEqual(file1.preview.caption, 'First Preview Caption Text')
|
||||
self.assertEqual(file2.preview.caption, 'Second Preview Caption Text')
|
||||
self.assertEqual(
|
||||
file1.original_hash,
|
||||
'sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
|
||||
|
@ -1,3 +1,5 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
@ -80,6 +82,64 @@ class PublicViewsTest(_BaseTestCase):
|
||||
self.assertTemplateUsed(response, 'extensions/home.html')
|
||||
|
||||
|
||||
class ApiViewsTest(_BaseTestCase):
|
||||
def test_blender_version_filter(self):
|
||||
create_approved_version(blender_version_min='4.0.1')
|
||||
create_approved_version(blender_version_min='4.1.1')
|
||||
create_approved_version(blender_version_min='4.2.1')
|
||||
url = reverse('extensions:api')
|
||||
|
||||
json = self.client.get(
|
||||
url + '?blender_version=4.1.1',
|
||||
HTTP_ACCEPT='application/json',
|
||||
).json()
|
||||
self.assertEqual(len(json['data']), 2)
|
||||
|
||||
json2 = self.client.get(
|
||||
url + '?blender_version=3.0.1',
|
||||
HTTP_ACCEPT='application/json',
|
||||
).json()
|
||||
self.assertEqual(len(json2['data']), 0)
|
||||
|
||||
json3 = self.client.get(
|
||||
url + '?blender_version=4.3.1',
|
||||
HTTP_ACCEPT='application/json',
|
||||
).json()
|
||||
self.assertEqual(len(json3['data']), 3)
|
||||
|
||||
def test_blender_version_filter_latest_not_max_version(self):
|
||||
version = create_approved_version(blender_version_min='4.0.1')
|
||||
version.date_created
|
||||
extension = version.extension
|
||||
create_approved_version(
|
||||
blender_version_min='4.2.1',
|
||||
extension=extension,
|
||||
date_created=version.date_created + timedelta(days=1),
|
||||
version='2.0.0',
|
||||
)
|
||||
create_approved_version(
|
||||
blender_version_min='3.0.0',
|
||||
extension=extension,
|
||||
date_created=version.date_created + timedelta(days=2),
|
||||
version='1.0.1',
|
||||
)
|
||||
create_approved_version(
|
||||
blender_version_min='4.2.1',
|
||||
extension=extension,
|
||||
date_created=version.date_created + timedelta(days=3),
|
||||
version='2.0.1',
|
||||
)
|
||||
url = reverse('extensions:api')
|
||||
|
||||
json = self.client.get(
|
||||
url + '?blender_version=4.1.1',
|
||||
HTTP_ACCEPT='application/json',
|
||||
).json()
|
||||
self.assertEqual(len(json['data']), 1)
|
||||
# we are expecting the latest matching, not the maximum version
|
||||
self.assertEqual(json['data'][0]['version'], '1.0.1')
|
||||
|
||||
|
||||
class ExtensionDetailViewTest(_BaseTestCase):
|
||||
def test_cannot_view_unlisted_extension_anonymously(self):
|
||||
extension = _create_extension()
|
||||
@ -229,3 +289,36 @@ class UpdateVersionViewTest(_BaseTestCase):
|
||||
self.client.force_login(random_user)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_blender_max_version(self):
|
||||
extension = _create_extension()
|
||||
extension_owner = extension.latest_version.file.user
|
||||
extension.authors.add(extension_owner)
|
||||
self.client.force_login(extension_owner)
|
||||
url = reverse(
|
||||
'extensions:version-update',
|
||||
kwargs={
|
||||
'type_slug': extension.type_slug,
|
||||
'slug': extension.slug,
|
||||
'pk': extension.latest_version.pk,
|
||||
},
|
||||
)
|
||||
version = extension.latest_version
|
||||
|
||||
response = self.client.post(
|
||||
url,
|
||||
{'release_notes': 'text', 'blender_version_max': 'invalid'},
|
||||
)
|
||||
# error page, no redirect
|
||||
self.assertEqual(response.status_code, 200)
|
||||
version.refresh_from_db()
|
||||
self.assertIsNone(version.blender_version_max)
|
||||
|
||||
response2 = self.client.post(
|
||||
url,
|
||||
{'release_notes': 'text', 'blender_version_max': '4.2.0'},
|
||||
)
|
||||
# success, redirect
|
||||
self.assertEqual(response2.status_code, 302)
|
||||
version.refresh_from_db()
|
||||
self.assertEqual(version.blender_version_max, '4.2.0')
|
||||
|
@ -42,38 +42,51 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
|
||||
self.fail('invalid_version')
|
||||
|
||||
def to_representation(self, instance):
|
||||
blender_version_min = instance.latest_version.blender_version_min
|
||||
blender_version_max = instance.latest_version.blender_version_max
|
||||
|
||||
# TODO: get the latest valid version
|
||||
# For now we skip the extension if the latest version is not in a valid range.
|
||||
if self.blender_version and not is_in_version_range(
|
||||
self.blender_version, blender_version_min, blender_version_max
|
||||
matching_version = None
|
||||
# avoid triggering additional db queries, reuse the prefetched queryset
|
||||
versions = [
|
||||
v
|
||||
for v in instance.versions.all()
|
||||
if v.file and v.file.status in instance.valid_file_statuses
|
||||
]
|
||||
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(
|
||||
self.blender_version,
|
||||
v.blender_version_min,
|
||||
v.blender_version_max,
|
||||
):
|
||||
return {}
|
||||
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
|
||||
|
||||
data = {
|
||||
'id': instance.extension_id,
|
||||
'schema_version': instance.latest_version.schema_version,
|
||||
'schema_version': matching_version.schema_version,
|
||||
'name': instance.name,
|
||||
'version': instance.latest_version.version,
|
||||
'tagline': instance.latest_version.tagline,
|
||||
'archive_hash': instance.latest_version.file.original_hash,
|
||||
'archive_size': instance.latest_version.file.size_bytes,
|
||||
'archive_url': self.request.build_absolute_uri(instance.latest_version.download_url),
|
||||
'version': matching_version.version,
|
||||
'tagline': matching_version.tagline,
|
||||
'archive_hash': matching_version.file.original_hash,
|
||||
'archive_size': matching_version.file.size_bytes,
|
||||
'archive_url': self.request.build_absolute_uri(matching_version.download_url),
|
||||
'type': EXTENSION_TYPE_SLUGS_SINGULAR.get(instance.type),
|
||||
'blender_version_min': instance.latest_version.blender_version_min,
|
||||
'blender_version_max': instance.latest_version.blender_version_max,
|
||||
'blender_version_min': matching_version.blender_version_min,
|
||||
'blender_version_max': matching_version.blender_version_max,
|
||||
'website': self.request.build_absolute_uri(instance.get_absolute_url()),
|
||||
'maintainer': str(instance.authors.first()),
|
||||
'license': [
|
||||
license_iter.slug for license_iter in instance.latest_version.licenses.all()
|
||||
],
|
||||
'permissions': [
|
||||
permission.slug for permission in instance.latest_version.permissions.all()
|
||||
],
|
||||
# avoid triggering additional db queries, reuse the prefetched queryset
|
||||
'maintainer': 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()],
|
||||
# TODO: handle copyright
|
||||
'tags': [str(tag) for tag in instance.latest_version.tags.all()],
|
||||
'tags': [str(tag) for tag in matching_version.tags.all()],
|
||||
}
|
||||
|
||||
return clean_json_dictionary_from_optional_fields(data)
|
||||
@ -93,10 +106,18 @@ class ExtensionsAPIView(APIView):
|
||||
)
|
||||
def get(self, request):
|
||||
blender_version = request.GET.get('blender_version')
|
||||
qs = Extension.objects.listed.prefetch_related(
|
||||
'authors',
|
||||
'versions',
|
||||
'versions__file',
|
||||
'versions__licenses',
|
||||
'versions__permissions',
|
||||
'versions__tags',
|
||||
).all()
|
||||
serializer = self.serializer_class(
|
||||
Extension.objects.listed, blender_version=blender_version, request=request, many=True
|
||||
qs, blender_version=blender_version, request=request, many=True
|
||||
)
|
||||
data = serializer.data
|
||||
data = [e for e in serializer.data if e is not None]
|
||||
return Response(
|
||||
{
|
||||
# TODO implement extension blocking by moderators
|
||||
|
@ -4,6 +4,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404, reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
|
||||
|
||||
@ -42,7 +43,12 @@ class ExtensionDetailView(ExtensionQuerysetMixin, DetailView):
|
||||
* maintainers should be able to preview their yet unlisted add-ons;
|
||||
* staff should be able to preview yet unlisted add-ons;
|
||||
"""
|
||||
return self.get_extension_queryset()
|
||||
return self.get_extension_queryset().prefetch_related(
|
||||
'authors',
|
||||
'versions',
|
||||
'versions__file',
|
||||
'versions__file__validation',
|
||||
)
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
"""Record a page view when returning the Extension object."""
|
||||
@ -327,7 +333,7 @@ class UpdateVersionView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
|
||||
|
||||
template_name = 'extensions/new_version_finalise.html'
|
||||
model = Version
|
||||
fields = ['release_notes']
|
||||
fields = ['blender_version_max', 'release_notes']
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse(
|
||||
@ -353,6 +359,7 @@ class DraftExtensionView(
|
||||
):
|
||||
template_name = 'extensions/draft_finalise.html'
|
||||
form_class = VersionForm
|
||||
msg_awaiting_review = _('Extension is ready for initial review')
|
||||
|
||||
@property
|
||||
def success_message(self) -> str:
|
||||
@ -418,7 +425,7 @@ class DraftExtensionView(
|
||||
user=self.request.user,
|
||||
extension=extension_form.instance,
|
||||
type=ApprovalActivity.ActivityType.AWAITING_REVIEW,
|
||||
message="initial submission",
|
||||
message=self.msg_awaiting_review,
|
||||
).save()
|
||||
return super().form_valid(form)
|
||||
except forms.ValidationError as e:
|
||||
|
@ -66,6 +66,14 @@ class DraftMixin:
|
||||
"""If the extension is incomplete, returns the FinalizeDraftView"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if (
|
||||
'slug' in kwargs
|
||||
and Extension.objects.filter(
|
||||
slug=kwargs['slug'], status=Extension.STATUSES.APPROVED
|
||||
).first()
|
||||
):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
extension = (
|
||||
Extension.objects.listed_or_authored_by(user_id=self.request.user.pk)
|
||||
.filter(status=Extension.STATUSES.INCOMPLETE)
|
||||
|
@ -42,7 +42,19 @@ class HomeView(ListedExtensionsView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
q = super().get_queryset()
|
||||
q = (
|
||||
super()
|
||||
.get_queryset()
|
||||
.prefetch_related(
|
||||
'authors',
|
||||
'preview_set',
|
||||
'preview_set__file',
|
||||
'ratings',
|
||||
'versions',
|
||||
'versions__file',
|
||||
'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
|
||||
@ -92,7 +104,15 @@ class SearchView(ListedExtensionsView):
|
||||
| Q(versions__tags__name__icontains=token)
|
||||
)
|
||||
queryset = queryset.filter(search_query).distinct()
|
||||
return queryset
|
||||
return queryset.prefetch_related(
|
||||
'authors',
|
||||
'preview_set',
|
||||
'preview_set__file',
|
||||
'ratings',
|
||||
'versions',
|
||||
'versions__file',
|
||||
'versions__tags',
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
@ -109,9 +129,9 @@ class SearchView(ListedExtensionsView):
|
||||
# Determine which tags to list depending on the context.
|
||||
if context.get('type'):
|
||||
tag_type_id = self._get_type_id_by_slug()
|
||||
context['tags'] = Tag.objects.filter(type=tag_type_id)
|
||||
context['tags'] = Tag.objects.filter(type=tag_type_id).exclude(versions=None)
|
||||
elif context.get('tag'):
|
||||
tag_type_id = context['tag'].type
|
||||
context['tags'] = Tag.objects.filter(type=tag_type_id)
|
||||
context['tags'] = Tag.objects.filter(type=tag_type_id).exclude(versions=None)
|
||||
|
||||
return context
|
||||
|
@ -1,17 +1,29 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.template.loader import render_to_string
|
||||
import background_task.admin
|
||||
import background_task.models
|
||||
|
||||
from .models import File, FileValidation
|
||||
import files.signals
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def scan_selected_files(self, request, queryset):
|
||||
|
||||
def schedule_scan(self, request, queryset):
|
||||
"""Scan selected files."""
|
||||
for instance in queryset:
|
||||
files.signals.schedule_scan(instance)
|
||||
|
||||
|
||||
def make_thumbnails(self, request, queryset):
|
||||
"""Make thumbnails for selected files."""
|
||||
for instance in queryset.filter(type__in=(File.TYPES.IMAGE, File.TYPES.VIDEO)):
|
||||
files.tasks.make_thumbnails.task_function(file_id=instance.pk)
|
||||
|
||||
|
||||
class FileValidationInlineAdmin(admin.StackedInline):
|
||||
model = FileValidation
|
||||
readonly_fields = ('date_created', 'date_modified', 'is_ok', 'results')
|
||||
@ -27,6 +39,28 @@ class FileValidationInlineAdmin(admin.StackedInline):
|
||||
|
||||
@admin.register(File)
|
||||
class FileAdmin(admin.ModelAdmin):
|
||||
class Media:
|
||||
css = {'all': ('files/admin/file.css',)}
|
||||
|
||||
def thumbnails(self, obj):
|
||||
if not obj or not (obj.is_image or obj.is_video):
|
||||
return ''
|
||||
try:
|
||||
context = {'file': obj, 'MEDIA_URL': settings.MEDIA_URL}
|
||||
return render_to_string('files/admin/thumbnails.html', context)
|
||||
except Exception:
|
||||
# Make sure any exception happening here is always logged
|
||||
# (e.g. admin eats exceptions in ModelAdmin properties, making it hard to debug)
|
||||
logger.exception('Failed to render thumbnails')
|
||||
raise
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
"""Override metadata help text depending on file type."""
|
||||
if obj and (obj.is_image or obj.is_video):
|
||||
help_text = 'Additional information about the file, e.g. existing thumbnails.'
|
||||
kwargs.update({'help_texts': {'metadata': help_text}})
|
||||
return super().get_form(request, obj, **kwargs)
|
||||
|
||||
view_on_site = False
|
||||
save_on_top = True
|
||||
|
||||
@ -48,6 +82,9 @@ class FileAdmin(admin.ModelAdmin):
|
||||
'date_approved',
|
||||
'date_status_changed',
|
||||
'size_bytes',
|
||||
'thumbnails',
|
||||
'thumbnail',
|
||||
'type',
|
||||
'user',
|
||||
'original_hash',
|
||||
'original_name',
|
||||
@ -59,6 +96,9 @@ class FileAdmin(admin.ModelAdmin):
|
||||
'^version__extension__name',
|
||||
'extensions__slug',
|
||||
'extensions__name',
|
||||
'original_name',
|
||||
'hash',
|
||||
'source',
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
@ -67,9 +107,8 @@ class FileAdmin(admin.ModelAdmin):
|
||||
{
|
||||
'fields': (
|
||||
'id',
|
||||
('source', 'thumbnail'),
|
||||
('original_name', 'content_type'),
|
||||
'type',
|
||||
('source', 'thumbnails', 'thumbnail'),
|
||||
('type', 'content_type', 'original_name'),
|
||||
'status',
|
||||
)
|
||||
},
|
||||
@ -99,7 +138,7 @@ class FileAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
inlines = [FileValidationInlineAdmin]
|
||||
actions = [scan_selected_files]
|
||||
actions = [schedule_scan, make_thumbnails]
|
||||
|
||||
def is_ok(self, obj):
|
||||
return obj.validation.is_ok if hasattr(obj, 'validation') else None
|
||||
|
@ -7,3 +7,10 @@ class FilesConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
import files.signals # noqa: F401
|
||||
|
||||
# Ubuntu 22.04 and earlier don't have WebP in `/etc/mime.types`,
|
||||
# which makes .webp invalid from standpoint of file upload forms.
|
||||
# FIXME: remove once the application is running on the next Ubuntu 24.04 LTS
|
||||
import mimetypes
|
||||
|
||||
mimetypes.add_type('image/webp', '.webp', strict=True)
|
||||
|
19
files/migrations/0008_alter_file_thumbnail.py
Normal file
19
files/migrations/0008_alter_file_thumbnail.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.2.11 on 2024-04-23 10:31
|
||||
|
||||
from django.db import migrations, models
|
||||
import files.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('files', '0007_alter_file_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='file',
|
||||
name='thumbnail',
|
||||
field=models.ImageField(blank=True, editable=False, help_text='Thumbnail generated from uploaded image or video source file', max_length=256, null=True, upload_to=files.models.thumbnail_upload_to),
|
||||
),
|
||||
]
|
@ -6,11 +6,8 @@ from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
|
||||
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin
|
||||
from files.utils import get_sha256, guess_mimetype_from_ext
|
||||
from constants.base import (
|
||||
FILE_STATUS_CHOICES,
|
||||
FILE_TYPE_CHOICES,
|
||||
)
|
||||
from files.utils import get_sha256, guess_mimetype_from_ext, get_thumbnail_upload_to
|
||||
from constants.base import FILE_STATUS_CHOICES, FILE_TYPE_CHOICES
|
||||
import utils
|
||||
|
||||
User = get_user_model()
|
||||
@ -41,15 +38,11 @@ def file_upload_to(instance, filename):
|
||||
|
||||
|
||||
def thumbnail_upload_to(instance, filename):
|
||||
prefix = 'thumbnails/'
|
||||
_hash = instance.hash.split(':')[-1]
|
||||
extension = Path(filename).suffix
|
||||
path = Path(prefix, _hash[:2], _hash).with_suffix(extension)
|
||||
return path
|
||||
return get_thumbnail_upload_to(instance.hash)
|
||||
|
||||
|
||||
class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
|
||||
track_changes_to_fields = {'status', 'size_bytes', 'hash'}
|
||||
track_changes_to_fields = {'status', 'size_bytes', 'hash', 'thumbnail', 'metadata'}
|
||||
|
||||
TYPES = FILE_TYPE_CHOICES
|
||||
STATUSES = FILE_STATUS_CHOICES
|
||||
@ -63,7 +56,8 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
|
||||
null=True,
|
||||
blank=True,
|
||||
max_length=256,
|
||||
help_text='Image thumbnail in case file is a video',
|
||||
help_text='Thumbnail generated from uploaded image or video source file',
|
||||
editable=False,
|
||||
)
|
||||
content_type = models.CharField(max_length=256, null=True, blank=True)
|
||||
type = models.PositiveSmallIntegerField(
|
||||
@ -203,6 +197,30 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
|
||||
def get_submit_url(self) -> str:
|
||||
return self.extension.get_draft_url()
|
||||
|
||||
def get_thumbnail_of_size(self, size_key: str) -> str:
|
||||
"""Return absolute path portion of the URL of a thumbnail of this file.
|
||||
|
||||
Fall back to the source file, if no thumbnail is stored.
|
||||
Log absence of the thumbnail file instead of exploding somewhere in the templates.
|
||||
"""
|
||||
# We don't (yet?) have thumbnails for anything other than images and videos.
|
||||
assert self.is_image or self.is_video, f'File pk={self.pk} is neither image nor video'
|
||||
|
||||
try:
|
||||
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}')
|
||||
return self.source.url
|
||||
|
||||
@property
|
||||
def thumbnail_1080p_url(self) -> str:
|
||||
return self.get_thumbnail_of_size('1080p')
|
||||
|
||||
@property
|
||||
def thumbnail_360p_url(self) -> str:
|
||||
return self.get_thumbnail_of_size('360p')
|
||||
|
||||
|
||||
class FileValidation(CreatedModifiedMixin, TrackChangesMixin, models.Model):
|
||||
track_changes_to_fields = {'is_ok', 'results'}
|
||||
|
@ -1,10 +1,12 @@
|
||||
import logging
|
||||
|
||||
from django.db.models.signals import pre_save, post_save, pre_delete
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
import files.models
|
||||
import files.tasks
|
||||
import files.utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -35,6 +37,55 @@ def _scan_new_file(
|
||||
schedule_scan(instance)
|
||||
|
||||
|
||||
def schedule_thumbnails(file: files.models.File) -> None:
|
||||
"""Schedule thumbnail generation for a given file."""
|
||||
if not file.is_image and not file.is_video:
|
||||
return
|
||||
args = {'pk': file.pk, 'type': file.get_type_display()}
|
||||
logger.info('Scheduling thumbnail generation for file pk=%(pk)s type=%(type)s', args)
|
||||
verbose_name = f'make thumbnails for "{file.source.name}"'
|
||||
files.tasks.make_thumbnails(file_id=file.pk, creator=file, verbose_name=verbose_name)
|
||||
|
||||
|
||||
def _schedule_thumbnails_when_created(
|
||||
sender: object, instance: files.models.File, created: bool, **kwargs: object
|
||||
) -> None:
|
||||
if not created:
|
||||
return
|
||||
|
||||
schedule_thumbnails(instance)
|
||||
|
||||
|
||||
def _schedule_thumbnails_when_validated(
|
||||
sender: object, instance: files.models.FileValidation, created: bool, **kwargs: object
|
||||
) -> None:
|
||||
if not created:
|
||||
return
|
||||
|
||||
if not instance.is_ok:
|
||||
return
|
||||
|
||||
# Generate thumbnails if initial scan found no issues
|
||||
schedule_thumbnails(instance.file)
|
||||
|
||||
|
||||
if settings.REQUIRE_FILE_VALIDATION:
|
||||
# Only schedule thumbnails when file is validated
|
||||
post_save.connect(_schedule_thumbnails_when_validated, sender=files.models.FileValidation)
|
||||
else:
|
||||
# Schedule thumbnails when a new file is created
|
||||
post_save.connect(_schedule_thumbnails_when_created, sender=files.models.File)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=files.models.File)
|
||||
def _log_file_delete(sender: object, instance: files.models.File, **kwargs: object) -> None:
|
||||
logger.info('Deleting file pk=%s source=%s', instance.pk, instance.source.name)
|
||||
@receiver(pre_delete, sender=files.models.FileValidation)
|
||||
def _log_deletion(sender: object, instance: files.models.File, **kwargs: object) -> None:
|
||||
instance.record_deletion()
|
||||
|
||||
|
||||
@receiver(post_delete, sender=files.models.File)
|
||||
def delete_orphaned_files(sender: object, instance: files.models.File, **kwargs: object) -> None:
|
||||
"""Delete source and thumbnail files from storage when File record is deleted."""
|
||||
files.utils.delete_file_in_storage(instance.source.name)
|
||||
files.utils.delete_file_in_storage(instance.thumbnail.name)
|
||||
files.utils.delete_thumbnails(instance.metadata)
|
||||
|
11
files/static/files/admin/file.css
Normal file
11
files/static/files/admin/file.css
Normal file
@ -0,0 +1,11 @@
|
||||
.file-thumbnail {
|
||||
display: inline-block;
|
||||
border: grey solid 1px;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.file-thumbnail-size {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
padding-right: 0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
@ -27,3 +27,45 @@ def clamdscan(file_id: int):
|
||||
file_validation.results = scan_result
|
||||
file_validation.is_ok = is_ok
|
||||
file_validation.save(update_fields={'results', 'is_ok', 'date_modified'})
|
||||
|
||||
|
||||
@background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING})
|
||||
def make_thumbnails(file_id: int) -> None:
|
||||
"""Generate thumbnails for a given file, store them in thumbnail and metadata columns."""
|
||||
file = files.models.File.objects.get(pk=file_id)
|
||||
args = {'pk': file_id, 'type': file.get_type_display()}
|
||||
|
||||
if not file.is_image and not file.is_video:
|
||||
logger.error('File pk=%(pk)s of type "%(type)s" is neither an image nor a video', args)
|
||||
return
|
||||
if settings.REQUIRE_FILE_VALIDATION and not file.validation.is_ok:
|
||||
logger.error("File pk={pk} is flagged, won't make thumbnails".format(**args))
|
||||
return
|
||||
|
||||
# For an image, source of the thumbnails is the original image
|
||||
source_path = file.source.path
|
||||
thumbnail_field = file.thumbnail
|
||||
unchanged_thumbnail = thumbnail_field.name
|
||||
|
||||
if file.is_video:
|
||||
frame_path = files.utils.get_thumbnail_upload_to(file.hash)
|
||||
# For a video, source of the thumbnails is a frame extracted with ffpeg
|
||||
files.utils.extract_frame(source_path, frame_path)
|
||||
thumbnail_field.name = frame_path
|
||||
source_path = frame_path
|
||||
|
||||
thumbnails = files.utils.make_thumbnails(source_path, file.hash)
|
||||
|
||||
if not thumbnail_field.name:
|
||||
thumbnail_field.name = thumbnails['1080p']['path']
|
||||
|
||||
update_fields = set()
|
||||
if thumbnail_field.name != unchanged_thumbnail:
|
||||
update_fields.add('thumbnail')
|
||||
if file.metadata.get('thumbnails') != thumbnails:
|
||||
file.metadata.update({'thumbnails': thumbnails})
|
||||
update_fields.add('metadata')
|
||||
if update_fields:
|
||||
args['update_fields'] = update_fields
|
||||
logger.info('Made thumbnails for file pk=%(pk)s, updating %(update_fields)s', args)
|
||||
file.save(update_fields=update_fields)
|
||||
|
8
files/templates/files/admin/thumbnails.html
Normal file
8
files/templates/files/admin/thumbnails.html
Normal file
@ -0,0 +1,8 @@
|
||||
<div class="file-thumbnails">
|
||||
{% for size_key, thumb in file.metadata.thumbnails.items %}
|
||||
<div class="file-thumbnail">
|
||||
<span class="file-thumbnail-size">{{ thumb.size.0 }}x{{ thumb.size.1 }}px</span>
|
||||
<img height="{% widthratio thumb.size.1 10 1 %}" src="{{ MEDIA_URL }}{{ thumb.path }}" title={{ thumb.path }}>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
@ -1,21 +1,19 @@
|
||||
{% load common i18n %}
|
||||
{# FIXME: we might want to rephrase is_moderator in terms of Django's (group) permissions #}
|
||||
{% if perms.files.view_file or request.user.is_moderator %}
|
||||
{% with file_validation=file.validation %}
|
||||
{% if file_validation and not file_validation.is_ok %}
|
||||
{% if suspicious_files %}
|
||||
<section>
|
||||
<div class="card pb-3 pt-4 px-4 mb-3 ext-detail-download-danger">
|
||||
<h3>⚠ {% trans "Suspicious upload" %}</h3>
|
||||
{% blocktrans asvar alert_text %}Scan of the {{ file }} indicates malicious content.{% endblocktrans %}
|
||||
{% blocktrans asvar alert_text %}Scan of the {{ suspicious_files.0 }} indicates malicious content.{% endblocktrans %}
|
||||
<h4>
|
||||
{{ alert_text }}
|
||||
{% if perms.files.view_file %}{# Moderators don't necessarily have access to the admin #}
|
||||
{% url 'admin:files_file_change' file.pk as admin_file_url %}
|
||||
{% url 'admin:files_file_change' suspicious_files.0.pk as admin_file_url %}
|
||||
<a href="{{ admin_file_url }}" target="_blank">{% trans "See details" %}</a>
|
||||
{% endif %}
|
||||
</h4>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
@ -1,10 +1,8 @@
|
||||
{% load common i18n %}
|
||||
{# FIXME: we might want to rephrase is_moderator in terms of Django's (group) permissions #}
|
||||
{% if perms.files.view_file or request.user.is_moderator %}
|
||||
{% with file_validation=file.validation %}
|
||||
{% if file_validation and not file_validation.is_ok %}
|
||||
{% blocktrans asvar alert_text %}Scan of the {{ file }} indicates malicious content.{% endblocktrans %}
|
||||
{% if suspicious_files %}
|
||||
{% blocktrans asvar alert_text %}Scan of the {{ suspicious_files.0 }} indicates malicious content.{% endblocktrans %}
|
||||
<b class="text-danger pt-2" title="{{ alert_text }}">⚠</b>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
@ -42,9 +42,11 @@ class FileTest(TestCase):
|
||||
'new_state': {'status': 'Approved'},
|
||||
'object': '<File: test.zip (Approved)>',
|
||||
'old_state': {
|
||||
'status': 2,
|
||||
'hash': 'foobar',
|
||||
'metadata': {},
|
||||
'size_bytes': 7149,
|
||||
'status': 2,
|
||||
'thumbnail': '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
112
files/tests/test_tasks.py
Normal file
112
files/tests/test_tasks.py
Normal file
@ -0,0 +1,112 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
import logging
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from common.tests.factories.files import FileFactory
|
||||
from files.tasks import make_thumbnails
|
||||
import files.models
|
||||
|
||||
TEST_MEDIA_DIR = Path(__file__).resolve().parent / 'media'
|
||||
|
||||
|
||||
@override_settings(MEDIA_ROOT=TEST_MEDIA_DIR, REQUIRE_FILE_VALIDATION=True)
|
||||
class TasksTest(TestCase):
|
||||
def test_make_thumbnails_fails_when_no_validation(self):
|
||||
file = FileFactory(original_hash='foobar', source='file/original_image_source.jpg')
|
||||
|
||||
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')
|
||||
files.models.FileValidation.objects.create(file=file, is_ok=False, results={})
|
||||
|
||||
with self.assertLogs(level=logging.ERROR) as logs:
|
||||
make_thumbnails.task_function(file_id=file.pk)
|
||||
|
||||
self.maxDiff = None
|
||||
self.assertEqual(
|
||||
logs.output[0], f"ERROR:files.tasks:File pk={file.pk} is flagged, won't make thumbnails"
|
||||
)
|
||||
|
||||
mock_make_thumbnails.assert_not_called()
|
||||
|
||||
@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
|
||||
)
|
||||
|
||||
with self.assertLogs(level=logging.ERROR) as logs:
|
||||
make_thumbnails.task_function(file_id=file.pk)
|
||||
|
||||
self.maxDiff = None
|
||||
self.assertEqual(
|
||||
logs.output[0],
|
||||
f'ERROR:files.tasks:File pk={file.pk} of type "Theme" is neither an image nor a video',
|
||||
)
|
||||
|
||||
mock_make_thumbnails.assert_not_called()
|
||||
|
||||
@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')
|
||||
files.models.FileValidation.objects.create(file=file, is_ok=True, results={})
|
||||
self.assertIsNone(file.thumbnail.name)
|
||||
self.assertEqual(file.metadata, {})
|
||||
|
||||
make_thumbnails.task_function(file_id=file.pk)
|
||||
|
||||
mock_image.open.assert_called_once_with(
|
||||
str(TEST_MEDIA_DIR / 'file' / 'original_image_source.jpg')
|
||||
)
|
||||
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.metadata,
|
||||
{
|
||||
'thumbnails': {
|
||||
'1080p': {'path': 'thumbnails/fo/foobar_1920x1080.png', 'size': [1920, 1080]},
|
||||
'360p': {'path': 'thumbnails/fo/foobar_640x360.png', 'size': [640, 360]},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@patch('files.utils.resize_image')
|
||||
@patch('files.utils.Image')
|
||||
@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
|
||||
)
|
||||
files.models.FileValidation.objects.create(file=file, is_ok=True, results={})
|
||||
self.assertIsNone(file.thumbnail.name)
|
||||
self.assertEqual(file.metadata, {})
|
||||
|
||||
make_thumbnails.task_function(file_id=file.pk)
|
||||
|
||||
mock_ffmpeg.assert_called_once_with()
|
||||
mock_image.open.assert_called_once_with(
|
||||
str(TEST_MEDIA_DIR / 'thumbnails' / 'de' / 'deadbeef.png')
|
||||
)
|
||||
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')
|
||||
# Check that File metadata and thumbnail fields were updated
|
||||
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]},
|
||||
},
|
||||
},
|
||||
)
|
@ -1,6 +1,20 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, ANY
|
||||
import tempfile
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from files.utils import find_path_by_name, find_exact_path, filter_paths_by_ext
|
||||
from files.utils import (
|
||||
extract_frame,
|
||||
filter_paths_by_ext,
|
||||
find_exact_path,
|
||||
find_path_by_name,
|
||||
get_thumbnail_upload_to,
|
||||
make_thumbnails,
|
||||
)
|
||||
|
||||
# Reusing test files from the extensions app
|
||||
TEST_FILES_DIR = Path(__file__).resolve().parent.parent.parent / 'extensions' / 'tests' / 'files'
|
||||
|
||||
|
||||
class UtilsTest(TestCase):
|
||||
@ -98,3 +112,49 @@ class UtilsTest(TestCase):
|
||||
]
|
||||
paths = filter_paths_by_ext(name_list, '.md')
|
||||
self.assertEqual(list(paths), [])
|
||||
|
||||
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'),
|
||||
):
|
||||
with self.subTest(file_hash=file_hash, kwargs=kwargs):
|
||||
self.assertEqual(get_thumbnail_upload_to(file_hash, **kwargs), expected)
|
||||
|
||||
@patch('files.utils.resize_image')
|
||||
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]},
|
||||
},
|
||||
make_thumbnails(TEST_FILES_DIR / 'test_preview_image_0001.png', 'foobar'),
|
||||
)
|
||||
|
||||
self.assertEqual(len(mock_resize_image.mock_calls), 2)
|
||||
for expected_size in ([1920, 1080], [640, 360]):
|
||||
with self.subTest(expected_size=expected_size):
|
||||
mock_resize_image.assert_any_call(
|
||||
ANY,
|
||||
expected_size,
|
||||
ANY,
|
||||
output_format='PNG',
|
||||
quality=83,
|
||||
optimize=True,
|
||||
progressive=True,
|
||||
)
|
||||
|
||||
@patch('files.utils.FFmpeg')
|
||||
def test_extract_frame(self, mock_ffmpeg):
|
||||
with tempfile.TemporaryDirectory() as output_dir:
|
||||
extract_frame('path/to/source/video.mp4', output_dir + '/frame.png')
|
||||
mock_ffmpeg.return_value.option.return_value.input.return_value.output.assert_any_call(
|
||||
output_dir + '/frame.png', {'ss': '00:00:00.01', 'frames:v': 1, 'update': 'true'}
|
||||
)
|
||||
|
||||
self.assertEqual(len(mock_ffmpeg.mock_calls), 5)
|
||||
mock_ffmpeg.assert_any_call()
|
||||
mock_ffmpeg.return_value.option.return_value.input.assert_any_call(
|
||||
'path/to/source/video.mp4'
|
||||
)
|
||||
|
124
files/utils.py
124
files/utils.py
@ -1,18 +1,26 @@
|
||||
from pathlib import Path
|
||||
import datetime
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import os.path
|
||||
import tempfile
|
||||
import toml
|
||||
import typing
|
||||
import zipfile
|
||||
|
||||
from PIL import Image
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from ffmpeg import FFmpeg, FFmpegFileNotFound, FFmpegInvalidCommand, FFmpegError
|
||||
from lxml import etree
|
||||
import clamd
|
||||
import magic
|
||||
|
||||
from constants.base import THUMBNAIL_FORMAT, THUMBNAIL_SIZES, THUMBNAIL_QUALITY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MODULE_DIR = Path(__file__).resolve().parent
|
||||
THEME_SCHEMA = []
|
||||
@ -172,3 +180,119 @@ def run_clamdscan(abs_path: str) -> tuple:
|
||||
result = clamd_socket.instream(f)['stream']
|
||||
logger.info('File at path=%s scanned: %s', abs_path, result)
|
||||
return result
|
||||
|
||||
|
||||
def delete_file_in_storage(file_name: str) -> None:
|
||||
"""Delete file from disk or whatever other default storage."""
|
||||
if not file_name:
|
||||
return
|
||||
|
||||
if not default_storage.exists(file_name):
|
||||
logger.warning("%s doesn't exist in storage, nothing to delete", file_name)
|
||||
else:
|
||||
logger.info('Deleting %s from storage', file_name)
|
||||
default_storage.delete(file_name)
|
||||
|
||||
|
||||
def delete_thumbnails(file_metadata: dict) -> None:
|
||||
"""Read thumbnail paths from given metadata and delete them from storage."""
|
||||
thumbnails = file_metadata.get('thumbnails', {})
|
||||
for _, thumb in thumbnails.items():
|
||||
path = thumb.get('path', '')
|
||||
if not path:
|
||||
continue
|
||||
delete_file_in_storage(path)
|
||||
|
||||
|
||||
def get_thumbnail_upload_to(file_hash: str, width: int = None, height: int = None) -> str:
|
||||
"""Return a full media path of a thumbnail.
|
||||
|
||||
Optionally, append thumbnail dimensions to the file name.
|
||||
"""
|
||||
prefix = 'thumbnails/'
|
||||
_hash = file_hash.split(':')[-1]
|
||||
thumbnail_ext = THUMBNAIL_FORMAT.lower()
|
||||
if thumbnail_ext == 'jpeg':
|
||||
thumbnail_ext = 'jpg'
|
||||
suffix = f'.{thumbnail_ext}'
|
||||
size_suffix = f'_{width}x{height}' if width and height else ''
|
||||
path = Path(prefix, _hash[:2], f'{_hash}{size_suffix}').with_suffix(suffix)
|
||||
return str(path)
|
||||
|
||||
|
||||
def resize_image(image: Image, size: tuple, output, output_format: str = 'PNG', **output_params):
|
||||
"""Resize a models.ImageField to a given size and write it into output file."""
|
||||
start_t = datetime.datetime.now()
|
||||
|
||||
source_image = image.convert('RGBA' if output_format == 'PNG' else 'RGB')
|
||||
source_image.thumbnail(size, Image.LANCZOS)
|
||||
source_image.save(output, output_format, **output_params)
|
||||
|
||||
end_t = datetime.datetime.now()
|
||||
args = {'source': image, 'size': size, 'time': (end_t - start_t).microseconds / 1000}
|
||||
logger.info('%(source)s to %(size)s done in %(time)sms', args)
|
||||
|
||||
|
||||
def make_thumbnails(
|
||||
source_path: str, file_hash: str, output_format: str = THUMBNAIL_FORMAT
|
||||
) -> dict:
|
||||
"""Generate thumbnail files for given file and a predefined list of dimensions.
|
||||
|
||||
Resulting thumbnail paths a derived from the given file hash and thumbnail sizes.
|
||||
Return a dict of size keys to output paths of generated thumbnail images.
|
||||
"""
|
||||
start_t = datetime.datetime.now()
|
||||
thumbnails = {}
|
||||
abs_path = os.path.join(settings.MEDIA_ROOT, source_path)
|
||||
image = Image.open(abs_path)
|
||||
for size_key, size in THUMBNAIL_SIZES.items():
|
||||
w, h = size
|
||||
output_path = get_thumbnail_upload_to(file_hash, width=w, height=h)
|
||||
with tempfile.TemporaryFile() as f:
|
||||
logger.info('Resizing %s to %s (%s)', abs_path, size, output_format)
|
||||
resize_image(
|
||||
image,
|
||||
size,
|
||||
f,
|
||||
output_format=THUMBNAIL_FORMAT,
|
||||
quality=THUMBNAIL_QUALITY,
|
||||
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)
|
||||
thumbnails[size_key] = {'size': size, 'path': output_path}
|
||||
image.close()
|
||||
|
||||
end_t = datetime.datetime.now()
|
||||
args = {'source': source_path, 'time': (end_t - start_t).microseconds / 1000}
|
||||
logger.info('%(source)s done in %(time)sms', args)
|
||||
return thumbnails
|
||||
|
||||
|
||||
def extract_frame(source_path: str, output_path: str, at_time: str = '00:00:00.01'):
|
||||
"""Extract a single frame of a video at a given path, write it to the given output path."""
|
||||
try:
|
||||
start_t = datetime.datetime.now()
|
||||
abs_path = os.path.join(settings.MEDIA_ROOT, output_path)
|
||||
ffmpeg = (
|
||||
FFmpeg()
|
||||
.option('y')
|
||||
.input(source_path)
|
||||
.output(abs_path, {'ss': at_time, 'frames:v': 1, 'update': 'true'})
|
||||
)
|
||||
output_dir = os.path.dirname(abs_path)
|
||||
if not os.path.isdir(output_dir):
|
||||
os.mkdir(output_dir)
|
||||
ffmpeg.execute()
|
||||
|
||||
end_t = datetime.datetime.now()
|
||||
args = {'source': source_path, 'time': (end_t - start_t).microseconds / 1000}
|
||||
logger.info('%(source)s done in %(time)sms', args)
|
||||
except (FFmpegError, FFmpegFileNotFound, FFmpegInvalidCommand) as e:
|
||||
logger.exception(f'Failed to extract a frame: {e.message}, {" ".join(ffmpeg.arguments)}')
|
||||
raise
|
||||
|
@ -28,11 +28,6 @@ class Command(BaseCommand):
|
||||
logger.info(f'{recipient} has unconfirmed email, skipping')
|
||||
n.save()
|
||||
continue
|
||||
# FIXME test with only internal emails first
|
||||
if not recipient.email.endswith('@blender.org'):
|
||||
logger.info('skipping: not an internal email')
|
||||
n.save()
|
||||
continue
|
||||
n.email_sent = True
|
||||
# first mark as processed, then send: avoid spamming in case of a crash-loop
|
||||
n.save()
|
||||
|
@ -32,9 +32,9 @@ class Notification(models.Model):
|
||||
|
||||
def format_email(self):
|
||||
action = self.action
|
||||
subject = f'New Activity: {action.actor.full_name} {action.verb} {action.target}'
|
||||
subject = f'New Activity: {action.actor} {action.verb} {action.target}'
|
||||
url = self.get_absolute_url()
|
||||
mesage = f'{action.actor.full_name} {action.verb} {action.target}: {url}'
|
||||
mesage = f'{action.actor} {action.verb} {action.target}: {url}'
|
||||
return (subject, mesage)
|
||||
|
||||
def get_absolute_url(self):
|
||||
|
@ -18,6 +18,7 @@ VERB2FLAGS = {
|
||||
Verb.REPORTED_RATING: [Flag.MODERATOR],
|
||||
Verb.REQUESTED_CHANGES: [Flag.AUTHOR, Flag.REVIEWER],
|
||||
Verb.REQUESTED_REVIEW: [Flag.MODERATOR, Flag.REVIEWER],
|
||||
Verb.UPLOADED_NEW_VERSION: [],
|
||||
}
|
||||
|
||||
|
||||
@ -41,7 +42,7 @@ def _create_notifications(
|
||||
notifications = []
|
||||
|
||||
flags = VERB2FLAGS.get(instance.verb, None)
|
||||
if not flags:
|
||||
if flags is None:
|
||||
logger.warning(f'no follower flags for verb={instance.verb}, nobody will be notified')
|
||||
return
|
||||
|
||||
|
@ -1,22 +1,48 @@
|
||||
{% extends "common/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load common filters i18n %}
|
||||
|
||||
{% block page_title %}{% blocktranslate %}Notifications{% endblocktranslate %}{% endblock page_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% trans 'Notifications' %}
|
||||
{% if user|unread_notification_count %}
|
||||
<form class="d-inline" action="{% url 'notifications:notifications-mark-read-all' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-sm" type="submit">{% trans 'Mark all as read' %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% if notification_list %}
|
||||
{% for notification in notification_list %}
|
||||
<div class="row">
|
||||
{{ notification.action }}
|
||||
{% if notification.read_at %}
|
||||
{% else %}
|
||||
{% blocktranslate %}Mark as read{% endblocktranslate %}
|
||||
<div class="row mb-2 {% if notification.read_at%}text-muted{% endif %}">
|
||||
<div class="col">
|
||||
|
||||
{{ notification.action.timestamp | naturaltime_compact }}
|
||||
|
||||
<a href="{% url 'extensions:by-author' user_id=notification.action.actor.pk %}">
|
||||
{{ notification.action.actor }}
|
||||
</a>
|
||||
|
||||
{{ notification.action.verb }}
|
||||
|
||||
<a href="{{ notification.action.target.get_absolute_url }}">{{ notification.action.target }}</a>
|
||||
|
||||
<a href="{{ notification.get_absolute_url }}"><button class="btn btn-sm">{% trans 'View' %}</button></a>
|
||||
|
||||
{% if not notification.read_at %}
|
||||
<form class="d-inline" action="{% url 'notifications:notifications-mark-read' pk=notification.pk %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-sm" type="submit">{% trans 'Mark as read' %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>
|
||||
{% blocktranslate %}You have no notifications{% endblocktranslate %}
|
||||
{% trans 'You have no notifications' %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""Notifications pages."""
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.http.response import JsonResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.utils import timezone
|
||||
from django.views.generic import ListView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
@ -13,11 +13,10 @@ from notifications.models import Notification
|
||||
|
||||
class NotificationsView(LoginRequiredMixin, ListView):
|
||||
model = Notification
|
||||
ordering = None # FIXME
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
return Notification.objects.filter(recipient=self.request.user)
|
||||
return Notification.objects.filter(recipient=self.request.user).order_by('-id')
|
||||
|
||||
|
||||
class MarkReadAllView(LoginRequiredMixin, FormView):
|
||||
@ -32,8 +31,7 @@ class MarkReadAllView(LoginRequiredMixin, FormView):
|
||||
notification.read_at = now
|
||||
|
||||
Notification.objects.bulk_update(unread, ['read_at'])
|
||||
|
||||
return JsonResponse({})
|
||||
return redirect('notifications:notifications')
|
||||
|
||||
|
||||
class MarkReadView(LoginRequiredMixin, SingleObjectMixin, View):
|
||||
@ -46,4 +44,4 @@ class MarkReadView(LoginRequiredMixin, SingleObjectMixin, View):
|
||||
return HttpResponseForbidden()
|
||||
notification.read_at = timezone.now()
|
||||
notification.save(update_fields=['read_at'])
|
||||
return JsonResponse({})
|
||||
return redirect('notifications:notifications')
|
||||
|
@ -11,6 +11,7 @@
|
||||
with_items:
|
||||
- clamav-daemon
|
||||
- clamav-unofficial-sigs
|
||||
- ffmpeg
|
||||
- git
|
||||
- libpq-dev
|
||||
- nginx-full
|
||||
@ -48,18 +49,7 @@
|
||||
tags:
|
||||
- dotenv
|
||||
|
||||
- name: Copying ASGI config files
|
||||
ansible.builtin.template:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
mode: 0644
|
||||
loop:
|
||||
- { src: templates/asgi/asgi.service, dest: "/etc/systemd/system/{{ service_name }}.service" }
|
||||
notify:
|
||||
- restart service
|
||||
tags:
|
||||
- asgi
|
||||
- gunicorn
|
||||
- import_tasks: tasks/configure_uwsgi.yaml
|
||||
|
||||
- import_tasks: tasks/deploy.yaml
|
||||
|
||||
|
22
playbooks/tasks/configure_uwsgi.yaml
Normal file
22
playbooks/tasks/configure_uwsgi.yaml
Normal file
@ -0,0 +1,22 @@
|
||||
---
|
||||
- name: Ensure /etc/uwsgi directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/uwsgi
|
||||
owner: root
|
||||
group: root
|
||||
state: directory
|
||||
mode: '0755'
|
||||
tags:
|
||||
- uwsgi
|
||||
- name: Copying uWSGI config files
|
||||
ansible.builtin.template:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
mode: 0644
|
||||
loop:
|
||||
- { src: templates/uwsgi/uwsgi.ini, dest: "/etc/uwsgi/{{ service_name }}.ini" }
|
||||
- { src: templates/uwsgi/uwsgi.service, dest: "/etc/systemd/system/{{ service_name }}.service" }
|
||||
notify:
|
||||
- restart service
|
||||
tags:
|
||||
- uwsgi
|
@ -1,24 +0,0 @@
|
||||
[Unit]
|
||||
Description={{ project_name }} {{ env|capitalize }}
|
||||
After=syslog.target network.target
|
||||
|
||||
[Service]
|
||||
User={{ user }}
|
||||
Group={{ group }}
|
||||
EnvironmentFile={{ env_file }}
|
||||
ExecStart={{ dir.source }}/.venv/bin/gunicorn {{ asgi_module }} -b 127.0.0.1:{{ port }} --max-requests {{ max_requests }} --max-requests-jitter {{ max_requests_jitter }} --workers {{ workers }} -k uvicorn.workers.UvicornWorker
|
||||
ExecReload=/bin/kill -s HUP $MAINPID
|
||||
Restart=always
|
||||
KillMode=mixed
|
||||
Type=notify
|
||||
SyslogIdentifier={{ service_name }}
|
||||
NotifyAccess=all
|
||||
WorkingDirectory={{ dir.source }}
|
||||
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=full
|
||||
CapabilityBoundingSet=~CAP_SYS_ADMIN
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
34
playbooks/templates/uwsgi/uwsgi.ini
Normal file
34
playbooks/templates/uwsgi/uwsgi.ini
Normal file
@ -0,0 +1,34 @@
|
||||
[uwsgi]
|
||||
uid = {{ user }}
|
||||
gid = {{ group }}
|
||||
pidfile = {{ uwsgi_pid }}
|
||||
env = DJANGO_SETTINGS_MODULE={{ django_settings_module }}
|
||||
env = LANG=en_US.UTF-8
|
||||
|
||||
# Django-related settings
|
||||
# the base directory (full path)
|
||||
chdir = {{ dir.source }}
|
||||
# Django's wsgi file
|
||||
module = {{ uwsgi_module }}
|
||||
# the virtualenv (full path)
|
||||
virtualenv = {{ dir.source }}/.venv/
|
||||
|
||||
buffer-size = 32768
|
||||
max-requests = 5000
|
||||
|
||||
# process-related settings
|
||||
master = true
|
||||
# maximum number of worker processes
|
||||
processes = 4
|
||||
# listen on HTTP port, use Keep-Alive
|
||||
http11-socket = 127.0.0.1:{{ port }}
|
||||
http-keepalive = 1
|
||||
|
||||
# clear environment on exit
|
||||
vacuum = true
|
||||
# silence "OSError: write error" generated by timing out clients
|
||||
disable-write-exception = true
|
||||
# running behind a proxy_pass, X-FORWARDED-FOR is the "real" IP address that should be logged
|
||||
log-x-forwarded-for = true
|
||||
# disable request logging: web server in front of uWSGI does this job better
|
||||
disable-logging = true
|
23
playbooks/templates/uwsgi/uwsgi.service
Normal file
23
playbooks/templates/uwsgi/uwsgi.service
Normal file
@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description={{ project_name }} {{ env|capitalize }} service.
|
||||
After=syslog.target
|
||||
|
||||
[Service]
|
||||
User={{ user }}
|
||||
Group={{ group }}
|
||||
EnvironmentFile={{ env_file }}
|
||||
ExecStart={{ dir.source }}/.venv/bin/uwsgi --ini /etc/uwsgi/{{ service_name }}.ini
|
||||
Restart=always
|
||||
KillSignal=SIGQUIT
|
||||
Type=notify
|
||||
SyslogIdentifier={{ service_name }}
|
||||
NotifyAccess=all
|
||||
WorkingDirectory={{ dir.source }}
|
||||
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=full
|
||||
CapabilityBoundingSet=~CAP_SYS_ADMIN
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
@ -3,9 +3,8 @@ project_name: Blender Extensions
|
||||
project_slug: blender-extensions
|
||||
service_name: "{{ project_slug }}-{{ env }}"
|
||||
background_service_name: '{{ service_name }}-background.service'
|
||||
|
||||
asgi_module: blender_extensions.asgi:application
|
||||
django_settings_module: blender_extensions.settings
|
||||
uwsgi_module: blender_extensions.wsgi:application
|
||||
max_requests: 1000
|
||||
max_requests_jitter: 50
|
||||
port: 8200
|
||||
@ -21,6 +20,7 @@ dir:
|
||||
errors: "/var/www/{{ service_name }}/html/errors"
|
||||
|
||||
env_file: "{{ dir.source }}/.env"
|
||||
uwsgi_pid: "{{ dir.source }}/{{ service_name }}.pid"
|
||||
|
||||
nginx:
|
||||
user: www-data
|
||||
|
@ -1,5 +1,5 @@
|
||||
from actstream import action
|
||||
from django.db.models.signals import pre_save, post_save
|
||||
from django.db.models.signals import pre_save, post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from constants.activity import Verb
|
||||
@ -40,3 +40,8 @@ def _create_action_from_rating(
|
||||
action_object=instance,
|
||||
target=instance.extension,
|
||||
)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Rating)
|
||||
def _log_deletion(sender: object, instance: Rating, **kwargs: object) -> None:
|
||||
instance.record_deletion()
|
||||
|
@ -63,6 +63,11 @@
|
||||
--star-size: 1.6em
|
||||
width: 8em
|
||||
|
||||
// TODO: refactor stars-helper
|
||||
.stars-helper
|
||||
max-height: 1.4rem
|
||||
transform: translateY(-.1rem)
|
||||
|
||||
.ratings-summary
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
@ -14,9 +14,9 @@
|
||||
{{ rating.user }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="me-auto">
|
||||
<li class="align-items-center d-flex me-auto">
|
||||
{% with score_percentage=rating.score %}
|
||||
<a href="{{ extension.get_ratings_url }}?score={{ rating.score }}">
|
||||
<a class="stars-helper" href="{{ extension.get_ratings_url }}?score={{ rating.score }}">
|
||||
{% include "ratings/components/average.html" with score=rating.score %}
|
||||
</a>
|
||||
{% endwith %}
|
||||
|
@ -4,7 +4,7 @@
|
||||
{% block page_title %}Rate {{ extension.name }}{% endblock page_title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="container rating-form">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% with form=form|add_form_classes %}
|
||||
|
@ -40,6 +40,7 @@ mistune==2.0.4
|
||||
multidict==6.0.2
|
||||
oauthlib==3.2.0
|
||||
Pillow==9.2.0
|
||||
python-ffmpeg==2.0.12
|
||||
python-magic==0.4.27
|
||||
requests==2.28.1
|
||||
requests-oauthlib==1.3.1
|
||||
@ -49,6 +50,5 @@ six==1.16.0
|
||||
sqlparse==0.4.2
|
||||
toml==0.10.2
|
||||
urllib3==1.26.11
|
||||
uvicorn==0.18.2
|
||||
webencodings==0.5.1
|
||||
yarl==1.7.2
|
||||
|
@ -1,3 +1,3 @@
|
||||
-r requirements.txt
|
||||
psycopg2==2.9.3
|
||||
gunicorn==20.1.0
|
||||
uwsgi==2.0.23
|
||||
|
18
reviewers/migrations/0009_alter_approvalactivity_type.py
Normal file
18
reviewers/migrations/0009_alter_approvalactivity_type.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.11 on 2024-04-29 17:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('reviewers', '0008_alter_approvalactivity_message'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='approvalactivity',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('COM', 'Comment'), ('APR', 'Approved'), ('AWC', 'Awaiting Changes'), ('AWR', 'Awaiting Review'), ('UNV', 'Uploaded New Version')], default='COM', max_length=3),
|
||||
),
|
||||
]
|
@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import common.help_texts
|
||||
from extensions.models import Extension
|
||||
from common.model_mixins import CreatedModifiedMixin
|
||||
from common.model_mixins import CreatedModifiedMixin, RecordDeletionMixin
|
||||
from utils import absolutify, send_mail
|
||||
|
||||
from constants.base import EXTENSION_TYPE_CHOICES
|
||||
@ -74,12 +74,13 @@ class ReviewerSubscription(CreatedModifiedMixin, models.Model):
|
||||
)
|
||||
|
||||
|
||||
class ApprovalActivity(CreatedModifiedMixin, models.Model):
|
||||
class ApprovalActivity(CreatedModifiedMixin, RecordDeletionMixin, models.Model):
|
||||
class ActivityType(models.TextChoices):
|
||||
COMMENT = "COM", _("Comment")
|
||||
APPROVED = "APR", _("Approved")
|
||||
AWAITING_CHANGES = "AWC", _("Awaiting Changes")
|
||||
AWAITING_REVIEW = "AWR", _("Awaiting Review")
|
||||
UPLOADED_NEW_VERSION = "UNV", _("Uploaded New Version")
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
|
||||
extension = models.ForeignKey(
|
||||
|
@ -1,6 +1,6 @@
|
||||
from actstream import action
|
||||
from actstream.actions import follow
|
||||
from django.db.models.signals import post_save
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from constants.activity import Flag, Verb
|
||||
@ -30,6 +30,7 @@ def _create_action_from_review_and_follow(
|
||||
ApprovalActivity.ActivityType.AWAITING_CHANGES: Verb.REQUESTED_CHANGES,
|
||||
ApprovalActivity.ActivityType.AWAITING_REVIEW: Verb.REQUESTED_REVIEW,
|
||||
ApprovalActivity.ActivityType.COMMENT: Verb.COMMENTED,
|
||||
ApprovalActivity.ActivityType.UPLOADED_NEW_VERSION: Verb.UPLOADED_NEW_VERSION,
|
||||
}
|
||||
action.send(
|
||||
instance.user,
|
||||
@ -37,3 +38,8 @@ def _create_action_from_review_and_follow(
|
||||
action_object=instance,
|
||||
target=instance.extension,
|
||||
)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=ApprovalActivity)
|
||||
def _log_deletion(sender: object, instance: ApprovalActivity, **kwargs: object) -> None:
|
||||
instance.record_deletion()
|
||||
|
@ -6,27 +6,25 @@
|
||||
{{ extension.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if extension.authors.count %}
|
||||
{% include "extensions/components/authors.html" %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% include "extensions/components/authors.html" %}</td>
|
||||
<td title="{{ extension.date_created }}">{{ extension.date_created|naturaltime_compact }}</td>
|
||||
<td class="d-flex">
|
||||
<a href="{{ extension.get_review_url }}#activity">
|
||||
<span>{{ extension.review_activity.all|length }}</span>
|
||||
<span>{{ stats.count }}</span>
|
||||
</a>
|
||||
|
||||
{% if extension.review_activity.all %}
|
||||
<a href="{{ extension.get_review_url }}#activity-{{ extension.review_activity.all.last.id }}" class="ms-3">
|
||||
<span>{{ extension.review_activity.all.last.date_created|naturaltime_compact }}</span>
|
||||
<a href="{{ extension.get_review_url }}#activity-{{ stats.last_activity.id }}" class="ms-3">
|
||||
<span>{{ stats.last_activity.date_created|naturaltime_compact }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% include "files/components/scan_details_flag.html" with file=extension.latest_version.file %}
|
||||
{% include "files/components/scan_details_flag.html" with suspicious_files=extension.suspicious_files %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ extension.get_review_url }}" class="text-decoration-none">
|
||||
{% include "common/components/status.html" with object=extension class="d-block" %}
|
||||
{% with last_type=stats.last_type_display|default:"Awaiting Review" %}
|
||||
<div class="d-block badge badge-status-{{ last_type|slugify }}">
|
||||
<i class="i-eye"></i>
|
||||
<span>{{ last_type }}</span>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -76,12 +76,14 @@
|
||||
<h3>Previews Pending Approval</h3>
|
||||
<div class="row">
|
||||
{% for preview in pending_previews %}
|
||||
{% with thumbnail_1080p_url=preview.file.thumbnail_1080p_url %}
|
||||
<div class="col-md-3">
|
||||
<a href="{{ preview.file.source.url }}" class="d-block mb-2" title="{{ preview.caption }}" target="_blank">
|
||||
<img class="img-fluid rounded" src="{{ preview.file.source.url }}" alt="{{ preview.caption }}">
|
||||
<img class="img-fluid rounded" src="{{ thumbnail_1080p_url }}" alt="{{ preview.caption }}">
|
||||
</a>
|
||||
{% include "common/components/status.html" with object=preview.file class="d-block" %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@ -99,8 +101,7 @@
|
||||
{% for activity in extension.review_activity.all %}
|
||||
<li id="activity-{{ activity.id }}">
|
||||
|
||||
{# All activities except comments. #}
|
||||
{% if activity.type != 'COM' %}
|
||||
{% 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>
|
||||
|
||||
@ -169,6 +170,7 @@
|
||||
{% if is_maintainer or request.user.is_moderator %}
|
||||
{% include "common/components/field.html" with field=form.type %}
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" id="activity-submit" class="btn btn-primary">
|
||||
<span>{% trans "Comment" %}</span>
|
||||
</button>
|
||||
|
@ -34,12 +34,10 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for extension in object_list %}
|
||||
{% if user.is_moderator %}
|
||||
{% include 'reviewers/components/review_list_item.html' %}
|
||||
{% elif extension.status_slug == 'awaiting-review' %}
|
||||
{% include 'reviewers/components/review_list_item.html' %}
|
||||
{% endif %}
|
||||
{% for stats in object_list %}
|
||||
{% with extension=stats.extension %}
|
||||
{% include 'reviewers/components/review_list_item.html' with extension=extension stats=stats %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -3,13 +3,21 @@ from django.shortcuts import reverse
|
||||
|
||||
from common.tests.factories.extensions import create_version
|
||||
from files.models import File
|
||||
from reviewers.models import ApprovalActivity
|
||||
|
||||
|
||||
class CommentsViewTest(TestCase):
|
||||
fixtures = ['licenses']
|
||||
|
||||
def setUp(self):
|
||||
self.default_version = create_version(file__status=File.STATUSES.AWAITING_REVIEW)
|
||||
version = create_version(file__status=File.STATUSES.AWAITING_REVIEW)
|
||||
self.default_version = version
|
||||
ApprovalActivity(
|
||||
type=ApprovalActivity.ActivityType.COMMENT,
|
||||
user=version.file.user,
|
||||
extension=version.extension,
|
||||
message='test comment',
|
||||
).save()
|
||||
|
||||
# List of extensions under review does not require authentication
|
||||
def test_list_visibility(self):
|
||||
|
@ -4,6 +4,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views.generic.list import ListView
|
||||
from django.views.generic import DetailView, FormView
|
||||
from django.shortcuts import reverse
|
||||
import django.forms
|
||||
|
||||
from files.models import File
|
||||
from extensions.models import Extension
|
||||
@ -12,15 +13,51 @@ from reviewers.models import ApprovalActivity
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
STATUS_CHANGE_TYPES = [
|
||||
ApprovalActivity.ActivityType.APPROVED,
|
||||
ApprovalActivity.ActivityType.AWAITING_CHANGES,
|
||||
ApprovalActivity.ActivityType.AWAITING_REVIEW,
|
||||
]
|
||||
|
||||
|
||||
class ApprovalQueueView(ListView):
|
||||
model = Extension
|
||||
paginate_by = 100
|
||||
|
||||
def get_queryset(self):
|
||||
return Extension.objects.exclude(status=Extension.STATUSES.APPROVED).order_by(
|
||||
'-date_created'
|
||||
qs = (
|
||||
ApprovalActivity.objects.prefetch_related(
|
||||
'extension',
|
||||
'extension__authors',
|
||||
'extension__versions',
|
||||
'extension__versions__file',
|
||||
'extension__versions__file__validation',
|
||||
)
|
||||
.order_by('-date_created')
|
||||
.all()
|
||||
)
|
||||
by_extension = {}
|
||||
result = []
|
||||
for item in qs:
|
||||
extension = item.extension
|
||||
stats = by_extension.get(extension, None)
|
||||
if not stats:
|
||||
# this check guarantees that we add a record only once per extension,
|
||||
# and iterating over qs we get result also ordered by item.date_created
|
||||
stats = {
|
||||
'count': 0,
|
||||
'extension': extension,
|
||||
'last_activity': None,
|
||||
'last_type_display': None,
|
||||
}
|
||||
by_extension[extension] = stats
|
||||
result.append(stats)
|
||||
stats['count'] += 1
|
||||
if not stats.get('last_activity', None):
|
||||
stats['last_activity'] = item
|
||||
if not stats.get('last_type_display', None) and item.type in STATUS_CHANGE_TYPES:
|
||||
stats['last_type_display'] = item.get_type_display
|
||||
return result
|
||||
|
||||
template_name = 'reviewers/extensions_review_list.html'
|
||||
|
||||
@ -35,22 +72,31 @@ class ExtensionsApprovalDetailView(DetailView):
|
||||
ctx['pending_previews'] = self.object.preview_set.exclude(
|
||||
file__status=File.STATUSES.APPROVED
|
||||
)
|
||||
ctx['status_change_types'] = STATUS_CHANGE_TYPES
|
||||
|
||||
if self.request.user.is_authenticated:
|
||||
form = ctx['comment_form'] = CommentForm()
|
||||
# Remove 'Approved' status from dropdown it not moderator
|
||||
if not (self.request.user.is_moderator or self.request.user.is_superuser):
|
||||
filtered_activity_types = [
|
||||
t
|
||||
for t in ApprovalActivity.ActivityType.choices
|
||||
if t[0]
|
||||
not in [
|
||||
# anyone can comment
|
||||
filtered_activity_types = {ApprovalActivity.ActivityType.COMMENT}
|
||||
user = self.request.user
|
||||
if self.object.has_maintainer(user):
|
||||
filtered_activity_types.add(ApprovalActivity.ActivityType.AWAITING_REVIEW)
|
||||
if user.is_moderator or user.is_superuser:
|
||||
filtered_activity_types.update(
|
||||
[
|
||||
ApprovalActivity.ActivityType.APPROVED,
|
||||
ApprovalActivity.ActivityType.AWAITING_CHANGES,
|
||||
]
|
||||
]
|
||||
form.fields['type'].choices = filtered_activity_types
|
||||
form.fields['type'].widget.choices = filtered_activity_types
|
||||
)
|
||||
choices = list(
|
||||
filter(
|
||||
lambda c: c[0] in filtered_activity_types, ApprovalActivity.ActivityType.choices
|
||||
)
|
||||
)
|
||||
form.fields['type'].choices = choices
|
||||
form.fields['type'].widget.choices = choices
|
||||
if len(choices) == 1:
|
||||
form.fields['type'].widget = django.forms.HiddenInput()
|
||||
return ctx
|
||||
|
||||
|
||||
|
@ -7,6 +7,7 @@ from django.contrib.admin.utils import NestedObjects
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models, DEFAULT_DB_ALIAS, transaction
|
||||
from django.templatetags.static import static
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
from common.model_mixins import TrackChangesMixin
|
||||
from files.utils import get_sha256_from_value
|
||||
@ -89,7 +90,7 @@ class User(TrackChangesMixin, AbstractUser):
|
||||
date_deletion_requested,
|
||||
)
|
||||
self.is_active = False
|
||||
self.date_deletion_requested = date_deletion_requested
|
||||
self.date_deletion_requested = parse_datetime(date_deletion_requested)
|
||||
self.save(update_fields=['is_active', 'date_deletion_requested'])
|
||||
|
||||
@transaction.atomic
|
||||
|
@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db.models.signals import m2m_changed, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
from blender_id_oauth_client import signals as bid_signals
|
||||
|
||||
@ -36,7 +37,7 @@ def update_user(
|
||||
Copy 'full_name' from the received 'oauth_info' and attempt to copy avatar from Blender ID.
|
||||
"""
|
||||
instance.full_name = oauth_info.get('full_name') or ''
|
||||
instance.confirmed_email_at = oauth_info.get('confirmed_email_at')
|
||||
instance.confirmed_email_at = parse_datetime(oauth_info.get('confirmed_email_at') or '')
|
||||
instance.save()
|
||||
|
||||
bid.copy_avatar_from_blender_id(user=instance)
|
||||
|
@ -12,7 +12,7 @@
|
||||
<div class="row">
|
||||
<div class="d-none d-md-block col-md-3">
|
||||
<div class="is-sticky pt-4">
|
||||
<nav class="box nav-drawer-nested">
|
||||
<nav class="box nav-drawer-nested p-3">
|
||||
<div class="nav-drawer-body fw-bold">
|
||||
{% include 'users/settings/tabs.html' %}
|
||||
</div>
|
||||
|
@ -3,9 +3,11 @@
|
||||
{% include "common/components/nav_link.html" with name="users:my-profile" title="Profile" classes="i-home py-2" %}
|
||||
|
||||
{% if user.teams.count %}
|
||||
{% include "common/components/nav_link.html" with name="teams:list" title="Teams" classes="i-people py-2" %}
|
||||
{% include "common/components/nav_link.html" with name="teams:list" title="Teams" classes="i-users py-2" %}
|
||||
{% endif %}
|
||||
|
||||
<div class="nav-pills-divider"></div>
|
||||
|
||||
{% include "common/components/nav_link.html" with name="users:my-profile-delete" title="Delete account" classes="i-trash py-2" %}
|
||||
</div>
|
||||
{% endspaceless %}
|
||||
|
@ -11,6 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.utils import IntegrityError
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.http.request import HttpRequest
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
@ -107,7 +108,7 @@ def handle_user_modified(payload: Dict[Any, Any]) -> None:
|
||||
update_fields.add('full_name')
|
||||
|
||||
if 'confirmed_email_at' in payload:
|
||||
user.confirmed_email_at = payload['confirmed_email_at']
|
||||
user.confirmed_email_at = parse_datetime(payload.get('confirmed_email_at') or '')
|
||||
update_fields.add('confirmed_email_at')
|
||||
|
||||
if update_fields:
|
||||
|
Loading…
Reference in New Issue
Block a user