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

View File

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

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

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 = {
'default': dj_database_url.config(default='sqlite:///{}'.format(BASE_DIR / 'db.sqlite3')),
}
DATABASES['default']['CONN_MAX_AGE'] = None
# Password validation
# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
@ -325,3 +326,7 @@ EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD')
ACTSTREAM_SETTINGS = {
'MANAGER': 'actstream.managers.ActionManager',
}
# Require file validation for other file processing (e.g. thumbnails).
# Should be set for staging/production.
REQUIRE_FILE_VALIDATION = os.getenv('REQUIRE_FILE_VALIDATION', False)

View File

@ -1,8 +1,9 @@
from typing import Set, Tuple, Mapping, Any
import copy
import logging
from django.contrib.admin.models import DELETION
from django.contrib.contenttypes.models import ContentType
from django.core import serializers
from django.db import models
from django.shortcuts import reverse
from django.utils import timezone
@ -19,6 +20,13 @@ See TrackChangesMixin.pre_save_record().
"""
def _get_object_state(obj: object, fields=None, include_pk=False) -> dict:
data = serializers.serialize('python', [obj], fields=fields)[0]
if include_pk:
data['fields']['pk'] = data['pk']
return data['fields']
class CreatedModifiedMixin(models.Model):
"""Add standard date fields to a model."""
@ -45,7 +53,31 @@ class CreatedModifiedMixin(models.Model):
return f'{path}?{query}'
class TrackChangesMixin(models.Model):
class RecordDeletionMixin:
def record_deletion(self):
"""Create a LogEntry describing a deletion of this object."""
msg_args = {'type': type(self), 'pk': self.pk}
logger.info('Deleting %(type)s pk=%(pk)s', msg_args)
if hasattr(self, 'cannot_be_deleted_reasons'):
cannot_be_deleted_reasons = self.cannot_be_deleted_reasons
if len(cannot_be_deleted_reasons) > 0:
# This shouldn't happen: prior validation steps should have taken care of this.
msg_args['reasons'] = cannot_be_deleted_reasons
logger.error("%(type)s pk=%(pk)s is being deleted but it %(reasons)s", msg_args)
state = _get_object_state(self, include_pk=True)
message = [
{
'deleted': {
'name': str(self._meta.verbose_name),
'object': repr(self),
'old_state': state,
},
}
]
attach_log_entry(self, message, action_flag=DELETION)
class TrackChangesMixin(RecordDeletionMixin, models.Model):
"""Tracks changes of Django models.
Tracks which fields have changed in the save() function, so that
@ -92,9 +124,7 @@ class TrackChangesMixin(models.Model):
update_fields = kwargs.get('update_fields')
was_modified = self._was_modified(db_instance, update_fields=update_fields)
old_instance_data = {
attr: copy.deepcopy(getattr(db_instance, attr)) for attr in self.track_changes_to_fields
}
old_instance_data = _get_object_state(db_instance, fields=self.track_changes_to_fields)
return was_modified, old_instance_data
def record_status_change(self, was_changed, old_state, **kwargs):
@ -120,8 +150,9 @@ class TrackChangesMixin(models.Model):
if not was_changed or not self.pk:
return
new_state = _get_object_state(self, fields=self.track_changes_to_fields)
changed_fields = {
field for field in old_state.keys() if getattr(self, field) != old_state[field]
field for field in old_state.keys() if new_state.get(field) != old_state.get(field)
}
message = [
{

View File

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

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

View File

@ -1,3 +1,29 @@
.card
@extend .box
+media-sm
--cards-items-per-row: 2
+media-md
--cards-items-per-row: 3
+media-lg
--cards-items-per-row: 4
.cards-item-content
overflow: hidden
.crads-item-excerpt
line-height: calc(24 / 18)
.cards-item-extra
text-transform: none
.cards-item-extra-rating-stars
margin-bottom: .2rem
.stars
font-size: 1.4rem
.cards-item-title
+padding(0, y)

View File

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

View File

@ -1,3 +1,6 @@
\:root
--lh-sm: 2.4rem
.hero.extension-detail
--hero-max-height: 0
--hero-min-height: 24.0rem
@ -166,114 +169,6 @@
color: var(--color-text-secondary)
+fw-normal
.cards
+media-sm
--cards-items-per-row: 2
+media-md
--cards-items-per-row: 3
+media-lg
--cards-items-per-row: 4
.ext-card
+box-card
display: flex
flex-direction: column
height: 100%
overflow: hidden
transition: box-shadow ease-in-out 1s
&:hover
box-shadow: 1.0rem 1.0rem 2.0rem 0px rgba(0, 0, 0, .04), -1.0rem 0 2.0rem 0px rgba(0, 0, 0, .04)
&.is-bg-blur
background-color: hsl(213, 10%, 21%)
border: thin solid hsl(213, 10%, 20%)
position: relative
.ext-card-body
--color-text-secondary: hsla(213, 40%, 90%, .6)
border-bottom-left-radius: var(--border-radius-lg)
border-bottom-right-radius: var(--border-radius-lg)
+padding(1, top)
mix-blend-mode: screen
position: relative
z-index: 1
&:has(.ext-card-admin)
.ext-card-body
border-radius: 0
.ext-card-thumbnail-img
-webkit-mask-img: -webkit-gradient(linear, left 60%, left bottom, from(rgba(0,0,0,1)), to(rgba(0,0,0,0)))
.ext-card-thumbnail:hover
&+.ext-card-body .ext-card-title
color: var(--color-text-primary)
&.ext-card-row
flex-direction: row
+margin(3, bottom)
.ext-card-thumbnail
--card-thumbnail-width: 24.0rem
border-top-right-radius: 0
border-bottom-left-radius: var(--border-radius-lg)
height: 100%
img
border-top-right-radius: 0
border-bottom-left-radius: var(--border-radius-lg)
.ext-blender-version
display: inline-block
.ext-card-thumbnail-blur
bottom: 0
filter: blur(5.0rem)
left: 0
position: absolute
right: 0
transform: scale(1.25)
top: 0
z-index: 0
opacity: .5
.ext-card-thumbnail
--card-thumbnail-width: 100%
align-items: center
border-top-left-radius: var(--border-radius-lg)
border-top-right-radius: var(--border-radius-lg)
display: block
justify-content: center
max-width: var(--card-thumbnail-width)
overflow: hidden
.ext-card-thumbnail-img
background-position: center
background-size: cover
+make-aspect-ratio('16x9')
transition: transform ease-out var(--transition-speed)
.ext-card-body
display: flex
flex: 1
flex-direction: column
justify-content: space-between
+padding(3)
p
line-height: calc(24 / 18)
.ext-card-title
font-size: var(--fs-lg)
+margin(3, bottom)
transition: color var(--transition-speed)
a
text-decoration: none
.ext-list-details
@extend .list-inline
@ -286,12 +181,6 @@
&+.ext-list-details
+margin(2, top)
.ext-card-tags
display: flex
flex-wrap: wrap
gap: 3.2rem
justify-content: flex-start
/* Show only on row list view.*/
.ext-blender-version
display: none
@ -299,19 +188,6 @@
.ext-edit-field-row
+margin(2, top)
.ext-card-admin
align-items: center
background-color: hsla(213, 80%, 1%, .33)
border-bottom-left-radius: var(--border-radius-lg)
border-bottom-right-radius: var(--border-radius-lg)
border-top: var(--border-width) solid var(--border-color)
display: flex
justify-content: space-between
z-index: 1
dd
font-family: var(--font-body)
.previews-upload
+box-card
+padding(3)
@ -323,9 +199,9 @@
gap: .8rem
.previews-list-item
--preview-thumbnail-max-size: 180px
--preview-thumbnail-max-size: calc(12.4rem * 16 / 9)
align-items: center
align-items: start
background-color:
border-radius: var(--border-radius-lg)
border: var(--border-width) solid var(--border-color)
@ -334,6 +210,7 @@
.previews-list-item-thumbnail
margin: 0
+margin(2, y)
width: var(--preview-thumbnail-max-size)
.previews-list-item-thumbnail-img
@ -341,6 +218,7 @@
background-position: center
background-size: cover
border-radius: var(--border-radius)
height: 12.4rem
+make-aspect-ratio('16x9')
.details
@ -349,6 +227,7 @@
label
font-size: var(--fs-sm)
line-height: var(--lh-sm)
ul
+list-unstyled
@ -389,9 +268,12 @@
+fw-normal
+margin(3, left)
details[open]
.show-on-collapse
display: none
details
padding: 0
&[open]
.show-on-collapse
display: none
.ext-detail-info
.ext-detail-permissions
@ -460,3 +342,36 @@
.ext-review-list-activity
display: flex
+padding(0, x)
.rating-form
select
color: var(--color-warning)
&:active,
&:hover,
&:focus
color: var(--color-warning)
// TODO: consider adding component boxed nav generic to web-assets, and make variants on top of that
.nav-pills
@extend .dropdown-menu
box-shadow: none
display: block
position: relative
.nav-pills-item
@extend .dropdown-item
+margin(1, bottom)
&.active
background-color: var(--color-accent-bg)
&:last-child
+margin(0, bottom)
.nav-pills-divider
@extend .dropdown-divider
+margin(0, top)

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
$font-path: '/static/fonts'
/* Import variables.*/
$grid-breakpoints: (xs: 0,sm: 768px,md: 1020px,lg: 1220px,xl: 1380px,xxl: 1680px) !default
$grid-breakpoints: (xs: 0,sm: 768px,md: 1020px,lg: 1220px,xl: 1380px,xxl: 1680px)
$container-max-widths: (sm: 760px, md: 1020px, lg: 1070px, xl: 1320px, xxl: 1600px)
$container-width: map-get($container-max-widths, 'xl')
@ -41,7 +41,7 @@ $container-width: map-get($container-max-widths, 'xl')
+media-xs
width: 60px
/* Temporarily here until it can be moved to web-assets v2. */
/* TODO: temporarily here until it can be moved to web-assets v2. */
.nav-global-links-right
gap: 0 var(--spacer-2)
.navbar-search
@ -52,8 +52,8 @@ $container-width: map-get($container-max-widths, 'xl')
.profile-avatar
border-radius: 50%
height: var(--spacer-4)
pointer-events: none
width: 2.6rem
.search-highlight
background-color: var(--btn-color-bg)
@ -76,7 +76,7 @@ $container-width: map-get($container-max-widths, 'xl')
font-size: var(--fs-xs)
justify-content: center
padding-block: .1rem
padding-inline: .4rem
padding-inline: var(--spacer-1)
position: absolute
top: calc(var(--spacer-2) * -1)
transition: background-color var(--transition-speed), box-shadow 500ms, color var(--transition-speed)

View File

@ -1,3 +1,4 @@
{% load common %}
{% load pipeline %}
{% load static %}
{% load i18n %}
@ -137,6 +138,9 @@
</li>
{% if user.is_authenticated %}
<a href="{% url 'notifications:notifications' %}">
<i class="i-bell {% if user|unread_notification_count %}text-primary{% endif %}"></i>
</a>
<li class="nav-item dropdown">
<button id="navbarDropdown" aria-expanded="false" aria-haspopup="true" data-toggle-menu-id="nav-account-dropdown" role="button" class="nav-link dropdown-toggle js-dropdown-toggle">
<i class="i-user"></i>

View File

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

View File

@ -1,5 +1,5 @@
{% if name %}
<a class="nav-link {% include "common/components/_nav_item_active" %} {% if classes %}{{ classes }}{% endif %}" href="{% url name %}">{{ title }}</a>
<a class="nav-pills-item {% include "common/components/_nav_item_active" %} {% if classes %}{{ classes }}{% endif %}" href="{% url name %}">{{ title }}</a>
{% elif path %}
<a class="nav-link {% include "common/components/_nav_item_active" %} {% if classes %}{{ classes }}{% endif %}" href="{{ path }}">{{ title }}</a>
<a class="nav-pills-item {% include "common/components/_nav_item_active" %} {% if classes %}{{ classes }}{% endif %}" href="{{ path }}">{{ title }}</a>
{% endif %}

View File

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

View File

@ -13,6 +13,7 @@ from common.markdown import (
render_as_text as render_markdown_as_text,
)
from extensions.models import Tag
from notifications.models import Notification
import utils
@ -160,3 +161,8 @@ def replace(value, old_char_new_char):
"""Replaces occurrences of old_char with new_char in the given value."""
old_char, new_char = old_char_new_char.split(',')
return value.replace(old_char, new_char)
@register.filter(name='unread_notification_count')
def unread_notification_count(user):
return Notification.objects.filter(recipient=user, read_at__isnull=True).count()

View File

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

View File

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

View File

@ -100,3 +100,10 @@ ABUSE_TYPE = Choices(
('ABUSE_USER', ABUSE_TYPE_USER, "User"),
('ABUSE_RATING', ABUSE_TYPE_RATING, "Rating"),
)
# **N.B.**: thumbnail sizes are not intended to be changed on the fly:
# thumbnails of existing images must exist in MEDIA_ROOT before
# the code expecting thumbnails of new dimensions can be deployed!
THUMBNAIL_SIZES = {'1080p': [1920, 1080], '360p': [640, 360]}
THUMBNAIL_FORMAT = 'PNG'
THUMBNAIL_QUALITY = 83

View File

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

View File

@ -66,24 +66,14 @@ class AddPreviewFileForm(forms.ModelForm):
def save(self, *args, **kwargs):
"""Save Preview from the cleaned form data."""
# If file with this hash was already uploaded by the same user, return it
hash_ = self.instance.generate_hash(self.instance.source)
model = self.instance.__class__
existing_image = model.objects.filter(original_hash=hash_, user=self.request.user).first()
if (
existing_image
and not existing_image.extension_preview.filter(extension_id=self.extension.id).count()
):
logger.warning('Found an existing %s pk=%s', model, existing_image.pk)
self.instance = existing_image
# Fill in missing fields from request and the source file
self.instance.user = self.request.user
instance = super().save(*args, **kwargs)
# Create extension preview and save caption to it
instance.extension_preview.create(
extensions.models.Preview.objects.create(
file=instance,
caption=self.cleaned_data['caption'],
extension=self.extension,
)

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

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 constants.activity import Flag
from reviewers.models import ApprovalActivity
import extensions.models
import files.models
@ -18,15 +19,14 @@ User = get_user_model()
@receiver(pre_delete, sender=extensions.models.Extension)
@receiver(pre_delete, sender=extensions.models.Preview)
@receiver(pre_delete, sender=extensions.models.Version)
def _log_extension_delete(sender: object, instance: object, **kwargs: object) -> None:
cannot_be_deleted_reasons = instance.cannot_be_deleted_reasons
if len(cannot_be_deleted_reasons) > 0:
# This shouldn't happen: prior validation steps should have taken care of this.
# raise ValidationError({'__all__': cannot_be_deleted_reasons})
args = {'sender': sender, 'pk': instance.pk, 'reasons': cannot_be_deleted_reasons}
logger.error("%(sender)s pk=%(pk)s is being deleted but it %(reasons)s", args)
logger.info('Deleting %s pk=%s "%s"', sender, instance.pk, str(instance))
def _log_deletion(
sender: object,
instance: Union[
extensions.models.Extension, extensions.models.Version, extensions.models.Preview
],
**kwargs: object,
) -> None:
instance.record_deletion()
@receiver(post_delete, sender=extensions.models.Preview)
@ -166,3 +166,26 @@ def _auto_approve_subsequent_uploads(
args = {'f_id': file.pk, 'pk': instance.pk, 'sender': sender, 's': file.source.name}
logger.info('Auto-approving file pk=%(f_id)s of %(sender)s pk=%(pk)s source=%(s)s', args)
file.save(update_fields={'status', 'date_modified'})
@receiver(post_save, sender=extensions.models.Version)
def _create_approval_activity_for_new_version_if_listed(
sender: object,
instance: extensions.models.Version,
created: bool,
raw: bool,
**kwargs: object,
):
if raw:
return
if not created:
return
extension = instance.extension
if not extension.is_listed or not instance.file:
return
ApprovalActivity(
type=ApprovalActivity.ActivityType.UPLOADED_NEW_VERSION,
user=instance.file.user,
extension=instance.extension,
message=f'uploaded new version: {instance.version}',
).save()

View File

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

View File

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

View File

@ -1,68 +1,69 @@
{% 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="cards-item-content">
<a href="{{ extension.get_absolute_url }}">
<div class="cards-item-thumbnail">
<img alt="{{ extension.name }}" src="{{ thumbnail_360p_url }}" title="{{ extension.name }}">
</div>
</a>
<h3 class="cards-item-title">
<a href="{{ extension.get_absolute_url }}">{{ extension.name }}</a>
</h3>
<div class="cards-item-excerpt">
<p>
{{ latest.tagline }}
</p>
</div>
<div class="cards-item-extra">
<ul>
<li>
{% if extension.team %}
{% with team=extension.team %}
<a href="{{ team.get_absolute_url }}" title="{{ team.name }}">{{ team.name }}</a>
{% endwith %}
{% elif extension.authors.count %}
{% include "extensions/components/authors.html" %}
{% endif %}
</li>
</ul>
<div class="ext-card {% if blur %}is-bg-blur{% endif %}">
{% if blur %}
<div class="ext-card-thumbnail-blur" style="background-image: url({{ extension.previews.listed.first.source.url }});"></div>
{% endif %}
<ul class="cards-item-extra-rating-stars">
{% if extension.average_score %}
<li>
<a class="align-items-center d-flex" href="{{ extension.get_ratings_url }}">
{% include "ratings/components/average.html" with score=extension.average_score %}
({{ extension.text_ratings_count|int_compact }})
</a>
</li>
{% endif %}
<a class="ext-card-thumbnail" href="{{ extension.get_absolute_url }}">
<div class="ext-card-thumbnail-img" style="background-image: url({{ extension.previews.listed.first.source.url }});" title="{{ extension.name }}"></div>
</a>
{% if extension.download_count %}
<li title="{{ extension.download_count }} downloads">
<i class="i-download"></i> {{ extension.download_count | int_compact }}
</li>
{% endif %}
<div class="ext-card-body">
<h3 class="ext-card-title">
<a href="{{ extension.get_absolute_url }}">{{ extension.name }}</a>
</h3>
<p>
{{ latest.tagline }}
</p>
{% if show_type %}
<li class="ms-auto">
{{ extension.get_type_display }}
</li>
{% endif %}
</ul>
<ul class="ext-list-details">
<li class="ext-card-author">
{% if extension.team %}
{% with team=extension.team %}
<a href="{{ team.get_absolute_url }}" title="{{ team.name }}">{{ team.name }}</a>
{% endwith %}
{% elif extension.authors.count %}
{% include "extensions/components/authors.html" %}
{% endif %}
</li>
</ul>
{% if latest.tags.count %}
<ul>
{% for tag in latest.tags.all %}
<li>
{% include "extensions/components/badge_tag.html" with small=True version=latest %}
</li>
{% endfor %}
</ul>
{% endif %}
<ul class="ext-list-details mt-1">
{% if extension.average_score %}
<li>
<a class="align-items-center d-flex" href="{{ extension.get_ratings_url }}">
{% include "ratings/components/average.html" with score=extension.average_score %}
({{ extension.text_ratings_count|int_compact }})
</a>
</li>
{% endif %}
{% if extension.download_count %}
<li title="{{ extension.download_count }} downloads">
<i class="i-download"></i> {{ extension.download_count | int_compact }}
</li>
{% endif %}
{% if show_type %}
<li class="ms-auto">
{{ extension.get_type_display }}
</li>
{% endif %}
</ul>
{% if latest.tags.count %}
<ul class="ext-list-details ext-card-tags">
<li class="ext-card-tags">
{% include "extensions/components/tags.html" with small=True version=latest %}
</li>
</ul>
{% endif %}
</div>
{# Author/admin tools can be added here in an extending template #}
{% block admin %}{% endblock admin %}
</div>
</div>
{# Author/admin tools can be added here in an extending template #}
{% block admin %}{% endblock admin %}
</div>
{% endwith %}

View File

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

View File

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

View File

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

View File

@ -78,7 +78,7 @@
<div class="is-sticky py-3">
<div class="row">
<div class="col">
{% include "extensions/components/extension_edit_detail_card.html" with extension=form.instance.extension latest=form.instance is_initial=True %}
{% include "extensions/components/extension_edit_detail_card.html" with extension=form.instance.extension version=form.instance is_initial=True %}
<section class="card p-3 mt-3">
<div class="btn-col">

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@
{% block page_title %}{{ extension.name }}{% endblock page_title %}
{% block content %}
{% with latest=extension.latest_version author=extension.latest_version.file.user form=form|add_form_classes %}
{% with author=extension.latest_version.file.user form=form|add_form_classes %}
<div class="row">
<div class="col-md-8">
<h2>{{ extension.get_type_display }} {% trans 'details' %}</h2>
@ -93,7 +93,7 @@
<div class="is-sticky py-3">
<div class="row mb-3">
<div class="col">
{% include "extensions/components/extension_edit_detail_card.html" with extension=extension latest=latest %}
{% include "extensions/components/extension_edit_detail_card.html" with extension=extension version=extension.latest_version %}
<section class="card p-3 mt-3">
<div class="btn-col">

View File

@ -44,8 +44,7 @@
<div class="is-sticky">
<div class="row">
<div class="col">
{% include "extensions/components/extension_edit_detail_card.html" with extension=form.instance.extension latest=form.instance %}
{% include "extensions/components/extension_edit_detail_card.html" with extension=form.instance.extension version=form.instance is_editable=True form=form %}
<section class="card p-3 mt-3">
<div class="btn-col">
<button type="submit" class="btn btn-primary">

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.files import FileFactory
from common.tests.factories.users import UserFactory
from common.tests.factories.users import UserFactory, create_moderator
import extensions.models
import files.models
import reviewers.models
TEST_MEDIA_DIR = Path(__file__).resolve().parent / 'media'
# Media file are physically deleted when files records are deleted, hence the override
@override_settings(MEDIA_ROOT=TEST_MEDIA_DIR)
class DeleteTest(TestCase):
fixtures = ['dev', 'licenses']
def test_unlisted_unrated_extension_can_be_deleted_by_author(self):
self.maxDiff = None
version = create_version(
file__status=files.models.File.STATUSES.AWAITING_REVIEW,
ratings=[],
@ -29,6 +39,31 @@ class DeleteTest(TestCase):
self.assertEqual(extension.cannot_be_deleted_reasons, [])
preview_file = extension.previews.first()
self.assertIsNotNone(preview_file)
# Create some ApprovalActivity as well
moderator = create_moderator()
approval_activity = reviewers.models.ApprovalActivity.objects.create(
extension=extension,
user=moderator,
message='This is a message in approval activity',
)
# Create a file validation record
file_validation = files.models.FileValidation.objects.create(
file=version_file, results={'deadbeef': 'foobar'}
)
object_reprs = list(
map(
repr,
[
preview_file,
version_file,
file_validation,
extension,
approval_activity,
preview_file.preview,
version,
],
)
)
url = extension.get_delete_url()
user = extension.authors.first()
@ -49,6 +84,47 @@ class DeleteTest(TestCase):
self.assertIsNone(extensions.models.Version.objects.filter(pk=version.pk).first())
self.assertIsNone(files.models.File.objects.filter(pk=version_file.pk).first())
self.assertIsNone(files.models.File.objects.filter(pk=preview_file.pk).first())
# Check that each of the deleted records was logged
deletion_log_entries_q = LogEntry.objects.filter(action_flag=DELETION)
self.assertEqual(deletion_log_entries_q.count(), 7)
self.assertEqual(
[_.object_repr for _ in deletion_log_entries_q],
object_reprs,
)
log_entry = deletion_log_entries_q.filter(object_repr__contains='Extension').first()
change_message_data = json.loads(log_entry.change_message)
self.assertEqual(
change_message_data[0]['deleted']['object'], f'<Extension: Add-on "{extension.name}">'
)
self.assertEqual(
set(change_message_data[0]['deleted']['old_state'].keys()),
{
'average_score',
'date_approved',
'date_created',
'date_modified',
'date_status_changed',
'description',
'download_count',
'extension_id',
'is_listed',
'name',
'pk',
'slug',
'status',
'support',
'team',
'type',
'view_count',
'website',
},
)
self.assertEqual(
log_entry.get_change_message(),
f'Deleted extension “<Extension: Add-on "{extension.name}">”.',
)
# TODO: check that files were deleted from storage (create a temp one prior to the check)
def test_publicly_listed_extension_cannot_be_deleted(self):

View File

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

View File

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

View File

@ -1,3 +1,5 @@
from datetime import timedelta
from django.test import TestCase
from django.urls import reverse
@ -80,6 +82,64 @@ class PublicViewsTest(_BaseTestCase):
self.assertTemplateUsed(response, 'extensions/home.html')
class ApiViewsTest(_BaseTestCase):
def test_blender_version_filter(self):
create_approved_version(blender_version_min='4.0.1')
create_approved_version(blender_version_min='4.1.1')
create_approved_version(blender_version_min='4.2.1')
url = reverse('extensions:api')
json = self.client.get(
url + '?blender_version=4.1.1',
HTTP_ACCEPT='application/json',
).json()
self.assertEqual(len(json['data']), 2)
json2 = self.client.get(
url + '?blender_version=3.0.1',
HTTP_ACCEPT='application/json',
).json()
self.assertEqual(len(json2['data']), 0)
json3 = self.client.get(
url + '?blender_version=4.3.1',
HTTP_ACCEPT='application/json',
).json()
self.assertEqual(len(json3['data']), 3)
def test_blender_version_filter_latest_not_max_version(self):
version = create_approved_version(blender_version_min='4.0.1')
version.date_created
extension = version.extension
create_approved_version(
blender_version_min='4.2.1',
extension=extension,
date_created=version.date_created + timedelta(days=1),
version='2.0.0',
)
create_approved_version(
blender_version_min='3.0.0',
extension=extension,
date_created=version.date_created + timedelta(days=2),
version='1.0.1',
)
create_approved_version(
blender_version_min='4.2.1',
extension=extension,
date_created=version.date_created + timedelta(days=3),
version='2.0.1',
)
url = reverse('extensions:api')
json = self.client.get(
url + '?blender_version=4.1.1',
HTTP_ACCEPT='application/json',
).json()
self.assertEqual(len(json['data']), 1)
# we are expecting the latest matching, not the maximum version
self.assertEqual(json['data'][0]['version'], '1.0.1')
class ExtensionDetailViewTest(_BaseTestCase):
def test_cannot_view_unlisted_extension_anonymously(self):
extension = _create_extension()
@ -229,3 +289,36 @@ class UpdateVersionViewTest(_BaseTestCase):
self.client.force_login(random_user)
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_blender_max_version(self):
extension = _create_extension()
extension_owner = extension.latest_version.file.user
extension.authors.add(extension_owner)
self.client.force_login(extension_owner)
url = reverse(
'extensions:version-update',
kwargs={
'type_slug': extension.type_slug,
'slug': extension.slug,
'pk': extension.latest_version.pk,
},
)
version = extension.latest_version
response = self.client.post(
url,
{'release_notes': 'text', 'blender_version_max': 'invalid'},
)
# error page, no redirect
self.assertEqual(response.status_code, 200)
version.refresh_from_db()
self.assertIsNone(version.blender_version_max)
response2 = self.client.post(
url,
{'release_notes': 'text', 'blender_version_max': '4.2.0'},
)
# success, redirect
self.assertEqual(response2.status_code, 302)
version.refresh_from_db()
self.assertEqual(version.blender_version_max, '4.2.0')

View File

@ -42,38 +42,51 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
self.fail('invalid_version')
def to_representation(self, instance):
blender_version_min = instance.latest_version.blender_version_min
blender_version_max = instance.latest_version.blender_version_max
matching_version = None
# avoid triggering additional db queries, reuse the prefetched queryset
versions = [
v
for v in instance.versions.all()
if v.file and v.file.status in instance.valid_file_statuses
]
if not versions:
return None
versions = sorted(versions, key=lambda v: v.date_created, reverse=True)
if self.blender_version:
for v in versions:
if is_in_version_range(
self.blender_version,
v.blender_version_min,
v.blender_version_max,
):
matching_version = v
break
else:
# same as latest_version, but without triggering a new queryset
matching_version = versions[0]
# TODO: get the latest valid version
# For now we skip the extension if the latest version is not in a valid range.
if self.blender_version and not is_in_version_range(
self.blender_version, blender_version_min, blender_version_max
):
return {}
if not matching_version:
return None
data = {
'id': instance.extension_id,
'schema_version': instance.latest_version.schema_version,
'schema_version': matching_version.schema_version,
'name': instance.name,
'version': instance.latest_version.version,
'tagline': instance.latest_version.tagline,
'archive_hash': instance.latest_version.file.original_hash,
'archive_size': instance.latest_version.file.size_bytes,
'archive_url': self.request.build_absolute_uri(instance.latest_version.download_url),
'version': matching_version.version,
'tagline': matching_version.tagline,
'archive_hash': matching_version.file.original_hash,
'archive_size': matching_version.file.size_bytes,
'archive_url': self.request.build_absolute_uri(matching_version.download_url),
'type': EXTENSION_TYPE_SLUGS_SINGULAR.get(instance.type),
'blender_version_min': instance.latest_version.blender_version_min,
'blender_version_max': instance.latest_version.blender_version_max,
'blender_version_min': matching_version.blender_version_min,
'blender_version_max': matching_version.blender_version_max,
'website': self.request.build_absolute_uri(instance.get_absolute_url()),
'maintainer': str(instance.authors.first()),
'license': [
license_iter.slug for license_iter in instance.latest_version.licenses.all()
],
'permissions': [
permission.slug for permission in instance.latest_version.permissions.all()
],
# avoid triggering additional db queries, reuse the prefetched queryset
'maintainer': str(instance.authors.all()[0]),
'license': [license_iter.slug for license_iter in matching_version.licenses.all()],
'permissions': [permission.slug for permission in matching_version.permissions.all()],
# TODO: handle copyright
'tags': [str(tag) for tag in instance.latest_version.tags.all()],
'tags': [str(tag) for tag in matching_version.tags.all()],
}
return clean_json_dictionary_from_optional_fields(data)
@ -93,10 +106,18 @@ class ExtensionsAPIView(APIView):
)
def get(self, request):
blender_version = request.GET.get('blender_version')
qs = Extension.objects.listed.prefetch_related(
'authors',
'versions',
'versions__file',
'versions__licenses',
'versions__permissions',
'versions__tags',
).all()
serializer = self.serializer_class(
Extension.objects.listed, blender_version=blender_version, request=request, many=True
qs, blender_version=blender_version, request=request, many=True
)
data = serializer.data
data = [e for e in serializer.data if e is not None]
return Response(
{
# TODO implement extension blocking by moderators

View File

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

View File

@ -66,6 +66,14 @@ class DraftMixin:
"""If the extension is incomplete, returns the FinalizeDraftView"""
def dispatch(self, request, *args, **kwargs):
if (
'slug' in kwargs
and Extension.objects.filter(
slug=kwargs['slug'], status=Extension.STATUSES.APPROVED
).first()
):
return super().dispatch(request, *args, **kwargs)
extension = (
Extension.objects.listed_or_authored_by(user_id=self.request.user.pk)
.filter(status=Extension.STATUSES.INCOMPLETE)

View File

@ -42,7 +42,19 @@ class HomeView(ListedExtensionsView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
q = super().get_queryset()
q = (
super()
.get_queryset()
.prefetch_related(
'authors',
'preview_set',
'preview_set__file',
'ratings',
'versions',
'versions__file',
'versions__tags',
)
)
context['addons'] = q.filter(type=EXTENSION_TYPE_CHOICES.BPY).order_by('-average_score')[:8]
context['themes'] = q.filter(type=EXTENSION_TYPE_CHOICES.THEME).order_by('-average_score')[
:8
@ -92,7 +104,15 @@ class SearchView(ListedExtensionsView):
| Q(versions__tags__name__icontains=token)
)
queryset = queryset.filter(search_query).distinct()
return queryset
return queryset.prefetch_related(
'authors',
'preview_set',
'preview_set__file',
'ratings',
'versions',
'versions__file',
'versions__tags',
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@ -109,9 +129,9 @@ class SearchView(ListedExtensionsView):
# Determine which tags to list depending on the context.
if context.get('type'):
tag_type_id = self._get_type_id_by_slug()
context['tags'] = Tag.objects.filter(type=tag_type_id)
context['tags'] = Tag.objects.filter(type=tag_type_id).exclude(versions=None)
elif context.get('tag'):
tag_type_id = context['tag'].type
context['tags'] = Tag.objects.filter(type=tag_type_id)
context['tags'] = Tag.objects.filter(type=tag_type_id).exclude(versions=None)
return context

View File

@ -1,17 +1,29 @@
import logging
from django.conf import settings
from django.contrib import admin
from django.template.loader import render_to_string
import background_task.admin
import background_task.models
from .models import File, FileValidation
import files.signals
logger = logging.getLogger(__name__)
def scan_selected_files(self, request, queryset):
def schedule_scan(self, request, queryset):
"""Scan selected files."""
for instance in queryset:
files.signals.schedule_scan(instance)
def make_thumbnails(self, request, queryset):
"""Make thumbnails for selected files."""
for instance in queryset.filter(type__in=(File.TYPES.IMAGE, File.TYPES.VIDEO)):
files.tasks.make_thumbnails.task_function(file_id=instance.pk)
class FileValidationInlineAdmin(admin.StackedInline):
model = FileValidation
readonly_fields = ('date_created', 'date_modified', 'is_ok', 'results')
@ -27,6 +39,28 @@ class FileValidationInlineAdmin(admin.StackedInline):
@admin.register(File)
class FileAdmin(admin.ModelAdmin):
class Media:
css = {'all': ('files/admin/file.css',)}
def thumbnails(self, obj):
if not obj or not (obj.is_image or obj.is_video):
return ''
try:
context = {'file': obj, 'MEDIA_URL': settings.MEDIA_URL}
return render_to_string('files/admin/thumbnails.html', context)
except Exception:
# Make sure any exception happening here is always logged
# (e.g. admin eats exceptions in ModelAdmin properties, making it hard to debug)
logger.exception('Failed to render thumbnails')
raise
def get_form(self, request, obj=None, **kwargs):
"""Override metadata help text depending on file type."""
if obj and (obj.is_image or obj.is_video):
help_text = 'Additional information about the file, e.g. existing thumbnails.'
kwargs.update({'help_texts': {'metadata': help_text}})
return super().get_form(request, obj, **kwargs)
view_on_site = False
save_on_top = True
@ -48,6 +82,9 @@ class FileAdmin(admin.ModelAdmin):
'date_approved',
'date_status_changed',
'size_bytes',
'thumbnails',
'thumbnail',
'type',
'user',
'original_hash',
'original_name',
@ -59,6 +96,9 @@ class FileAdmin(admin.ModelAdmin):
'^version__extension__name',
'extensions__slug',
'extensions__name',
'original_name',
'hash',
'source',
)
fieldsets = (
@ -67,9 +107,8 @@ class FileAdmin(admin.ModelAdmin):
{
'fields': (
'id',
('source', 'thumbnail'),
('original_name', 'content_type'),
'type',
('source', 'thumbnails', 'thumbnail'),
('type', 'content_type', 'original_name'),
'status',
)
},
@ -99,7 +138,7 @@ class FileAdmin(admin.ModelAdmin):
)
inlines = [FileValidationInlineAdmin]
actions = [scan_selected_files]
actions = [schedule_scan, make_thumbnails]
def is_ok(self, obj):
return obj.validation.is_ok if hasattr(obj, 'validation') else None

View File

@ -7,3 +7,10 @@ class FilesConfig(AppConfig):
def ready(self):
import files.signals # noqa: F401
# Ubuntu 22.04 and earlier don't have WebP in `/etc/mime.types`,
# which makes .webp invalid from standpoint of file upload forms.
# FIXME: remove once the application is running on the next Ubuntu 24.04 LTS
import mimetypes
mimetypes.add_type('image/webp', '.webp', strict=True)

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 common.model_mixins import CreatedModifiedMixin, TrackChangesMixin
from files.utils import get_sha256, guess_mimetype_from_ext
from constants.base import (
FILE_STATUS_CHOICES,
FILE_TYPE_CHOICES,
)
from files.utils import get_sha256, guess_mimetype_from_ext, get_thumbnail_upload_to
from constants.base import FILE_STATUS_CHOICES, FILE_TYPE_CHOICES
import utils
User = get_user_model()
@ -41,15 +38,11 @@ def file_upload_to(instance, filename):
def thumbnail_upload_to(instance, filename):
prefix = 'thumbnails/'
_hash = instance.hash.split(':')[-1]
extension = Path(filename).suffix
path = Path(prefix, _hash[:2], _hash).with_suffix(extension)
return path
return get_thumbnail_upload_to(instance.hash)
class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
track_changes_to_fields = {'status', 'size_bytes', 'hash'}
track_changes_to_fields = {'status', 'size_bytes', 'hash', 'thumbnail', 'metadata'}
TYPES = FILE_TYPE_CHOICES
STATUSES = FILE_STATUS_CHOICES
@ -63,7 +56,8 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
null=True,
blank=True,
max_length=256,
help_text='Image thumbnail in case file is a video',
help_text='Thumbnail generated from uploaded image or video source file',
editable=False,
)
content_type = models.CharField(max_length=256, null=True, blank=True)
type = models.PositiveSmallIntegerField(
@ -203,6 +197,30 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
def get_submit_url(self) -> str:
return self.extension.get_draft_url()
def get_thumbnail_of_size(self, size_key: str) -> str:
"""Return absolute path portion of the URL of a thumbnail of this file.
Fall back to the source file, if no thumbnail is stored.
Log absence of the thumbnail file instead of exploding somewhere in the templates.
"""
# We don't (yet?) have thumbnails for anything other than images and videos.
assert self.is_image or self.is_video, f'File pk={self.pk} is neither image nor video'
try:
path = self.metadata['thumbnails'][size_key]['path']
return self.thumbnail.storage.url(path)
except (KeyError, TypeError):
log.exception(f'File pk={self.pk} is missing thumbnail "{size_key}": {self.metadata}')
return self.source.url
@property
def thumbnail_1080p_url(self) -> str:
return self.get_thumbnail_of_size('1080p')
@property
def thumbnail_360p_url(self) -> str:
return self.get_thumbnail_of_size('360p')
class FileValidation(CreatedModifiedMixin, TrackChangesMixin, models.Model):
track_changes_to_fields = {'is_ok', 'results'}

View File

@ -1,10 +1,12 @@
import logging
from django.db.models.signals import pre_save, post_save, pre_delete
from django.conf import settings
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete
from django.dispatch import receiver
import files.models
import files.tasks
import files.utils
logger = logging.getLogger(__name__)
@ -35,6 +37,55 @@ def _scan_new_file(
schedule_scan(instance)
def schedule_thumbnails(file: files.models.File) -> None:
"""Schedule thumbnail generation for a given file."""
if not file.is_image and not file.is_video:
return
args = {'pk': file.pk, 'type': file.get_type_display()}
logger.info('Scheduling thumbnail generation for file pk=%(pk)s type=%(type)s', args)
verbose_name = f'make thumbnails for "{file.source.name}"'
files.tasks.make_thumbnails(file_id=file.pk, creator=file, verbose_name=verbose_name)
def _schedule_thumbnails_when_created(
sender: object, instance: files.models.File, created: bool, **kwargs: object
) -> None:
if not created:
return
schedule_thumbnails(instance)
def _schedule_thumbnails_when_validated(
sender: object, instance: files.models.FileValidation, created: bool, **kwargs: object
) -> None:
if not created:
return
if not instance.is_ok:
return
# Generate thumbnails if initial scan found no issues
schedule_thumbnails(instance.file)
if settings.REQUIRE_FILE_VALIDATION:
# Only schedule thumbnails when file is validated
post_save.connect(_schedule_thumbnails_when_validated, sender=files.models.FileValidation)
else:
# Schedule thumbnails when a new file is created
post_save.connect(_schedule_thumbnails_when_created, sender=files.models.File)
@receiver(pre_delete, sender=files.models.File)
def _log_file_delete(sender: object, instance: files.models.File, **kwargs: object) -> None:
logger.info('Deleting file pk=%s source=%s', instance.pk, instance.source.name)
@receiver(pre_delete, sender=files.models.FileValidation)
def _log_deletion(sender: object, instance: files.models.File, **kwargs: object) -> None:
instance.record_deletion()
@receiver(post_delete, sender=files.models.File)
def delete_orphaned_files(sender: object, instance: files.models.File, **kwargs: object) -> None:
"""Delete source and thumbnail files from storage when File record is deleted."""
files.utils.delete_file_in_storage(instance.source.name)
files.utils.delete_file_in_storage(instance.thumbnail.name)
files.utils.delete_thumbnails(instance.metadata)

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.is_ok = is_ok
file_validation.save(update_fields={'results', 'is_ok', 'date_modified'})
@background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING})
def make_thumbnails(file_id: int) -> None:
"""Generate thumbnails for a given file, store them in thumbnail and metadata columns."""
file = files.models.File.objects.get(pk=file_id)
args = {'pk': file_id, 'type': file.get_type_display()}
if not file.is_image and not file.is_video:
logger.error('File pk=%(pk)s of type "%(type)s" is neither an image nor a video', args)
return
if settings.REQUIRE_FILE_VALIDATION and not file.validation.is_ok:
logger.error("File pk={pk} is flagged, won't make thumbnails".format(**args))
return
# For an image, source of the thumbnails is the original image
source_path = file.source.path
thumbnail_field = file.thumbnail
unchanged_thumbnail = thumbnail_field.name
if file.is_video:
frame_path = files.utils.get_thumbnail_upload_to(file.hash)
# For a video, source of the thumbnails is a frame extracted with ffpeg
files.utils.extract_frame(source_path, frame_path)
thumbnail_field.name = frame_path
source_path = frame_path
thumbnails = files.utils.make_thumbnails(source_path, file.hash)
if not thumbnail_field.name:
thumbnail_field.name = thumbnails['1080p']['path']
update_fields = set()
if thumbnail_field.name != unchanged_thumbnail:
update_fields.add('thumbnail')
if file.metadata.get('thumbnails') != thumbnails:
file.metadata.update({'thumbnails': thumbnails})
update_fields.add('metadata')
if update_fields:
args['update_fields'] = update_fields
logger.info('Made thumbnails for file pk=%(pk)s, updating %(update_fields)s', args)
file.save(update_fields=update_fields)

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

View File

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

View File

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

112
files/tests/test_tasks.py Normal file
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 files.utils import find_path_by_name, find_exact_path, filter_paths_by_ext
from files.utils import (
extract_frame,
filter_paths_by_ext,
find_exact_path,
find_path_by_name,
get_thumbnail_upload_to,
make_thumbnails,
)
# Reusing test files from the extensions app
TEST_FILES_DIR = Path(__file__).resolve().parent.parent.parent / 'extensions' / 'tests' / 'files'
class UtilsTest(TestCase):
@ -98,3 +112,49 @@ class UtilsTest(TestCase):
]
paths = filter_paths_by_ext(name_list, '.md')
self.assertEqual(list(paths), [])
def test_get_thumbnail_upload_to(self):
for file_hash, kwargs, expected in (
('foobar', {}, 'thumbnails/fo/foobar.png'),
('deadbeef', {'width': None, 'height': None}, 'thumbnails/de/deadbeef.png'),
('deadbeef', {'width': 640, 'height': 360}, 'thumbnails/de/deadbeef_640x360.png'),
):
with self.subTest(file_hash=file_hash, kwargs=kwargs):
self.assertEqual(get_thumbnail_upload_to(file_hash, **kwargs), expected)
@patch('files.utils.resize_image')
def test_make_thumbnails(self, mock_resize_image):
self.assertEqual(
{
'1080p': {'path': 'thumbnails/fo/foobar_1920x1080.png', 'size': [1920, 1080]},
'360p': {'path': 'thumbnails/fo/foobar_640x360.png', 'size': [640, 360]},
},
make_thumbnails(TEST_FILES_DIR / 'test_preview_image_0001.png', 'foobar'),
)
self.assertEqual(len(mock_resize_image.mock_calls), 2)
for expected_size in ([1920, 1080], [640, 360]):
with self.subTest(expected_size=expected_size):
mock_resize_image.assert_any_call(
ANY,
expected_size,
ANY,
output_format='PNG',
quality=83,
optimize=True,
progressive=True,
)
@patch('files.utils.FFmpeg')
def test_extract_frame(self, mock_ffmpeg):
with tempfile.TemporaryDirectory() as output_dir:
extract_frame('path/to/source/video.mp4', output_dir + '/frame.png')
mock_ffmpeg.return_value.option.return_value.input.return_value.output.assert_any_call(
output_dir + '/frame.png', {'ss': '00:00:00.01', 'frames:v': 1, 'update': 'true'}
)
self.assertEqual(len(mock_ffmpeg.mock_calls), 5)
mock_ffmpeg.assert_any_call()
mock_ffmpeg.return_value.option.return_value.input.assert_any_call(
'path/to/source/video.mp4'
)

View File

@ -1,18 +1,26 @@
from pathlib import Path
import datetime
import hashlib
import io
import logging
import mimetypes
import os
import os.path
import tempfile
import toml
import typing
import zipfile
from PIL import Image
from django.conf import settings
from django.core.files.storage import default_storage
from ffmpeg import FFmpeg, FFmpegFileNotFound, FFmpegInvalidCommand, FFmpegError
from lxml import etree
import clamd
import magic
from constants.base import THUMBNAIL_FORMAT, THUMBNAIL_SIZES, THUMBNAIL_QUALITY
logger = logging.getLogger(__name__)
MODULE_DIR = Path(__file__).resolve().parent
THEME_SCHEMA = []
@ -172,3 +180,119 @@ def run_clamdscan(abs_path: str) -> tuple:
result = clamd_socket.instream(f)['stream']
logger.info('File at path=%s scanned: %s', abs_path, result)
return result
def delete_file_in_storage(file_name: str) -> None:
"""Delete file from disk or whatever other default storage."""
if not file_name:
return
if not default_storage.exists(file_name):
logger.warning("%s doesn't exist in storage, nothing to delete", file_name)
else:
logger.info('Deleting %s from storage', file_name)
default_storage.delete(file_name)
def delete_thumbnails(file_metadata: dict) -> None:
"""Read thumbnail paths from given metadata and delete them from storage."""
thumbnails = file_metadata.get('thumbnails', {})
for _, thumb in thumbnails.items():
path = thumb.get('path', '')
if not path:
continue
delete_file_in_storage(path)
def get_thumbnail_upload_to(file_hash: str, width: int = None, height: int = None) -> str:
"""Return a full media path of a thumbnail.
Optionally, append thumbnail dimensions to the file name.
"""
prefix = 'thumbnails/'
_hash = file_hash.split(':')[-1]
thumbnail_ext = THUMBNAIL_FORMAT.lower()
if thumbnail_ext == 'jpeg':
thumbnail_ext = 'jpg'
suffix = f'.{thumbnail_ext}'
size_suffix = f'_{width}x{height}' if width and height else ''
path = Path(prefix, _hash[:2], f'{_hash}{size_suffix}').with_suffix(suffix)
return str(path)
def resize_image(image: Image, size: tuple, output, output_format: str = 'PNG', **output_params):
"""Resize a models.ImageField to a given size and write it into output file."""
start_t = datetime.datetime.now()
source_image = image.convert('RGBA' if output_format == 'PNG' else 'RGB')
source_image.thumbnail(size, Image.LANCZOS)
source_image.save(output, output_format, **output_params)
end_t = datetime.datetime.now()
args = {'source': image, 'size': size, 'time': (end_t - start_t).microseconds / 1000}
logger.info('%(source)s to %(size)s done in %(time)sms', args)
def make_thumbnails(
source_path: str, file_hash: str, output_format: str = THUMBNAIL_FORMAT
) -> dict:
"""Generate thumbnail files for given file and a predefined list of dimensions.
Resulting thumbnail paths a derived from the given file hash and thumbnail sizes.
Return a dict of size keys to output paths of generated thumbnail images.
"""
start_t = datetime.datetime.now()
thumbnails = {}
abs_path = os.path.join(settings.MEDIA_ROOT, source_path)
image = Image.open(abs_path)
for size_key, size in THUMBNAIL_SIZES.items():
w, h = size
output_path = get_thumbnail_upload_to(file_hash, width=w, height=h)
with tempfile.TemporaryFile() as f:
logger.info('Resizing %s to %s (%s)', abs_path, size, output_format)
resize_image(
image,
size,
f,
output_format=THUMBNAIL_FORMAT,
quality=THUMBNAIL_QUALITY,
optimize=True,
progressive=True,
)
logger.info('Saving a thumbnail to %s', output_path)
# Overwrite files instead of allowing storage generate a deduplicating suffix
if default_storage.exists(output_path):
logger.warning('%s exists, overwriting', output_path)
default_storage.delete(output_path)
default_storage.save(output_path, f)
thumbnails[size_key] = {'size': size, 'path': output_path}
image.close()
end_t = datetime.datetime.now()
args = {'source': source_path, 'time': (end_t - start_t).microseconds / 1000}
logger.info('%(source)s done in %(time)sms', args)
return thumbnails
def extract_frame(source_path: str, output_path: str, at_time: str = '00:00:00.01'):
"""Extract a single frame of a video at a given path, write it to the given output path."""
try:
start_t = datetime.datetime.now()
abs_path = os.path.join(settings.MEDIA_ROOT, output_path)
ffmpeg = (
FFmpeg()
.option('y')
.input(source_path)
.output(abs_path, {'ss': at_time, 'frames:v': 1, 'update': 'true'})
)
output_dir = os.path.dirname(abs_path)
if not os.path.isdir(output_dir):
os.mkdir(output_dir)
ffmpeg.execute()
end_t = datetime.datetime.now()
args = {'source': source_path, 'time': (end_t - start_t).microseconds / 1000}
logger.info('%(source)s done in %(time)sms', args)
except (FFmpegError, FFmpegFileNotFound, FFmpegInvalidCommand) as e:
logger.exception(f'Failed to extract a frame: {e.message}, {" ".join(ffmpeg.arguments)}')
raise

View File

@ -28,11 +28,6 @@ class Command(BaseCommand):
logger.info(f'{recipient} has unconfirmed email, skipping')
n.save()
continue
# FIXME test with only internal emails first
if not recipient.email.endswith('@blender.org'):
logger.info('skipping: not an internal email')
n.save()
continue
n.email_sent = True
# first mark as processed, then send: avoid spamming in case of a crash-loop
n.save()

View File

@ -32,9 +32,9 @@ class Notification(models.Model):
def format_email(self):
action = self.action
subject = f'New Activity: {action.actor.full_name} {action.verb} {action.target}'
subject = f'New Activity: {action.actor} {action.verb} {action.target}'
url = self.get_absolute_url()
mesage = f'{action.actor.full_name} {action.verb} {action.target}: {url}'
mesage = f'{action.actor} {action.verb} {action.target}: {url}'
return (subject, mesage)
def get_absolute_url(self):

View File

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

View File

@ -1,22 +1,48 @@
{% extends "common/base.html" %}
{% load i18n %}
{% load common filters i18n %}
{% block page_title %}{% blocktranslate %}Notifications{% endblocktranslate %}{% endblock page_title %}
{% block content %}
<h1>
{% trans 'Notifications' %}
{% if user|unread_notification_count %}
<form class="d-inline" action="{% url 'notifications:notifications-mark-read-all' %}" method="post">
{% csrf_token %}
<button class="btn btn-sm" type="submit">{% trans 'Mark all as read' %}</button>
</form>
{% endif %}
</h1>
{% if notification_list %}
{% for notification in notification_list %}
<div class="row">
{{ notification.action }}
{% if notification.read_at %}
{% else %}
{% blocktranslate %}Mark as read{% endblocktranslate %}
<div class="row mb-2 {% if notification.read_at%}text-muted{% endif %}">
<div class="col">
{{ notification.action.timestamp | naturaltime_compact }}
<a href="{% url 'extensions:by-author' user_id=notification.action.actor.pk %}">
{{ notification.action.actor }}
</a>
{{ notification.action.verb }}
<a href="{{ notification.action.target.get_absolute_url }}">{{ notification.action.target }}</a>
<a href="{{ notification.get_absolute_url }}"><button class="btn btn-sm">{% trans 'View' %}</button></a>
{% if not notification.read_at %}
<form class="d-inline" action="{% url 'notifications:notifications-mark-read' pk=notification.pk %}" method="post">
{% csrf_token %}
<button class="btn btn-sm" type="submit">{% trans 'Mark as read' %}</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p>
{% blocktranslate %}You have no notifications{% endblocktranslate %}
{% trans 'You have no notifications' %}
</p>
{% endif %}
{% endblock content %}

View File

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

View File

@ -11,6 +11,7 @@
with_items:
- clamav-daemon
- clamav-unofficial-sigs
- ffmpeg
- git
- libpq-dev
- nginx-full
@ -48,18 +49,7 @@
tags:
- dotenv
- name: Copying ASGI config files
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: 0644
loop:
- { src: templates/asgi/asgi.service, dest: "/etc/systemd/system/{{ service_name }}.service" }
notify:
- restart service
tags:
- asgi
- gunicorn
- import_tasks: tasks/configure_uwsgi.yaml
- import_tasks: tasks/deploy.yaml

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
service_name: "{{ project_slug }}-{{ env }}"
background_service_name: '{{ service_name }}-background.service'
asgi_module: blender_extensions.asgi:application
django_settings_module: blender_extensions.settings
uwsgi_module: blender_extensions.wsgi:application
max_requests: 1000
max_requests_jitter: 50
port: 8200
@ -21,6 +20,7 @@ dir:
errors: "/var/www/{{ service_name }}/html/errors"
env_file: "{{ dir.source }}/.env"
uwsgi_pid: "{{ dir.source }}/{{ service_name }}.pid"
nginx:
user: www-data

View File

@ -1,5 +1,5 @@
from actstream import action
from django.db.models.signals import pre_save, post_save
from django.db.models.signals import pre_save, post_save, pre_delete
from django.dispatch import receiver
from constants.activity import Verb
@ -40,3 +40,8 @@ def _create_action_from_rating(
action_object=instance,
target=instance.extension,
)
@receiver(pre_delete, sender=Rating)
def _log_deletion(sender: object, instance: Rating, **kwargs: object) -> None:
instance.record_deletion()

View File

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

View File

@ -14,11 +14,11 @@
{{ rating.user }}
</a>
</li>
<li class="me-auto">
<li class="align-items-center d-flex me-auto">
{% with score_percentage=rating.score %}
<a href="{{ extension.get_ratings_url }}?score={{ rating.score }}">
{% include "ratings/components/average.html" with score=rating.score %}
</a>
<a class="stars-helper" href="{{ extension.get_ratings_url }}?score={{ rating.score }}">
{% include "ratings/components/average.html" with score=rating.score %}
</a>
{% endwith %}
</li>
<li>

View File

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

View File

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

View File

@ -1,3 +1,3 @@
-r requirements.txt
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
from extensions.models import Extension
from common.model_mixins import CreatedModifiedMixin
from common.model_mixins import CreatedModifiedMixin, RecordDeletionMixin
from utils import absolutify, send_mail
from constants.base import EXTENSION_TYPE_CHOICES
@ -74,12 +74,13 @@ class ReviewerSubscription(CreatedModifiedMixin, models.Model):
)
class ApprovalActivity(CreatedModifiedMixin, models.Model):
class ApprovalActivity(CreatedModifiedMixin, RecordDeletionMixin, models.Model):
class ActivityType(models.TextChoices):
COMMENT = "COM", _("Comment")
APPROVED = "APR", _("Approved")
AWAITING_CHANGES = "AWC", _("Awaiting Changes")
AWAITING_REVIEW = "AWR", _("Awaiting Review")
UPLOADED_NEW_VERSION = "UNV", _("Uploaded New Version")
user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
extension = models.ForeignKey(

View File

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

View File

@ -6,27 +6,25 @@
{{ extension.name }}
</a>
</td>
<td>
{% if extension.authors.count %}
{% include "extensions/components/authors.html" %}
{% endif %}
</td>
<td>{% include "extensions/components/authors.html" %}</td>
<td title="{{ extension.date_created }}">{{ extension.date_created|naturaltime_compact }}</td>
<td class="d-flex">
<a href="{{ extension.get_review_url }}#activity">
<span>{{ extension.review_activity.all|length }}</span>
<span>{{ stats.count }}</span>
</a>
{% if extension.review_activity.all %}
<a href="{{ extension.get_review_url }}#activity-{{ extension.review_activity.all.last.id }}" class="ms-3">
<span>{{ extension.review_activity.all.last.date_created|naturaltime_compact }}</span>
<a href="{{ extension.get_review_url }}#activity-{{ stats.last_activity.id }}" class="ms-3">
<span>{{ stats.last_activity.date_created|naturaltime_compact }}</span>
</a>
{% endif %}
{% include "files/components/scan_details_flag.html" with file=extension.latest_version.file %}
{% include "files/components/scan_details_flag.html" with suspicious_files=extension.suspicious_files %}
</td>
<td>
<a href="{{ extension.get_review_url }}" class="text-decoration-none">
{% include "common/components/status.html" with object=extension class="d-block" %}
{% with last_type=stats.last_type_display|default:"Awaiting Review" %}
<div class="d-block badge badge-status-{{ last_type|slugify }}">
<i class="i-eye"></i>
<span>{{ last_type }}</span>
</div>
{% endwith %}
</a>
</td>
</tr>

View File

@ -76,12 +76,14 @@
<h3>Previews Pending Approval</h3>
<div class="row">
{% for preview in pending_previews %}
<div class="col-md-3">
<a href="{{ preview.file.source.url }}" class="d-block mb-2" title="{{ preview.caption }}" target="_blank">
<img class="img-fluid rounded" src="{{ preview.file.source.url }}" alt="{{ preview.caption }}">
</a>
{% include "common/components/status.html" with object=preview.file class="d-block" %}
</div>
{% with thumbnail_1080p_url=preview.file.thumbnail_1080p_url %}
<div class="col-md-3">
<a href="{{ preview.file.source.url }}" class="d-block mb-2" title="{{ preview.caption }}" target="_blank">
<img class="img-fluid rounded" src="{{ thumbnail_1080p_url }}" alt="{{ preview.caption }}">
</a>
{% include "common/components/status.html" with object=preview.file class="d-block" %}
</div>
{% endwith %}
{% endfor %}
</div>
</section>
@ -99,8 +101,7 @@
{% for activity in extension.review_activity.all %}
<li id="activity-{{ activity.id }}">
{# All activities except comments. #}
{% if activity.type != 'COM' %}
{% if activity.type in status_change_types %}
<div class="activity-item activity-status-change activity-status-{{ activity.get_type_display|slugify }}">
<i class="activity-icon i-activity-{{ activity.get_type_display|slugify }}"></i>
@ -167,8 +168,9 @@
<div class="d-flex align-items-center">
<div class="btn-row ms-3 w-100 justify-content-end">
{% 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 %}
<button type="submit" id="activity-submit" class="btn btn-primary">
<span>{% trans "Comment" %}</span>
</button>

View File

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

View File

@ -3,13 +3,21 @@ from django.shortcuts import reverse
from common.tests.factories.extensions import create_version
from files.models import File
from reviewers.models import ApprovalActivity
class CommentsViewTest(TestCase):
fixtures = ['licenses']
def setUp(self):
self.default_version = create_version(file__status=File.STATUSES.AWAITING_REVIEW)
version = create_version(file__status=File.STATUSES.AWAITING_REVIEW)
self.default_version = version
ApprovalActivity(
type=ApprovalActivity.ActivityType.COMMENT,
user=version.file.user,
extension=version.extension,
message='test comment',
).save()
# List of extensions under review does not require authentication
def test_list_visibility(self):

View File

@ -4,6 +4,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.list import ListView
from django.views.generic import DetailView, FormView
from django.shortcuts import reverse
import django.forms
from files.models import File
from extensions.models import Extension
@ -12,15 +13,51 @@ from reviewers.models import ApprovalActivity
log = logging.getLogger(__name__)
STATUS_CHANGE_TYPES = [
ApprovalActivity.ActivityType.APPROVED,
ApprovalActivity.ActivityType.AWAITING_CHANGES,
ApprovalActivity.ActivityType.AWAITING_REVIEW,
]
class ApprovalQueueView(ListView):
model = Extension
paginate_by = 100
def get_queryset(self):
return Extension.objects.exclude(status=Extension.STATUSES.APPROVED).order_by(
'-date_created'
qs = (
ApprovalActivity.objects.prefetch_related(
'extension',
'extension__authors',
'extension__versions',
'extension__versions__file',
'extension__versions__file__validation',
)
.order_by('-date_created')
.all()
)
by_extension = {}
result = []
for item in qs:
extension = item.extension
stats = by_extension.get(extension, None)
if not stats:
# this check guarantees that we add a record only once per extension,
# and iterating over qs we get result also ordered by item.date_created
stats = {
'count': 0,
'extension': extension,
'last_activity': None,
'last_type_display': None,
}
by_extension[extension] = stats
result.append(stats)
stats['count'] += 1
if not stats.get('last_activity', None):
stats['last_activity'] = item
if not stats.get('last_type_display', None) and item.type in STATUS_CHANGE_TYPES:
stats['last_type_display'] = item.get_type_display
return result
template_name = 'reviewers/extensions_review_list.html'
@ -35,22 +72,31 @@ class ExtensionsApprovalDetailView(DetailView):
ctx['pending_previews'] = self.object.preview_set.exclude(
file__status=File.STATUSES.APPROVED
)
ctx['status_change_types'] = STATUS_CHANGE_TYPES
if self.request.user.is_authenticated:
form = ctx['comment_form'] = CommentForm()
# Remove 'Approved' status from dropdown it not moderator
if not (self.request.user.is_moderator or self.request.user.is_superuser):
filtered_activity_types = [
t
for t in ApprovalActivity.ActivityType.choices
if t[0]
not in [
# anyone can comment
filtered_activity_types = {ApprovalActivity.ActivityType.COMMENT}
user = self.request.user
if self.object.has_maintainer(user):
filtered_activity_types.add(ApprovalActivity.ActivityType.AWAITING_REVIEW)
if user.is_moderator or user.is_superuser:
filtered_activity_types.update(
[
ApprovalActivity.ActivityType.APPROVED,
ApprovalActivity.ActivityType.AWAITING_CHANGES,
]
]
form.fields['type'].choices = filtered_activity_types
form.fields['type'].widget.choices = filtered_activity_types
)
choices = list(
filter(
lambda c: c[0] in filtered_activity_types, ApprovalActivity.ActivityType.choices
)
)
form.fields['type'].choices = choices
form.fields['type'].widget.choices = choices
if len(choices) == 1:
form.fields['type'].widget = django.forms.HiddenInput()
return ctx

View File

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

View File

@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.db.models.signals import m2m_changed, pre_save
from django.dispatch import receiver
from django.utils.dateparse import parse_datetime
from blender_id_oauth_client import signals as bid_signals
@ -36,7 +37,7 @@ def update_user(
Copy 'full_name' from the received 'oauth_info' and attempt to copy avatar from Blender ID.
"""
instance.full_name = oauth_info.get('full_name') or ''
instance.confirmed_email_at = oauth_info.get('confirmed_email_at')
instance.confirmed_email_at = parse_datetime(oauth_info.get('confirmed_email_at') or '')
instance.save()
bid.copy_avatar_from_blender_id(user=instance)

View File

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

View File

@ -3,9 +3,11 @@
{% include "common/components/nav_link.html" with name="users:my-profile" title="Profile" classes="i-home py-2" %}
{% if user.teams.count %}
{% include "common/components/nav_link.html" with name="teams:list" title="Teams" classes="i-people py-2" %}
{% include "common/components/nav_link.html" with name="teams:list" title="Teams" classes="i-users py-2" %}
{% endif %}
<div class="nav-pills-divider"></div>
{% include "common/components/nav_link.html" with name="users:my-profile-delete" title="Delete account" classes="i-trash py-2" %}
</div>
{% endspaceless %}

View File

@ -11,6 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.db.utils import IntegrityError
from django.http import HttpResponse, HttpResponseBadRequest
from django.http.request import HttpRequest
from django.utils.dateparse import parse_datetime
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@ -107,7 +108,7 @@ def handle_user_modified(payload: Dict[Any, Any]) -> None:
update_fields.add('full_name')
if 'confirmed_email_at' in payload:
user.confirmed_email_at = payload['confirmed_email_at']
user.confirmed_email_at = parse_datetime(payload.get('confirmed_email_at') or '')
update_fields.add('confirmed_email_at')
if update_fields: