Implement Web Assets' theme system and selection, and add 'light' theme #118

Merged
Márton Lente merged 97 commits from martonlente/extensions-website:ui/theme-light into main 2024-05-08 14:20:07 +02:00
93 changed files with 1490 additions and 564 deletions
Showing only changes of commit d0e5dff9b0 - Show all commits

View File

@ -1,7 +1,7 @@
import logging import logging
from actstream import action 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 django.dispatch import receiver
from abuse.models import AbuseReport from abuse.models import AbuseReport
@ -45,3 +45,8 @@ def _create_action_from_report(
target=instance.extension, target=instance.extension,
action_object=instance, action_object=instance,
) )
@receiver(pre_delete, sender=AbuseReport)
def _log_deletion(sender: object, instance: AbuseReport, **kwargs: object) -> None:
instance.record_deletion()

View File

@ -88,7 +88,7 @@
<div class="dl-row"> <div class="dl-row">
<div class="dl-col"> <div class="dl-col">
<dt>Status</dt> <dt>Status</dt>
<dd>{% include "common/components/status.html" with class="d-block" %}</dd> <dd>{% include "common/components/status.html" %}</dd>
</div> </div>
</div> </div>
<div class="dl-row"> <div class="dl-row">

@ -1 +1 @@
Subproject commit 69ac038afd6b9312a9c715b44b7814b0cbdcf572 Subproject commit af61a962e1a30898279b4efdbb07a2dcb230a257

View File

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

View File

@ -120,6 +120,7 @@ WSGI_APPLICATION = 'blender_extensions.wsgi.application'
DATABASES = { DATABASES = {
'default': dj_database_url.config(default='sqlite:///{}'.format(BASE_DIR / 'db.sqlite3')), 'default': dj_database_url.config(default='sqlite:///{}'.format(BASE_DIR / 'db.sqlite3')),
} }
DATABASES['default']['CONN_MAX_AGE'] = None
# Password validation # Password validation
# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators # 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 = { ACTSTREAM_SETTINGS = {
'MANAGER': 'actstream.managers.ActionManager', '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)

View File

@ -1,8 +1,9 @@
from typing import Set, Tuple, Mapping, Any from typing import Set, Tuple, Mapping, Any
import copy
import logging import logging
from django.contrib.admin.models import DELETION
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core import serializers
from django.db import models from django.db import models
from django.shortcuts import reverse from django.shortcuts import reverse
from django.utils import timezone 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): class CreatedModifiedMixin(models.Model):
"""Add standard date fields to a model.""" """Add standard date fields to a model."""
@ -45,7 +53,31 @@ class CreatedModifiedMixin(models.Model):
return f'{path}?{query}' 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 changes of Django models.
Tracks which fields have changed in the save() function, so that 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') update_fields = kwargs.get('update_fields')
was_modified = self._was_modified(db_instance, update_fields=update_fields) was_modified = self._was_modified(db_instance, update_fields=update_fields)
old_instance_data = { old_instance_data = _get_object_state(db_instance, fields=self.track_changes_to_fields)
attr: copy.deepcopy(getattr(db_instance, attr)) for attr in self.track_changes_to_fields
}
return was_modified, old_instance_data return was_modified, old_instance_data
def record_status_change(self, was_changed, old_state, **kwargs): 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: if not was_changed or not self.pk:
return return
new_state = _get_object_state(self, fields=self.track_changes_to_fields)
changed_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 = [ message = [
{ {

View File

@ -16,7 +16,7 @@
&:last-child &:last-child
/* Remove bottom half of the vertical line for last item. */ /* Remove bottom half of the vertical line for last item. */
.comment-card:before .comment-card:before
height: calc(50% - 1.6rem) height: calc(50% - var(--spacer))
.activity-status-change:before .activity-status-change:before
height: calc(50% + var(--border-width)) height: calc(50% + var(--border-width))
@ -50,9 +50,12 @@
left: var(--border-width) left: var(--border-width)
position: absolute position: absolute
top: 50% top: 50%
width: 3.2rem width: calc(var(--spacer) * 2)
z-index: -1 z-index: -1
.activity-icon
top: 1.2rem
.profile-avatar .profile-avatar
border: var(--border-width) solid var(--border-color) border: var(--border-width) solid var(--border-color)
background-color: var(--border-color) background-color: var(--border-color)
@ -60,8 +63,9 @@
.comment-card .comment-card
&:after &:after
top: 3.2rem top: 3.2rem
.activity-icon .activity-icon
top: 2.0rem top: 2.2rem
.activity-status-change .activity-status-change
color: var(--color-text-tertiary) color: var(--color-text-tertiary)
@ -83,7 +87,7 @@
&.activity-status-approved &.activity-status-approved
.activity-icon .activity-icon
border-color: var(--color-success) 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) color: var(--color-success-text)
.activity-icon .activity-icon
@ -94,11 +98,11 @@
color: var(--color-text-tertiary) color: var(--color-text-tertiary)
display: flex display: flex
font-size: var(--fs-sm) font-size: var(--fs-sm)
height: 1.8rem height: var(--spacer-4)
justify-content: center justify-content: center
left: -.66rem left: -.66rem
position: absolute position: absolute
width: 1.8rem width: var(--spacer-4)
/* Comment form */ /* Comment form */
.comment-form .comment-form
@ -112,9 +116,6 @@
select select
width: auto width: auto
button[type="submit"]
min-width: 50%
textarea textarea
height: calc(var(--spacer) * 8) height: calc(var(--spacer) * 8)
max-height: 0 max-height: 0

View File

@ -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 a.badge-tag
--badge-color: var(--color-text-secondary) --badge-color: var(--color-text-secondary)
--badge-bg: var(--color-text-tertiary) --badge-bg: var(--color-text-tertiary)
@ -24,10 +31,3 @@ a.badge-tag
&-disabled-by-staff, &-disabled-by-staff,
&-disabled-by-author &-disabled-by-author
@extend .badge-secondary @extend .badge-secondary
.card-badge
+border-radius(lg)
border-bottom-left-radius: 0
border-bottom-right-radius: 0
display: flex
+padding(2, y)

View File

@ -1,3 +1,29 @@
.card .card
@extend .box @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)

View File

@ -15,12 +15,12 @@
border-radius: .4rem border-radius: .4rem
content: '' content: ''
display: block display: block
height: 1.6rem height: var(--spacer)
left: -.33rem left: -.33rem
position: absolute position: absolute
rotate: 45deg rotate: 45deg
top: 1.6rem top: var(--spacer)
width: 1.6rem width: var(--spacer)
z-index: -1 z-index: -1
p:last-child p:last-child
@ -35,9 +35,12 @@
align-items: center align-items: center
display: flex display: flex
+list-unstyled +list-unstyled
gap: 1.6rem gap: var(--spacer)
margin: 0 margin: 0
li
line-height: var(--lh-sm)
aside aside
+margin(2, top) +margin(2, top)

View File

@ -1,3 +1,6 @@
\:root
--lh-sm: 2.4rem
.hero.extension-detail .hero.extension-detail
--hero-max-height: 0 --hero-max-height: 0
--hero-min-height: 24.0rem --hero-min-height: 24.0rem
@ -166,114 +169,6 @@
color: var(--color-text-secondary) color: var(--color-text-secondary)
+fw-normal +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 .ext-list-details
@extend .list-inline @extend .list-inline
@ -286,12 +181,6 @@
&+.ext-list-details &+.ext-list-details
+margin(2, top) +margin(2, top)
.ext-card-tags
display: flex
flex-wrap: wrap
gap: 3.2rem
justify-content: flex-start
/* Show only on row list view.*/ /* Show only on row list view.*/
.ext-blender-version .ext-blender-version
display: none display: none
@ -299,19 +188,6 @@
.ext-edit-field-row .ext-edit-field-row
+margin(2, top) +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 .previews-upload
+box-card +box-card
+padding(3) +padding(3)
@ -323,9 +199,9 @@
gap: .8rem gap: .8rem
.previews-list-item .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: background-color:
border-radius: var(--border-radius-lg) border-radius: var(--border-radius-lg)
border: var(--border-width) solid var(--border-color) border: var(--border-width) solid var(--border-color)
@ -334,6 +210,7 @@
.previews-list-item-thumbnail .previews-list-item-thumbnail
margin: 0 margin: 0
+margin(2, y)
width: var(--preview-thumbnail-max-size) width: var(--preview-thumbnail-max-size)
.previews-list-item-thumbnail-img .previews-list-item-thumbnail-img
@ -341,6 +218,7 @@
background-position: center background-position: center
background-size: cover background-size: cover
border-radius: var(--border-radius) border-radius: var(--border-radius)
height: 12.4rem
+make-aspect-ratio('16x9') +make-aspect-ratio('16x9')
.details .details
@ -349,6 +227,7 @@
label label
font-size: var(--fs-sm) font-size: var(--fs-sm)
line-height: var(--lh-sm)
ul ul
+list-unstyled +list-unstyled
@ -389,7 +268,10 @@
+fw-normal +fw-normal
+margin(3, left) +margin(3, left)
details[open] details
padding: 0
&[open]
.show-on-collapse .show-on-collapse
display: none display: none
@ -460,3 +342,36 @@
.ext-review-list-activity .ext-review-list-activity
display: flex display: flex
+padding(0, x) +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)

View File

@ -12,7 +12,7 @@
.galleria-items .galleria-items
display: flex display: flex
gap: .8rem gap: var(--spacer-2)
overflow-x: auto overflow-x: auto
+padding(2, top) +padding(2, top)
scroll-behavior: smooth scroll-behavior: smooth
@ -43,7 +43,7 @@
.galleria-item-type-video .galleria-item-type-video
&::after &::after
font-size: 3.2rem font-size: calc(var(--spacer) * 2)
.galleria-item .galleria-item
+border-radius +border-radius
@ -69,7 +69,7 @@
transform: translate(-50%, -50%) transform: translate(-50%, -50%)
width: initial width: initial
height: initial height: initial
gap: 1.6rem gap: var(--spacer)
&.is-active &.is-active
@ -174,7 +174,7 @@
.indicator .indicator
background-color: rgba(black, .5) background-color: rgba(black, .5)
bottom: 1.6rem bottom: var(--spacer)
color: white color: white
+fw-bold +fw-bold
min-width: 3ch min-width: 3ch
@ -187,7 +187,7 @@
backdrop-filter: blur(2.8rem) backdrop-filter: blur(2.8rem)
background-color: rgba(black, .5) background-color: rgba(black, .5)
border-radius: var(--border-radius) border-radius: var(--border-radius)
bottom: 1.6rem bottom: var(--spacer)
color: white color: white
+font-weight(500) +font-weight(500)
line-height: 1.5 line-height: 1.5

View File

@ -7,7 +7,8 @@
h4, h4,
h5, h5,
h6 h6
+margin(4, top) +margin(3, bottom)
+padding(3, top)
img img
+border-radius(lg) +border-radius(lg)
@ -19,9 +20,4 @@
& > & >
h1:first-of-type, h1:first-of-type,
h2:first-of-type, +padding(0, top)
h3:first-of-type,
h4:first-of-type,
h5:first-of-type,
h6:first-of-type
+margin(0, top)

View File

@ -17,6 +17,7 @@
&:hover &:hover
cursor: move !important cursor: move !important
// TODO: move utilities 'fade' and 'show' to web-assets
.fade .fade
opacity: 0 opacity: 0
// TODO: make variable 'transition-speed-slow' work // TODO: make variable 'transition-speed-slow' work

View File

@ -2,7 +2,7 @@
$font-path: '/static/fonts' $font-path: '/static/fonts'
/* Import variables.*/ /* 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-max-widths: (sm: 760px, md: 1020px, lg: 1070px, xl: 1320px, xxl: 1600px)
$container-width: map-get($container-max-widths, 'xl') $container-width: map-get($container-max-widths, 'xl')
@ -41,7 +41,7 @@ $container-width: map-get($container-max-widths, 'xl')
+media-xs +media-xs
width: 60px 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 .nav-global-links-right
gap: 0 var(--spacer-2) gap: 0 var(--spacer-2)
.navbar-search .navbar-search
@ -52,8 +52,8 @@ $container-width: map-get($container-max-widths, 'xl')
.profile-avatar .profile-avatar
border-radius: 50% border-radius: 50%
height: var(--spacer-4)
pointer-events: none pointer-events: none
width: 2.6rem
.search-highlight .search-highlight
background-color: var(--btn-color-bg) background-color: var(--btn-color-bg)
@ -76,7 +76,7 @@ $container-width: map-get($container-max-widths, 'xl')
font-size: var(--fs-xs) font-size: var(--fs-xs)
justify-content: center justify-content: center
padding-block: .1rem padding-block: .1rem
padding-inline: .4rem padding-inline: var(--spacer-1)
position: absolute position: absolute
top: calc(var(--spacer-2) * -1) top: calc(var(--spacer-2) * -1)
transition: background-color var(--transition-speed), box-shadow 500ms, color var(--transition-speed) transition: background-color var(--transition-speed), box-shadow 500ms, color var(--transition-speed)

View File

@ -1,3 +1,4 @@
{% load common %}
{% load pipeline %} {% load pipeline %}
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
@ -137,6 +138,9 @@
</li> </li>
{% if user.is_authenticated %} {% 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"> <li class="nav-item dropdown">
<button id="navbarDropdown" aria-expanded="false" aria-haspopup="true" data-toggle-menu-id="nav-account-dropdown" role="button" class="nav-link dropdown-toggle js-dropdown-toggle"> <button id="navbarDropdown" aria-expanded="false" aria-haspopup="true" data-toggle-menu-id="nav-account-dropdown" role="button" class="nav-link dropdown-toggle js-dropdown-toggle">
<i class="i-user"></i> <i class="i-user"></i>

View File

@ -1,3 +1,4 @@
{# TODO: check if template is used and needed #}
<li class="nav-item {% include "common/components/_nav_item_active" %}"> <li class="nav-item {% include "common/components/_nav_item_active" %}">
{% include "common/components/nav_link.html" %} {% include "common/components/nav_link.html" %}
</li> </li>

View File

@ -1,5 +1,5 @@
{% if name %} {% 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 %} {% 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 %} {% endif %}

View File

@ -14,7 +14,7 @@
{% endblock hero %} {% endblock hero %}
{% block content %} {% block content %}
<div class="box p-5 my-3 is-flatpage"> <div class="box my-3 is-flatpage">
{{ flatpage.content|markdown }} {{ flatpage.content|markdown }}
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -13,6 +13,7 @@ from common.markdown import (
render_as_text as render_markdown_as_text, render_as_text as render_markdown_as_text,
) )
from extensions.models import Tag from extensions.models import Tag
from notifications.models import Notification
import utils 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.""" """Replaces occurrences of old_char with new_char in the given value."""
old_char, new_char = old_char_new_char.split(',') old_char, new_char = old_char_new_char.split(',')
return value.replace(old_char, new_char) 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()

View File

@ -6,7 +6,7 @@ from mdgen import MarkdownPostProvider
import factory import factory
import factory.fuzzy import factory.fuzzy
from extensions.models import Extension, Version, Tag from extensions.models import Extension, Version, Tag, Preview
from ratings.models import Rating from ratings.models import Rating
fake_markdown = Faker() fake_markdown = Faker()
@ -35,7 +35,7 @@ class ExtensionFactory(DjangoModelFactory):
if extracted: if extracted:
for _ in extracted: for _ in extracted:
_.extension_preview.create(caption='Media Caption', extension=self) Preview.objects.create(file=_, caption='Media Caption', extension=self)
@factory.post_generation @factory.post_generation
def process_extension_id(self, created, extracted, **kwargs): def process_extension_id(self, created, extracted, **kwargs):

View File

@ -10,6 +10,7 @@ class Verb:
REPORTED_RATING = 'reported rating' REPORTED_RATING = 'reported rating'
REQUESTED_CHANGES = 'requested changes' REQUESTED_CHANGES = 'requested changes'
REQUESTED_REVIEW = 'requested review' REQUESTED_REVIEW = 'requested review'
UPLOADED_NEW_VERSION = 'uploaded new version'
class Flag: class Flag:

View File

@ -100,3 +100,10 @@ ABUSE_TYPE = Choices(
('ABUSE_USER', ABUSE_TYPE_USER, "User"), ('ABUSE_USER', ABUSE_TYPE_USER, "User"),
('ABUSE_RATING', ABUSE_TYPE_RATING, "Rating"), ('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

View File

@ -1,3 +1,4 @@
from django.core.exceptions import ValidationError
from semantic_version.django_fields import VersionField as SemanticVersionField from semantic_version.django_fields import VersionField as SemanticVersionField
from semantic_version import Version from semantic_version import Version
import json import json
@ -11,7 +12,10 @@ class VersionStringField(SemanticVersionField):
return value return value
if value is None: if value is None:
return value return value
try:
return str(Version(value)) return str(Version(value))
except Exception as e:
raise ValidationError(e)
def from_db_value(self, value, expression, connection): def from_db_value(self, value, expression, connection):
return self.to_python(value) return self.to_python(value)
@ -22,7 +26,7 @@ class VersionStringField(SemanticVersionField):
return str(value) return str(value)
def value_to_string(self, obj): 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) return self.get_prep_value(value)
def from_json(self, json_str): def from_json(self, json_str):

View File

@ -66,24 +66,14 @@ class AddPreviewFileForm(forms.ModelForm):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Save Preview from the cleaned form data.""" """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 # Fill in missing fields from request and the source file
self.instance.user = self.request.user self.instance.user = self.request.user
instance = super().save(*args, **kwargs) instance = super().save(*args, **kwargs)
# Create extension preview and save caption to it # Create extension preview and save caption to it
instance.extension_preview.create( extensions.models.Preview.objects.create(
file=instance,
caption=self.cleaned_data['caption'], caption=self.cleaned_data['caption'],
extension=self.extension, extension=self.extension,
) )

View 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'),
),
]

View File

@ -10,7 +10,7 @@ from django.db.models import F, Q, Count
from django.urls import reverse from django.urls import reverse
from common.fields import FilterableManyToManyField from common.fields import FilterableManyToManyField
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin from common.model_mixins import CreatedModifiedMixin, RecordDeletionMixin, TrackChangesMixin
from constants.base import ( from constants.base import (
AUTHOR_ROLE_CHOICES, AUTHOR_ROLE_CHOICES,
AUTHOR_ROLE_DEV, AUTHOR_ROLE_DEV,
@ -33,7 +33,13 @@ log = logging.getLogger(__name__)
class RatingMixin: class RatingMixin:
@property @property
def text_ratings_count(self) -> int: 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 @property
def total_ratings_count(self) -> int: def total_ratings_count(self) -> int:
@ -274,10 +280,9 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
def get_previews(self): def get_previews(self):
"""Get preview files, sorted by Preview.position. """Get preview files, sorted by Preview.position.
TODO: Might be better to query Previews directly instead of going Avoid triggering additional querysets, rely on prefetch_related in the view.
for the reverse relationship.
""" """
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 @property
def valid_file_statuses(self) -> List[int]: def valid_file_statuses(self) -> List[int]:
@ -288,14 +293,13 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
@property @property
def latest_version(self): def latest_version(self):
"""Retrieve the latest version.""" """Retrieve the latest version."""
return ( versions = [
self.versions.filter( v for v in self.versions.all() if v.file and v.file.status in self.valid_file_statuses
file__status__in=self.valid_file_statuses, ]
file__isnull=False, if not versions:
) return None
.order_by('date_created') versions = sorted(versions, key=lambda v: v.date_created, reverse=True)
.last() return versions[0]
)
@property @property
def current_version(self): 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.""" """Return True if given user is listed as a maintainer."""
if user is None or user.is_anonymous: if user is None or user.is_anonymous:
return False return False
return self.authors.filter(maintainer__user_id=user.pk).exists() return user in self.authors.all()
def can_rate(self, user) -> bool: def can_rate(self, user) -> bool:
"""Return True if given user can rate this extension. """Return True if given user can rate this extension.
@ -364,6 +368,10 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
).exists() ).exists()
) )
def suspicious_files(self):
versions = self.versions.all()
return [v.file for v in versions if not v.file.validation.is_ok]
@classmethod @classmethod
def get_lookup_field(cls, identifier): def get_lookup_field(cls, identifier):
lookup_field = 'pk' 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) extension = models.ForeignKey(Extension, on_delete=models.CASCADE)
file = models.ForeignKey( file = models.OneToOneField('files.File', on_delete=models.CASCADE)
'files.File', related_name='extension_preview', on_delete=models.CASCADE
)
caption = models.CharField(max_length=255, default='', null=False, blank=True) caption = models.CharField(max_length=255, default='', null=False, blank=True)
position = models.IntegerField(default=0) position = models.IntegerField(default=0)

View File

@ -8,6 +8,7 @@ from django.db.models.signals import m2m_changed, pre_save, post_save, pre_delet
from django.dispatch import receiver from django.dispatch import receiver
from constants.activity import Flag from constants.activity import Flag
from reviewers.models import ApprovalActivity
import extensions.models import extensions.models
import files.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.Extension)
@receiver(pre_delete, sender=extensions.models.Preview) @receiver(pre_delete, sender=extensions.models.Preview)
@receiver(pre_delete, sender=extensions.models.Version) @receiver(pre_delete, sender=extensions.models.Version)
def _log_extension_delete(sender: object, instance: object, **kwargs: object) -> None: def _log_deletion(
cannot_be_deleted_reasons = instance.cannot_be_deleted_reasons sender: object,
if len(cannot_be_deleted_reasons) > 0: instance: Union[
# This shouldn't happen: prior validation steps should have taken care of this. extensions.models.Extension, extensions.models.Version, extensions.models.Preview
# raise ValidationError({'__all__': cannot_be_deleted_reasons}) ],
args = {'sender': sender, 'pk': instance.pk, 'reasons': cannot_be_deleted_reasons} **kwargs: object,
logger.error("%(sender)s pk=%(pk)s is being deleted but it %(reasons)s", args) ) -> None:
instance.record_deletion()
logger.info('Deleting %s pk=%s "%s"', sender, instance.pk, str(instance))
@receiver(post_delete, sender=extensions.models.Preview) @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} 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) 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'}) 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()

View File

@ -31,7 +31,7 @@ function appendImageUploadForm() {
<div class="align-items-center d-flex previews-list-item-thumbnail ps-3"> <div class="align-items-center d-flex previews-list-item-thumbnail ps-3">
<div class="js-input-img-thumbnail previews-list-item-thumbnail-img" title="Preview"> <div class="js-input-img-thumbnail previews-list-item-thumbnail-img" title="Preview">
<div class="align-items-center d-flex js-input-img-thumbnail-icon justify-content-center"> <div class="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> </div>
</div> </div>

View File

@ -3,6 +3,18 @@
<a <a
href="https://www.blender.org/download/releases/{{ version.blender_version_min|version_without_patch|replace:".,-" }}/" href="https://www.blender.org/download/releases/{{ version.blender_version_min|version_without_patch|replace:".,-" }}/"
title="{{ version.blender_version_min }}">Blender {{ version.blender_version_min|version_without_patch }}</a> title="{{ version.blender_version_min }}">Blender {{ version.blender_version_min|version_without_patch }}</a>
{% if is_editable %}
&mdash;
<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 %}
{% if version.blender_version_max|version_without_patch != version.blender_version_min|version_without_patch %} {% if version.blender_version_max|version_without_patch != version.blender_version_min|version_without_patch %}
&mdash; &mdash;
@ -13,3 +25,4 @@
{% else %} {% else %}
{% trans 'and newer' %} {% trans 'and newer' %}
{% endif %} {% endif %}
{% endif %}

View File

@ -1,25 +1,23 @@
{% load common filters %} {% load common filters %}
{% with latest=extension.latest_version %} {% with latest=extension.latest_version thumbnail_360p_url=extension.previews.listed.first.thumbnail_360p_url %}
<div class="cards-item">
<div class="ext-card {% if blur %}is-bg-blur{% endif %}"> <div class="cards-item-content">
{% if blur %} <a href="{{ extension.get_absolute_url }}">
<div class="ext-card-thumbnail-blur" style="background-image: url({{ extension.previews.listed.first.source.url }});"></div> <div class="cards-item-thumbnail">
{% endif %} <img alt="{{ extension.name }}" src="{{ thumbnail_360p_url }}" title="{{ extension.name }}">
</div>
<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>
</a> </a>
<h3 class="cards-item-title">
<div class="ext-card-body">
<h3 class="ext-card-title">
<a href="{{ extension.get_absolute_url }}">{{ extension.name }}</a> <a href="{{ extension.get_absolute_url }}">{{ extension.name }}</a>
</h3> </h3>
<div class="cards-item-excerpt">
<p> <p>
{{ latest.tagline }} {{ latest.tagline }}
</p> </p>
</div>
<ul class="ext-list-details"> <div class="cards-item-extra">
<li class="ext-card-author"> <ul>
<li>
{% if extension.team %} {% if extension.team %}
{% with team=extension.team %} {% with team=extension.team %}
<a href="{{ team.get_absolute_url }}" title="{{ team.name }}">{{ team.name }}</a> <a href="{{ team.get_absolute_url }}" title="{{ team.name }}">{{ team.name }}</a>
@ -30,7 +28,7 @@
</li> </li>
</ul> </ul>
<ul class="ext-list-details mt-1"> <ul class="cards-item-extra-rating-stars">
{% if extension.average_score %} {% if extension.average_score %}
<li> <li>
<a class="align-items-center d-flex" href="{{ extension.get_ratings_url }}"> <a class="align-items-center d-flex" href="{{ extension.get_ratings_url }}">
@ -54,15 +52,18 @@
</ul> </ul>
{% if latest.tags.count %} {% if latest.tags.count %}
<ul class="ext-list-details ext-card-tags"> <ul>
<li class="ext-card-tags"> {% for tag in latest.tags.all %}
{% include "extensions/components/tags.html" with small=True version=latest %} <li>
{% include "extensions/components/badge_tag.html" with small=True version=latest %}
</li> </li>
{% endfor %}
</ul> </ul>
{% endif %} {% endif %}
</div>
</div>
{# Author/admin tools can be added here in an extending template #} {# Author/admin tools can be added here in an extending template #}
{% block admin %}{% endblock admin %} {% block admin %}{% endblock admin %}
</div> </div>
</div>
{% endwith %} {% endwith %}

View File

@ -4,7 +4,7 @@
<section class="ext-detail-info"> <section class="ext-detail-info">
<div class="card p-0"> <div class="card p-0">
{% if is_initial %} {% if is_initial %}
<div class="badge badge-info card-badge"> <div class="badge badge-card badge-info">
Information retrieved from manifest Information retrieved from manifest
</div> </div>
{% else %} {% else %}
@ -54,7 +54,7 @@
<div class="dl-row"> <div class="dl-row">
<div class="dl-col"> <div class="dl-col">
<dt>{% trans 'Tagline' %}</dt> <dt>{% trans 'Tagline' %}</dt>
<dd title="{{ latest.tagline }}">{{ latest.tagline }}</dd> <dd title="{{ version.tagline }}">{{ version.tagline }}</dd>
</div> </div>
</div> </div>
@ -63,20 +63,20 @@
<dt>{% trans 'Version' %}</dt> <dt>{% trans 'Version' %}</dt>
<dd> <dd>
<a href="{{ extension.get_versions_url }}"> <a href="{{ extension.get_versions_url }}">
{{ latest.version }} {{ version.version }}
</a> </a>
</dd> </dd>
</div> </div>
<div class="dl-col"> <div class="dl-col">
<dt>{% trans 'Size' %}</dt> <dt>{% trans 'Size' %}</dt>
<dd>{{ latest.file.size_bytes|filesizeformat }}</dd> <dd>{{ version.file.size_bytes|filesizeformat }}</dd>
</div> </div>
</div> </div>
<div class="dl-row"> <div class="dl-row">
<div class="dl-col"> <div class="dl-col">
<dt>{% trans 'Compatibility' %}</dt> <dt>{% trans 'Compatibility' %}</dt>
<dd>{% include "extensions/components/blender_version.html" with version=latest %}</dd> <dd>{% include "extensions/components/blender_version.html" with version=version is_editable=is_editable form=form %}</dd>
</div> </div>
</div> </div>
@ -91,8 +91,8 @@
<div class="dl-row"> <div class="dl-row">
<div class="dl-col"> <div class="dl-col">
<dt>License{{ latest.licenses.count|pluralize }}</dt> <dt>License{{ version.licenses.count|pluralize }}</dt>
{% for license in latest.licenses.all %} {% for license in version.licenses.all %}
<dd> <dd>
{% include "common/components/external_link.html" with url=license.url title=license %} {% include "common/components/external_link.html" with url=license.url title=license %}
</dd> </dd>
@ -102,14 +102,14 @@
<div class="dl-row"> <div class="dl-row">
<div class="dl-col"> <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> </div>
<div class="dl-row"> <div class="dl-row">
<dd> <dd>
{% if latest.tags.count %} {% if version.tags.count %}
{% include "extensions/components/tags.html" with small=True version=latest %} {% include "extensions/components/tags.html" with small=True version=version %}
{% else %} {% else %}
No tags. No tags.
{% endif %} {% endif %}

View File

@ -3,19 +3,17 @@
{% if previews %} {% if previews %}
<div class="galleria-items{% if previews.count > 5 %} is-many{% endif %}{% if previews.count == 1 %} is-single{% endif %}" id="galleria-items"> <div class="galleria-items{% if previews.count > 5 %} is-many{% endif %}{% if previews.count == 1 %} is-single{% endif %}" id="galleria-items">
{% for preview in previews %} {% for preview in previews %}
{% with thumbnail_1080p_url=preview.thumbnail_1080p_url %}
<a <a
class="galleria-item js-galleria-item-preview galleria-item-type-{{ preview.content_type|slugify|slice:5 }}{% if forloop.first %} is-active{% endif %}" class="galleria-item js-galleria-item-preview galleria-item-type-{{ 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 %} {% if 'video' in preview.content_type %}data-galleria-video-url="{{ preview.source.url }}"{% endif %}
data-galleria-content-type="{{ preview.content_type }}" data-galleria-content-type="{{ preview.content_type }}"
data-galleria-index="{{ forloop.counter }}"> data-galleria-index="{{ forloop.counter }}">
{% if 'video' in preview.content_type and preview.thumbnail %} <img src="{{ thumbnail_1080p_url }}" alt="{{ preview.preview.caption }}">
<img src="{{ preview.thumbnail.url }}" alt="{{ preview.extension_preview.first.caption }}">
{% else %}
<img src="{{ preview.source.url }}" alt="{{ preview.extension_preview.first.caption }}">
{% endif %}
</a> </a>
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}

View File

@ -5,11 +5,7 @@
{% block page_title %}{{ extension.name }}{% endblock page_title %} {% block page_title %}{{ extension.name }}{% endblock page_title %}
{% block content %} {% block content %}
{% if extension.latest_version %} {% include "files/components/scan_details.html" with suspicious_files=extension.suspicious_files %}
{% with latest=extension.latest_version %}
{% include "files/components/scan_details.html" with file=latest.file %}
{% endwith %}
{% endif %}
{% has_maintainer extension as is_maintainer %} {% has_maintainer extension as is_maintainer %}
{% with latest=extension.latest_version %} {% with latest=extension.latest_version %}
@ -23,11 +19,13 @@
{# Description #} {# Description #}
{% block extension_description %} {% block extension_description %}
{% if extension.description %}
<section id="about" class="mt-3"> <section id="about" class="mt-3">
<div class="box ext-detail-description"> <div class="box ext-detail-description">
{{ extension.description|markdown }} {{ extension.description|markdown }}
</div> </div>
</section> </section>
{% endif %}
{% endblock extension_description %} {% endblock extension_description %}
{# What's New #} {# What's New #}
@ -55,7 +53,7 @@
{% if extension.versions.listed|length > 1 %} {% if extension.versions.listed|length > 1 %}
<p> <p>
<a href="{{ extension.get_versions_url }}" class="d-block mt-3"> <a href="{{ extension.get_versions_url }}" class="d-block mt-3">
See all changelogs See all versions
</a> </a>
</p> </p>
{% endif %} {% endif %}

View File

@ -78,7 +78,7 @@
<div class="is-sticky py-3"> <div class="is-sticky py-3">
<div class="row"> <div class="row">
<div class="col"> <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"> <section class="card p-3 mt-3">
<div class="btn-col"> <div class="btn-col">

View File

@ -1,5 +1,5 @@
{% extends "common/base.html" %} {% extends "common/base.html" %}
{% load i18n %} {% load cache i18n %}
{% block page_title %}Extensions{% endblock page_title %} {% block page_title %}Extensions{% endblock page_title %}
@ -32,6 +32,7 @@
{% endblock hero %} {% endblock hero %}
{% block content %} {% block content %}
{% cache 60 home %}
<section class="mt-3"> <section class="mt-3">
<div class="d-flex"> <div class="d-flex">
<h2> <h2>
@ -40,7 +41,7 @@
<a href="{% url 'extensions:by-type' type_slug='add-ons' %}" class="ms-auto">See all</a> <a href="{% url 'extensions:by-type' type_slug='add-ons' %}" class="ms-auto">See all</a>
</div> </div>
<p>Extend Blender capabilities with these add-ons by the community.</p> <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 %} {% for extension in addons %}
{% include "extensions/components/card.html" %} {% include "extensions/components/card.html" %}
{% endfor %} {% endfor %}
@ -57,7 +58,7 @@
</a> </a>
</div> </div>
<p>Blender themes to your liking. Dark, light, flat, colorful, and everything in between.</p> <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 %} {% for extension in themes %}
{% include "extensions/components/card.html" %} {% include "extensions/components/card.html" %}
{% endfor %} {% endfor %}
@ -71,4 +72,5 @@
</a> </a>
</div> </div>
</section> </section>
{% endcache %}
{% endblock content %} {% endblock content %}

View File

@ -12,13 +12,11 @@
<h3>Tags</h3> <h3>Tags</h3>
<ul> <ul>
{% for list_tag in tags %} {% for list_tag in tags %}
{% if list_tag.versions.all|length %}
<li class="{% if tag == list_tag %}is-active{% endif %}"> <li class="{% if tag == list_tag %}is-active{% endif %}">
<a href="{% url "extensions:by-tag" tag_slug=list_tag.slug %}" title="{{ list_tag.name }}"> <a href="{% url "extensions:by-tag" tag_slug=list_tag.slug %}" title="{{ list_tag.name }}">
{{ list_tag.name }} {{ list_tag.name }}
</a> </a>
</li> </li>
{% endif %}
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
@ -49,7 +47,7 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% if object_list %} {% if object_list %}
<div class="cards card-layout-horizontal cards-3"> <div class="cards cards-3">
{% for extension in object_list %} {% for extension in object_list %}
{% include "extensions/components/card.html" with show_type=False %} {% include "extensions/components/card.html" with show_type=False %}
{% endfor %} {% endfor %}

View File

@ -8,10 +8,10 @@
{% with inlineform=newform|add_form_classes %} {% with inlineform=newform|add_form_classes %}
<div class="ext-edit-field-row js-ext-edit-field-row"> <div class="ext-edit-field-row js-ext-edit-field-row">
<div class="previews-list-item"> <div class="previews-list-item">
<div class="align-items-center d-flex previews-list-item-thumbnail ps-3"> <div class="d-flex previews-list-item-thumbnail ps-3">
<div class="js-input-img-thumbnail previews-list-item-thumbnail-img" title="Preview"> <div class="js-input-img-thumbnail previews-list-item-thumbnail-img" title="Preview">
<div class="align-items-center d-flex js-input-img-thumbnail-icon justify-content-center"> <div class="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> </div>
</div> </div>

View File

@ -2,16 +2,17 @@
{% load i18n %} {% load i18n %}
{% block admin %} {% block admin %}
<div class="ext-card-admin p-3"> <div class="bg-secondary cards-item-extra pt-3">
<div> <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"> <a href="{{ extension.get_manage_url }}" class="btn btn-sm">
<i class="i-edit"></i> <i class="i-edit"></i>
<span>{% trans 'Edit' %}</span> <span>{% trans 'Edit' %}</span>
</a> </a>
<div class="align-items-center d-flex">
{% include "common/components/status.html" with object=extension class="badge-tag" %}
</div> </div>
</li>
<div> </ul>
{% include "common/components/status.html" with object=extension %}
</div>
</div> </div>
{% endblock admin %} {% endblock admin %}

View File

@ -4,7 +4,7 @@
{% block page_title %}{{ extension.name }}{% endblock page_title %} {% block page_title %}{{ extension.name }}{% endblock page_title %}
{% block content %} {% 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="row">
<div class="col-md-8"> <div class="col-md-8">
<h2>{{ extension.get_type_display }} {% trans 'details' %}</h2> <h2>{{ extension.get_type_display }} {% trans 'details' %}</h2>
@ -93,7 +93,7 @@
<div class="is-sticky py-3"> <div class="is-sticky py-3">
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <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"> <section class="card p-3 mt-3">
<div class="btn-col"> <div class="btn-col">

View File

@ -44,8 +44,7 @@
<div class="is-sticky"> <div class="is-sticky">
<div class="row"> <div class="row">
<div class="col"> <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"> <section class="card p-3 mt-3">
<div class="btn-col"> <div class="btn-col">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">

View File

@ -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.extensions import create_approved_version, create_version
from common.tests.factories.files import FileFactory from common.tests.factories.files import FileFactory
from common.tests.factories.users import UserFactory from common.tests.factories.users import UserFactory, create_moderator
import extensions.models import extensions.models
import files.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): class DeleteTest(TestCase):
fixtures = ['dev', 'licenses'] fixtures = ['dev', 'licenses']
def test_unlisted_unrated_extension_can_be_deleted_by_author(self): def test_unlisted_unrated_extension_can_be_deleted_by_author(self):
self.maxDiff = None
version = create_version( version = create_version(
file__status=files.models.File.STATUSES.AWAITING_REVIEW, file__status=files.models.File.STATUSES.AWAITING_REVIEW,
ratings=[], ratings=[],
@ -29,6 +39,31 @@ class DeleteTest(TestCase):
self.assertEqual(extension.cannot_be_deleted_reasons, []) self.assertEqual(extension.cannot_be_deleted_reasons, [])
preview_file = extension.previews.first() preview_file = extension.previews.first()
self.assertIsNotNone(preview_file) 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() url = extension.get_delete_url()
user = extension.authors.first() user = extension.authors.first()
@ -49,6 +84,47 @@ class DeleteTest(TestCase):
self.assertIsNone(extensions.models.Version.objects.filter(pk=version.pk).first()) 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=version_file.pk).first())
self.assertIsNone(files.models.File.objects.filter(pk=preview_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) # 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): def test_publicly_listed_extension_cannot_be_deleted(self):

View File

@ -10,6 +10,7 @@ from common.tests.factories.users import UserFactory
from common.tests.utils import _get_all_form_errors from common.tests.utils import _get_all_form_errors
from extensions.models import Extension, Version from extensions.models import Extension, Version
from files.models import File from files.models import File
from reviewers.models import ApprovalActivity
import utils import utils
@ -425,6 +426,14 @@ class NewVersionTest(TestCase):
f'/add-ons/{self.extension.slug}/manage/versions/new/{file.pk}/', f'/add-ons/{self.extension.slug}/manage/versions/new/{file.pk}/',
) )
self.assertEqual(self.extension.versions.count(), 1) 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 # Check step 2: finalise new version and send to review
url = response['Location'] url = response['Location']
@ -444,3 +453,10 @@ class NewVersionTest(TestCase):
self.assertEqual(new_version.schema_version, '1.0.0') self.assertEqual(new_version.schema_version, '1.0.0')
self.assertEqual(new_version.release_notes, 'new version') self.assertEqual(new_version.release_notes, 'new version')
self.assertEqual(new_version.file.get_status_display(), 'Approved') 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,
)

View File

@ -74,7 +74,7 @@ class UpdateTest(TestCase):
self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 1) self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 1)
self.assertEqual(extension.previews.count(), 1) self.assertEqual(extension.previews.count(), 1)
file1 = extension.previews.all()[0] 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( self.assertEqual(
file1.original_hash, file1.original_hash,
'sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340', 'sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
@ -123,8 +123,8 @@ class UpdateTest(TestCase):
self.assertEqual(extension.previews.count(), 2) self.assertEqual(extension.previews.count(), 2)
file1 = extension.previews.all()[0] file1 = extension.previews.all()[0]
file2 = extension.previews.all()[1] file2 = extension.previews.all()[1]
self.assertEqual(file1.extension_preview.first().caption, 'First Preview Caption Text') self.assertEqual(file1.preview.caption, 'First Preview Caption Text')
self.assertEqual(file2.extension_preview.first().caption, 'Second Preview Caption Text') self.assertEqual(file2.preview.caption, 'Second Preview Caption Text')
self.assertEqual( self.assertEqual(
file1.original_hash, file1.original_hash,
'sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340', 'sha256:643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',

View File

@ -1,3 +1,5 @@
from datetime import timedelta
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
@ -80,6 +82,64 @@ class PublicViewsTest(_BaseTestCase):
self.assertTemplateUsed(response, 'extensions/home.html') 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): class ExtensionDetailViewTest(_BaseTestCase):
def test_cannot_view_unlisted_extension_anonymously(self): def test_cannot_view_unlisted_extension_anonymously(self):
extension = _create_extension() extension = _create_extension()
@ -229,3 +289,36 @@ class UpdateVersionViewTest(_BaseTestCase):
self.client.force_login(random_user) self.client.force_login(random_user)
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 403) 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')

View File

@ -42,38 +42,51 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
self.fail('invalid_version') self.fail('invalid_version')
def to_representation(self, instance): def to_representation(self, instance):
blender_version_min = instance.latest_version.blender_version_min matching_version = None
blender_version_max = instance.latest_version.blender_version_max # avoid triggering additional db queries, reuse the prefetched queryset
versions = [
# TODO: get the latest valid version v
# For now we skip the extension if the latest version is not in a valid range. for v in instance.versions.all()
if self.blender_version and not is_in_version_range( if v.file and v.file.status in instance.valid_file_statuses
self.blender_version, blender_version_min, blender_version_max ]
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 = { data = {
'id': instance.extension_id, 'id': instance.extension_id,
'schema_version': instance.latest_version.schema_version, 'schema_version': matching_version.schema_version,
'name': instance.name, 'name': instance.name,
'version': instance.latest_version.version, 'version': matching_version.version,
'tagline': instance.latest_version.tagline, 'tagline': matching_version.tagline,
'archive_hash': instance.latest_version.file.original_hash, 'archive_hash': matching_version.file.original_hash,
'archive_size': instance.latest_version.file.size_bytes, 'archive_size': matching_version.file.size_bytes,
'archive_url': self.request.build_absolute_uri(instance.latest_version.download_url), 'archive_url': self.request.build_absolute_uri(matching_version.download_url),
'type': EXTENSION_TYPE_SLUGS_SINGULAR.get(instance.type), 'type': EXTENSION_TYPE_SLUGS_SINGULAR.get(instance.type),
'blender_version_min': instance.latest_version.blender_version_min, 'blender_version_min': matching_version.blender_version_min,
'blender_version_max': instance.latest_version.blender_version_max, 'blender_version_max': matching_version.blender_version_max,
'website': self.request.build_absolute_uri(instance.get_absolute_url()), 'website': self.request.build_absolute_uri(instance.get_absolute_url()),
'maintainer': str(instance.authors.first()), # avoid triggering additional db queries, reuse the prefetched queryset
'license': [ 'maintainer': str(instance.authors.all()[0]),
license_iter.slug for license_iter in instance.latest_version.licenses.all() 'license': [license_iter.slug for license_iter in matching_version.licenses.all()],
], 'permissions': [permission.slug for permission in matching_version.permissions.all()],
'permissions': [
permission.slug for permission in instance.latest_version.permissions.all()
],
# TODO: handle copyright # 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) return clean_json_dictionary_from_optional_fields(data)
@ -93,10 +106,18 @@ class ExtensionsAPIView(APIView):
) )
def get(self, request): def get(self, request):
blender_version = request.GET.get('blender_version') 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( 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( return Response(
{ {
# TODO implement extension blocking by moderators # TODO implement extension blocking by moderators

View File

@ -4,6 +4,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404, reverse 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 import DetailView, ListView
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView 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; * maintainers should be able to preview their yet unlisted add-ons;
* staff should be able to preview 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): def get_object(self, queryset=None):
"""Record a page view when returning the Extension object.""" """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' template_name = 'extensions/new_version_finalise.html'
model = Version model = Version
fields = ['release_notes'] fields = ['blender_version_max', 'release_notes']
def get_success_url(self): def get_success_url(self):
return reverse( return reverse(
@ -353,6 +359,7 @@ class DraftExtensionView(
): ):
template_name = 'extensions/draft_finalise.html' template_name = 'extensions/draft_finalise.html'
form_class = VersionForm form_class = VersionForm
msg_awaiting_review = _('Extension is ready for initial review')
@property @property
def success_message(self) -> str: def success_message(self) -> str:
@ -418,7 +425,7 @@ class DraftExtensionView(
user=self.request.user, user=self.request.user,
extension=extension_form.instance, extension=extension_form.instance,
type=ApprovalActivity.ActivityType.AWAITING_REVIEW, type=ApprovalActivity.ActivityType.AWAITING_REVIEW,
message="initial submission", message=self.msg_awaiting_review,
).save() ).save()
return super().form_valid(form) return super().form_valid(form)
except forms.ValidationError as e: except forms.ValidationError as e:

View File

@ -66,6 +66,14 @@ class DraftMixin:
"""If the extension is incomplete, returns the FinalizeDraftView""" """If the extension is incomplete, returns the FinalizeDraftView"""
def dispatch(self, request, *args, **kwargs): 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 = (
Extension.objects.listed_or_authored_by(user_id=self.request.user.pk) Extension.objects.listed_or_authored_by(user_id=self.request.user.pk)
.filter(status=Extension.STATUSES.INCOMPLETE) .filter(status=Extension.STATUSES.INCOMPLETE)

View File

@ -42,7 +42,19 @@ class HomeView(ListedExtensionsView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**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['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')[ context['themes'] = q.filter(type=EXTENSION_TYPE_CHOICES.THEME).order_by('-average_score')[
:8 :8
@ -92,7 +104,15 @@ class SearchView(ListedExtensionsView):
| Q(versions__tags__name__icontains=token) | Q(versions__tags__name__icontains=token)
) )
queryset = queryset.filter(search_query).distinct() 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): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@ -109,9 +129,9 @@ class SearchView(ListedExtensionsView):
# Determine which tags to list depending on the context. # Determine which tags to list depending on the context.
if context.get('type'): if context.get('type'):
tag_type_id = self._get_type_id_by_slug() 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'): elif context.get('tag'):
tag_type_id = context['tag'].type 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 return context

View File

@ -1,17 +1,29 @@
import logging
from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.template.loader import render_to_string
import background_task.admin import background_task.admin
import background_task.models import background_task.models
from .models import File, FileValidation from .models import File, FileValidation
import files.signals import files.signals
logger = logging.getLogger(__name__)
def scan_selected_files(self, request, queryset):
def schedule_scan(self, request, queryset):
"""Scan selected files.""" """Scan selected files."""
for instance in queryset: for instance in queryset:
files.signals.schedule_scan(instance) 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): class FileValidationInlineAdmin(admin.StackedInline):
model = FileValidation model = FileValidation
readonly_fields = ('date_created', 'date_modified', 'is_ok', 'results') readonly_fields = ('date_created', 'date_modified', 'is_ok', 'results')
@ -27,6 +39,28 @@ class FileValidationInlineAdmin(admin.StackedInline):
@admin.register(File) @admin.register(File)
class FileAdmin(admin.ModelAdmin): 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 view_on_site = False
save_on_top = True save_on_top = True
@ -48,6 +82,9 @@ class FileAdmin(admin.ModelAdmin):
'date_approved', 'date_approved',
'date_status_changed', 'date_status_changed',
'size_bytes', 'size_bytes',
'thumbnails',
'thumbnail',
'type',
'user', 'user',
'original_hash', 'original_hash',
'original_name', 'original_name',
@ -59,6 +96,9 @@ class FileAdmin(admin.ModelAdmin):
'^version__extension__name', '^version__extension__name',
'extensions__slug', 'extensions__slug',
'extensions__name', 'extensions__name',
'original_name',
'hash',
'source',
) )
fieldsets = ( fieldsets = (
@ -67,9 +107,8 @@ class FileAdmin(admin.ModelAdmin):
{ {
'fields': ( 'fields': (
'id', 'id',
('source', 'thumbnail'), ('source', 'thumbnails', 'thumbnail'),
('original_name', 'content_type'), ('type', 'content_type', 'original_name'),
'type',
'status', 'status',
) )
}, },
@ -99,7 +138,7 @@ class FileAdmin(admin.ModelAdmin):
) )
inlines = [FileValidationInlineAdmin] inlines = [FileValidationInlineAdmin]
actions = [scan_selected_files] actions = [schedule_scan, make_thumbnails]
def is_ok(self, obj): def is_ok(self, obj):
return obj.validation.is_ok if hasattr(obj, 'validation') else None return obj.validation.is_ok if hasattr(obj, 'validation') else None

View File

@ -7,3 +7,10 @@ class FilesConfig(AppConfig):
def ready(self): def ready(self):
import files.signals # noqa: F401 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)

View 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),
),
]

View File

@ -6,11 +6,8 @@ from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin
from files.utils import get_sha256, guess_mimetype_from_ext from files.utils import get_sha256, guess_mimetype_from_ext, get_thumbnail_upload_to
from constants.base import ( from constants.base import FILE_STATUS_CHOICES, FILE_TYPE_CHOICES
FILE_STATUS_CHOICES,
FILE_TYPE_CHOICES,
)
import utils import utils
User = get_user_model() User = get_user_model()
@ -41,15 +38,11 @@ def file_upload_to(instance, filename):
def thumbnail_upload_to(instance, filename): def thumbnail_upload_to(instance, filename):
prefix = 'thumbnails/' return get_thumbnail_upload_to(instance.hash)
_hash = instance.hash.split(':')[-1]
extension = Path(filename).suffix
path = Path(prefix, _hash[:2], _hash).with_suffix(extension)
return path
class File(CreatedModifiedMixin, TrackChangesMixin, models.Model): 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 TYPES = FILE_TYPE_CHOICES
STATUSES = FILE_STATUS_CHOICES STATUSES = FILE_STATUS_CHOICES
@ -63,7 +56,8 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
null=True, null=True,
blank=True, blank=True,
max_length=256, 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) content_type = models.CharField(max_length=256, null=True, blank=True)
type = models.PositiveSmallIntegerField( type = models.PositiveSmallIntegerField(
@ -203,6 +197,30 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
def get_submit_url(self) -> str: def get_submit_url(self) -> str:
return self.extension.get_draft_url() 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): class FileValidation(CreatedModifiedMixin, TrackChangesMixin, models.Model):
track_changes_to_fields = {'is_ok', 'results'} track_changes_to_fields = {'is_ok', 'results'}

View File

@ -1,10 +1,12 @@
import logging 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 from django.dispatch import receiver
import files.models import files.models
import files.tasks import files.tasks
import files.utils
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -35,6 +37,55 @@ def _scan_new_file(
schedule_scan(instance) 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) @receiver(pre_delete, sender=files.models.File)
def _log_file_delete(sender: object, instance: files.models.File, **kwargs: object) -> None: @receiver(pre_delete, sender=files.models.FileValidation)
logger.info('Deleting file pk=%s source=%s', instance.pk, instance.source.name) 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)

View 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;
}

View File

@ -27,3 +27,45 @@ def clamdscan(file_id: int):
file_validation.results = scan_result file_validation.results = scan_result
file_validation.is_ok = is_ok file_validation.is_ok = is_ok
file_validation.save(update_fields={'results', 'is_ok', 'date_modified'}) 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)

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

View File

@ -1,21 +1,19 @@
{% load common i18n %} {% load common i18n %}
{# FIXME: we might want to rephrase is_moderator in terms of Django's (group) permissions #} {# FIXME: we might want to rephrase is_moderator in terms of Django's (group) permissions #}
{% if perms.files.view_file or request.user.is_moderator %} {% if perms.files.view_file or request.user.is_moderator %}
{% with file_validation=file.validation %} {% if suspicious_files %}
{% if file_validation and not file_validation.is_ok %}
<section> <section>
<div class="card pb-3 pt-4 px-4 mb-3 ext-detail-download-danger"> <div class="card pb-3 pt-4 px-4 mb-3 ext-detail-download-danger">
<h3>&nbsp;{% trans "Suspicious upload" %}</h3> <h3>&nbsp;{% 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> <h4>
{{ alert_text }} {{ alert_text }}
{% if perms.files.view_file %}{# Moderators don't necessarily have access to the admin #} {% 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> <a href="{{ admin_file_url }}" target="_blank">{% trans "See details" %}</a>
{% endif %} {% endif %}
</h4> </h4>
</div> </div>
</section> </section>
{% endif %} {% endif %}
{% endwith %}
{% endif %} {% endif %}

View File

@ -1,10 +1,8 @@
{% load common i18n %} {% load common i18n %}
{# FIXME: we might want to rephrase is_moderator in terms of Django's (group) permissions #} {# FIXME: we might want to rephrase is_moderator in terms of Django's (group) permissions #}
{% if perms.files.view_file or request.user.is_moderator %} {% if perms.files.view_file or request.user.is_moderator %}
{% with file_validation=file.validation %} {% if suspicious_files %}
{% if file_validation and not file_validation.is_ok %} {% blocktrans asvar alert_text %}Scan of the {{ suspicious_files.0 }} indicates malicious content.{% endblocktrans %}
{% blocktrans asvar alert_text %}Scan of the {{ file }} indicates malicious content.{% endblocktrans %}
<b class="text-danger pt-2" title="{{ alert_text }}"></b> <b class="text-danger pt-2" title="{{ alert_text }}"></b>
{% endif %} {% endif %}
{% endwith %}
{% endif %} {% endif %}

View File

@ -42,9 +42,11 @@ class FileTest(TestCase):
'new_state': {'status': 'Approved'}, 'new_state': {'status': 'Approved'},
'object': '<File: test.zip (Approved)>', 'object': '<File: test.zip (Approved)>',
'old_state': { 'old_state': {
'status': 2,
'hash': 'foobar', 'hash': 'foobar',
'metadata': {},
'size_bytes': 7149, 'size_bytes': 7149,
'status': 2,
'thumbnail': '',
}, },
} }
}, },

112
files/tests/test_tasks.py Normal file
View 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]},
},
},
)

View File

@ -1,6 +1,20 @@
from pathlib import Path
from unittest.mock import patch, ANY
import tempfile
from django.test import TestCase 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): class UtilsTest(TestCase):
@ -98,3 +112,49 @@ class UtilsTest(TestCase):
] ]
paths = filter_paths_by_ext(name_list, '.md') paths = filter_paths_by_ext(name_list, '.md')
self.assertEqual(list(paths), []) 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'
)

View File

@ -1,18 +1,26 @@
from pathlib import Path from pathlib import Path
import datetime
import hashlib import hashlib
import io import io
import logging import logging
import mimetypes import mimetypes
import os import os
import os.path import os.path
import tempfile
import toml import toml
import typing import typing
import zipfile 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 from lxml import etree
import clamd import clamd
import magic import magic
from constants.base import THUMBNAIL_FORMAT, THUMBNAIL_SIZES, THUMBNAIL_QUALITY
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MODULE_DIR = Path(__file__).resolve().parent MODULE_DIR = Path(__file__).resolve().parent
THEME_SCHEMA = [] THEME_SCHEMA = []
@ -172,3 +180,119 @@ def run_clamdscan(abs_path: str) -> tuple:
result = clamd_socket.instream(f)['stream'] result = clamd_socket.instream(f)['stream']
logger.info('File at path=%s scanned: %s', abs_path, result) logger.info('File at path=%s scanned: %s', abs_path, result)
return 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

View File

@ -28,11 +28,6 @@ class Command(BaseCommand):
logger.info(f'{recipient} has unconfirmed email, skipping') logger.info(f'{recipient} has unconfirmed email, skipping')
n.save() n.save()
continue 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 n.email_sent = True
# first mark as processed, then send: avoid spamming in case of a crash-loop # first mark as processed, then send: avoid spamming in case of a crash-loop
n.save() n.save()

View File

@ -32,9 +32,9 @@ class Notification(models.Model):
def format_email(self): def format_email(self):
action = self.action 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() 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) return (subject, mesage)
def get_absolute_url(self): def get_absolute_url(self):

View File

@ -18,6 +18,7 @@ VERB2FLAGS = {
Verb.REPORTED_RATING: [Flag.MODERATOR], Verb.REPORTED_RATING: [Flag.MODERATOR],
Verb.REQUESTED_CHANGES: [Flag.AUTHOR, Flag.REVIEWER], Verb.REQUESTED_CHANGES: [Flag.AUTHOR, Flag.REVIEWER],
Verb.REQUESTED_REVIEW: [Flag.MODERATOR, Flag.REVIEWER], Verb.REQUESTED_REVIEW: [Flag.MODERATOR, Flag.REVIEWER],
Verb.UPLOADED_NEW_VERSION: [],
} }
@ -41,7 +42,7 @@ def _create_notifications(
notifications = [] notifications = []
flags = VERB2FLAGS.get(instance.verb, None) 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') logger.warning(f'no follower flags for verb={instance.verb}, nobody will be notified')
return return

View File

@ -1,22 +1,48 @@
{% extends "common/base.html" %} {% extends "common/base.html" %}
{% load i18n %} {% load common filters i18n %}
{% block page_title %}{% blocktranslate %}Notifications{% endblocktranslate %}{% endblock page_title %} {% block page_title %}{% blocktranslate %}Notifications{% endblocktranslate %}{% endblock page_title %}
{% block content %} {% 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 %} {% if notification_list %}
{% for notification in notification_list %} {% for notification in notification_list %}
<div class="row"> <div class="row mb-2 {% if notification.read_at%}text-muted{% endif %}">
{{ notification.action }} <div class="col">
{% if notification.read_at %}
{% else %} {{ notification.action.timestamp | naturaltime_compact }}
{% blocktranslate %}Mark as read{% endblocktranslate %}
<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 %} {% endif %}
</div>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<p> <p>
{% blocktranslate %}You have no notifications{% endblocktranslate %} {% trans 'You have no notifications' %}
</p> </p>
{% endif %} {% endif %}
{% endblock content %} {% endblock content %}

View File

@ -1,7 +1,7 @@
"""Notifications pages.""" """Notifications pages."""
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseForbidden from django.http import HttpResponseForbidden
from django.http.response import JsonResponse from django.shortcuts import redirect
from django.utils import timezone from django.utils import timezone
from django.views.generic import ListView from django.views.generic import ListView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
@ -13,11 +13,10 @@ from notifications.models import Notification
class NotificationsView(LoginRequiredMixin, ListView): class NotificationsView(LoginRequiredMixin, ListView):
model = Notification model = Notification
ordering = None # FIXME
paginate_by = 10 paginate_by = 10
def get_queryset(self): 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): class MarkReadAllView(LoginRequiredMixin, FormView):
@ -32,8 +31,7 @@ class MarkReadAllView(LoginRequiredMixin, FormView):
notification.read_at = now notification.read_at = now
Notification.objects.bulk_update(unread, ['read_at']) Notification.objects.bulk_update(unread, ['read_at'])
return redirect('notifications:notifications')
return JsonResponse({})
class MarkReadView(LoginRequiredMixin, SingleObjectMixin, View): class MarkReadView(LoginRequiredMixin, SingleObjectMixin, View):
@ -46,4 +44,4 @@ class MarkReadView(LoginRequiredMixin, SingleObjectMixin, View):
return HttpResponseForbidden() return HttpResponseForbidden()
notification.read_at = timezone.now() notification.read_at = timezone.now()
notification.save(update_fields=['read_at']) notification.save(update_fields=['read_at'])
return JsonResponse({}) return redirect('notifications:notifications')

View File

@ -11,6 +11,7 @@
with_items: with_items:
- clamav-daemon - clamav-daemon
- clamav-unofficial-sigs - clamav-unofficial-sigs
- ffmpeg
- git - git
- libpq-dev - libpq-dev
- nginx-full - nginx-full
@ -48,18 +49,7 @@
tags: tags:
- dotenv - dotenv
- name: Copying ASGI config files - import_tasks: tasks/configure_uwsgi.yaml
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/deploy.yaml - import_tasks: tasks/deploy.yaml

View 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

View File

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

View 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

View 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

View File

@ -3,9 +3,8 @@ project_name: Blender Extensions
project_slug: blender-extensions project_slug: blender-extensions
service_name: "{{ project_slug }}-{{ env }}" service_name: "{{ project_slug }}-{{ env }}"
background_service_name: '{{ service_name }}-background.service' background_service_name: '{{ service_name }}-background.service'
asgi_module: blender_extensions.asgi:application
django_settings_module: blender_extensions.settings django_settings_module: blender_extensions.settings
uwsgi_module: blender_extensions.wsgi:application
max_requests: 1000 max_requests: 1000
max_requests_jitter: 50 max_requests_jitter: 50
port: 8200 port: 8200
@ -21,6 +20,7 @@ dir:
errors: "/var/www/{{ service_name }}/html/errors" errors: "/var/www/{{ service_name }}/html/errors"
env_file: "{{ dir.source }}/.env" env_file: "{{ dir.source }}/.env"
uwsgi_pid: "{{ dir.source }}/{{ service_name }}.pid"
nginx: nginx:
user: www-data user: www-data

View File

@ -1,5 +1,5 @@
from actstream import action 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 django.dispatch import receiver
from constants.activity import Verb from constants.activity import Verb
@ -40,3 +40,8 @@ def _create_action_from_rating(
action_object=instance, action_object=instance,
target=instance.extension, target=instance.extension,
) )
@receiver(pre_delete, sender=Rating)
def _log_deletion(sender: object, instance: Rating, **kwargs: object) -> None:
instance.record_deletion()

View File

@ -63,6 +63,11 @@
--star-size: 1.6em --star-size: 1.6em
width: 8em width: 8em
// TODO: refactor stars-helper
.stars-helper
max-height: 1.4rem
transform: translateY(-.1rem)
.ratings-summary .ratings-summary
display: flex display: flex
flex-direction: column flex-direction: column

View File

@ -14,9 +14,9 @@
{{ rating.user }} {{ rating.user }}
</a> </a>
</li> </li>
<li class="me-auto"> <li class="align-items-center d-flex me-auto">
{% with score_percentage=rating.score %} {% 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 %} {% include "ratings/components/average.html" with score=rating.score %}
</a> </a>
{% endwith %} {% endwith %}

View File

@ -4,7 +4,7 @@
{% block page_title %}Rate {{ extension.name }}{% endblock page_title %} {% block page_title %}Rate {{ extension.name }}{% endblock page_title %}
{% block content %} {% block content %}
<div class="container"> <div class="container rating-form">
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{% with form=form|add_form_classes %} {% with form=form|add_form_classes %}

View File

@ -40,6 +40,7 @@ mistune==2.0.4
multidict==6.0.2 multidict==6.0.2
oauthlib==3.2.0 oauthlib==3.2.0
Pillow==9.2.0 Pillow==9.2.0
python-ffmpeg==2.0.12
python-magic==0.4.27 python-magic==0.4.27
requests==2.28.1 requests==2.28.1
requests-oauthlib==1.3.1 requests-oauthlib==1.3.1
@ -49,6 +50,5 @@ six==1.16.0
sqlparse==0.4.2 sqlparse==0.4.2
toml==0.10.2 toml==0.10.2
urllib3==1.26.11 urllib3==1.26.11
uvicorn==0.18.2
webencodings==0.5.1 webencodings==0.5.1
yarl==1.7.2 yarl==1.7.2

View File

@ -1,3 +1,3 @@
-r requirements.txt -r requirements.txt
psycopg2==2.9.3 psycopg2==2.9.3
gunicorn==20.1.0 uwsgi==2.0.23

View 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),
),
]

View File

@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
import common.help_texts import common.help_texts
from extensions.models import Extension 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 utils import absolutify, send_mail
from constants.base import EXTENSION_TYPE_CHOICES 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): class ActivityType(models.TextChoices):
COMMENT = "COM", _("Comment") COMMENT = "COM", _("Comment")
APPROVED = "APR", _("Approved") APPROVED = "APR", _("Approved")
AWAITING_CHANGES = "AWC", _("Awaiting Changes") AWAITING_CHANGES = "AWC", _("Awaiting Changes")
AWAITING_REVIEW = "AWR", _("Awaiting Review") AWAITING_REVIEW = "AWR", _("Awaiting Review")
UPLOADED_NEW_VERSION = "UNV", _("Uploaded New Version")
user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True) user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
extension = models.ForeignKey( extension = models.ForeignKey(

View File

@ -1,6 +1,6 @@
from actstream import action from actstream import action
from actstream.actions import follow 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 django.dispatch import receiver
from constants.activity import Flag, Verb 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_CHANGES: Verb.REQUESTED_CHANGES,
ApprovalActivity.ActivityType.AWAITING_REVIEW: Verb.REQUESTED_REVIEW, ApprovalActivity.ActivityType.AWAITING_REVIEW: Verb.REQUESTED_REVIEW,
ApprovalActivity.ActivityType.COMMENT: Verb.COMMENTED, ApprovalActivity.ActivityType.COMMENT: Verb.COMMENTED,
ApprovalActivity.ActivityType.UPLOADED_NEW_VERSION: Verb.UPLOADED_NEW_VERSION,
} }
action.send( action.send(
instance.user, instance.user,
@ -37,3 +38,8 @@ def _create_action_from_review_and_follow(
action_object=instance, action_object=instance,
target=instance.extension, target=instance.extension,
) )
@receiver(pre_delete, sender=ApprovalActivity)
def _log_deletion(sender: object, instance: ApprovalActivity, **kwargs: object) -> None:
instance.record_deletion()

View File

@ -6,27 +6,25 @@
{{ extension.name }} {{ extension.name }}
</a> </a>
</td> </td>
<td> <td>{% include "extensions/components/authors.html" %}</td>
{% if extension.authors.count %}
{% include "extensions/components/authors.html" %}
{% endif %}
</td>
<td title="{{ extension.date_created }}">{{ extension.date_created|naturaltime_compact }}</td> <td title="{{ extension.date_created }}">{{ extension.date_created|naturaltime_compact }}</td>
<td class="d-flex"> <td class="d-flex">
<a href="{{ extension.get_review_url }}#activity"> <a href="{{ extension.get_review_url }}#activity">
<span>{{ extension.review_activity.all|length }}</span> <span>{{ stats.count }}</span>
</a> </a>
<a href="{{ extension.get_review_url }}#activity-{{ stats.last_activity.id }}" class="ms-3">
{% if extension.review_activity.all %} <span>{{ stats.last_activity.date_created|naturaltime_compact }}</span>
<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> </a>
{% endif %} {% include "files/components/scan_details_flag.html" with suspicious_files=extension.suspicious_files %}
{% include "files/components/scan_details_flag.html" with file=extension.latest_version.file %}
</td> </td>
<td> <td>
<a href="{{ extension.get_review_url }}" class="text-decoration-none"> <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> </a>
</td> </td>
</tr> </tr>

View File

@ -76,12 +76,14 @@
<h3>Previews Pending Approval</h3> <h3>Previews Pending Approval</h3>
<div class="row"> <div class="row">
{% for preview in pending_previews %} {% for preview in pending_previews %}
{% with thumbnail_1080p_url=preview.file.thumbnail_1080p_url %}
<div class="col-md-3"> <div class="col-md-3">
<a href="{{ preview.file.source.url }}" class="d-block mb-2" title="{{ preview.caption }}" target="_blank"> <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> </a>
{% include "common/components/status.html" with object=preview.file class="d-block" %} {% include "common/components/status.html" with object=preview.file class="d-block" %}
</div> </div>
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
@ -99,8 +101,7 @@
{% for activity in extension.review_activity.all %} {% for activity in extension.review_activity.all %}
<li id="activity-{{ activity.id }}"> <li id="activity-{{ activity.id }}">
{# All activities except comments. #} {% if activity.type in status_change_types %}
{% if activity.type != 'COM' %}
<div class="activity-item activity-status-change activity-status-{{ activity.get_type_display|slugify }}"> <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> <i class="activity-icon i-activity-{{ activity.get_type_display|slugify }}"></i>
@ -169,6 +170,7 @@
{% if is_maintainer or request.user.is_moderator %} {% if is_maintainer or request.user.is_moderator %}
{% include "common/components/field.html" with field=form.type %} {% include "common/components/field.html" with field=form.type %}
{% endif %} {% endif %}
<button type="submit" id="activity-submit" class="btn btn-primary"> <button type="submit" id="activity-submit" class="btn btn-primary">
<span>{% trans "Comment" %}</span> <span>{% trans "Comment" %}</span>
</button> </button>

View File

@ -34,12 +34,10 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for extension in object_list %} {% for stats in object_list %}
{% if user.is_moderator %} {% with extension=stats.extension %}
{% include 'reviewers/components/review_list_item.html' %} {% include 'reviewers/components/review_list_item.html' with extension=extension stats=stats %}
{% elif extension.status_slug == 'awaiting-review' %} {% endwith %}
{% include 'reviewers/components/review_list_item.html' %}
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@ -3,13 +3,21 @@ from django.shortcuts import reverse
from common.tests.factories.extensions import create_version from common.tests.factories.extensions import create_version
from files.models import File from files.models import File
from reviewers.models import ApprovalActivity
class CommentsViewTest(TestCase): class CommentsViewTest(TestCase):
fixtures = ['licenses'] fixtures = ['licenses']
def setUp(self): 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 # List of extensions under review does not require authentication
def test_list_visibility(self): def test_list_visibility(self):

View File

@ -4,6 +4,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.views.generic import DetailView, FormView from django.views.generic import DetailView, FormView
from django.shortcuts import reverse from django.shortcuts import reverse
import django.forms
from files.models import File from files.models import File
from extensions.models import Extension from extensions.models import Extension
@ -12,15 +13,51 @@ from reviewers.models import ApprovalActivity
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
STATUS_CHANGE_TYPES = [
ApprovalActivity.ActivityType.APPROVED,
ApprovalActivity.ActivityType.AWAITING_CHANGES,
ApprovalActivity.ActivityType.AWAITING_REVIEW,
]
class ApprovalQueueView(ListView): class ApprovalQueueView(ListView):
model = Extension model = Extension
paginate_by = 100 paginate_by = 100
def get_queryset(self): def get_queryset(self):
return Extension.objects.exclude(status=Extension.STATUSES.APPROVED).order_by( qs = (
'-date_created' 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' template_name = 'reviewers/extensions_review_list.html'
@ -35,22 +72,31 @@ class ExtensionsApprovalDetailView(DetailView):
ctx['pending_previews'] = self.object.preview_set.exclude( ctx['pending_previews'] = self.object.preview_set.exclude(
file__status=File.STATUSES.APPROVED file__status=File.STATUSES.APPROVED
) )
ctx['status_change_types'] = STATUS_CHANGE_TYPES
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
form = ctx['comment_form'] = CommentForm() form = ctx['comment_form'] = CommentForm()
# Remove 'Approved' status from dropdown it not moderator # anyone can comment
if not (self.request.user.is_moderator or self.request.user.is_superuser): filtered_activity_types = {ApprovalActivity.ActivityType.COMMENT}
filtered_activity_types = [ user = self.request.user
t if self.object.has_maintainer(user):
for t in ApprovalActivity.ActivityType.choices filtered_activity_types.add(ApprovalActivity.ActivityType.AWAITING_REVIEW)
if t[0] if user.is_moderator or user.is_superuser:
not in [ filtered_activity_types.update(
[
ApprovalActivity.ActivityType.APPROVED, ApprovalActivity.ActivityType.APPROVED,
ApprovalActivity.ActivityType.AWAITING_CHANGES, ApprovalActivity.ActivityType.AWAITING_CHANGES,
] ]
] )
form.fields['type'].choices = filtered_activity_types choices = list(
form.fields['type'].widget.choices = filtered_activity_types 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 return ctx

View File

@ -7,6 +7,7 @@ from django.contrib.admin.utils import NestedObjects
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models, DEFAULT_DB_ALIAS, transaction from django.db import models, DEFAULT_DB_ALIAS, transaction
from django.templatetags.static import static from django.templatetags.static import static
from django.utils.dateparse import parse_datetime
from common.model_mixins import TrackChangesMixin from common.model_mixins import TrackChangesMixin
from files.utils import get_sha256_from_value from files.utils import get_sha256_from_value
@ -89,7 +90,7 @@ class User(TrackChangesMixin, AbstractUser):
date_deletion_requested, date_deletion_requested,
) )
self.is_active = False 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']) self.save(update_fields=['is_active', 'date_deletion_requested'])
@transaction.atomic @transaction.atomic

View File

@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.db.models.signals import m2m_changed, pre_save from django.db.models.signals import m2m_changed, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.dateparse import parse_datetime
from blender_id_oauth_client import signals as bid_signals 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. 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.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() instance.save()
bid.copy_avatar_from_blender_id(user=instance) bid.copy_avatar_from_blender_id(user=instance)

View File

@ -12,7 +12,7 @@
<div class="row"> <div class="row">
<div class="d-none d-md-block col-md-3"> <div class="d-none d-md-block col-md-3">
<div class="is-sticky pt-4"> <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"> <div class="nav-drawer-body fw-bold">
{% include 'users/settings/tabs.html' %} {% include 'users/settings/tabs.html' %}
</div> </div>

View File

@ -3,9 +3,11 @@
{% include "common/components/nav_link.html" with name="users:my-profile" title="Profile" classes="i-home py-2" %} {% include "common/components/nav_link.html" with name="users:my-profile" title="Profile" classes="i-home py-2" %}
{% if user.teams.count %} {% 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 %} {% 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" %} {% include "common/components/nav_link.html" with name="users:my-profile-delete" title="Delete account" classes="i-trash py-2" %}
</div> </div>
{% endspaceless %} {% endspaceless %}

View File

@ -11,6 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.http import HttpResponse, HttpResponseBadRequest from django.http import HttpResponse, HttpResponseBadRequest
from django.http.request import HttpRequest 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.csrf import csrf_exempt
from django.views.decorators.http import require_POST 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') update_fields.add('full_name')
if 'confirmed_email_at' in payload: 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') update_fields.add('confirmed_email_at')
if update_fields: if update_fields: