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
124 changed files with 2734 additions and 800 deletions
Showing only changes of commit 78bca26239 - Show all commits

View File

@ -61,53 +61,46 @@ log into http://extensions.local:8111/admin/ with `admin`/`admin`.
### Blender ID ### Blender ID
All the configuration of this web app is done via environment variables
(there's only one `settings.py` regardless of an environment).
Blender Extensions, as all other Blender web services, uses Blender ID. Blender Extensions, as all other Blender web services, uses Blender ID.
To configure OAuth login, first create a new OAuth2 application in Blender ID with the following settings: Blender Extensions can also receive Blender ID account modifications such as badge updates
via a webhook.
* Redirect URIs: `http://extensions.local:8111/oauth/authorized` For development, Blender ID's code contains a fixture with an OAuth app and a webhook
* Client type: "Confidential"; that should work without any changes to default configuration.
* Authorization grant type: "Authorization code"; To load this fixture, go to your development Blender ID and run the following:
* Name: "Blender Extensions Dev";
Then copy client ID and secret and save them as `BID_OAUTH_CLIENT` and `BID_OAUTH_SECRET` into a `.env` file: ./manage.py loaddata blender_extensions_devserver
export BID_OAUTH_CLIENT=<CLIENT ID HERE>
export BID_OAUTH_SECRET=<SECRET HERE>
Run the dev server using the following command:
source .env && ./manage.py runserver 8111
#### Webhook
Blender Extensions can receive account modifications such as badge updates via a webhook,
which has to be configured in Blender ID admin separately from the OAuth app.
In Admin Blender-ID API Webhooks click `Add Webhook` and set the following:
* Name: "Blender Extensions Dev";
* URL: `http://extensions.local:8111/webhooks/user-modified/`;
* App: choose the app created in the previous step;
Then copy webhook's secret into the `.env` file as `BID_WEBHOOK_USER_MODIFIED_SECRET`:
export BID_WEBHOOK_USER_MODIFIED_SECRET=<WEBHOOK SECRET HERE>
**N.B.**: the webhook view delegates the actual updating of the user profile **N.B.**: the webhook view delegates the actual updating of the user profile
to a background task, so in order to see the updates locally, start the processing of to a background task, so in order to see the updates locally, start the processing of
tasks using the following: tasks using the following:
source .env && ./manage.py process_tasks ./manage.py process_tasks
#### Blender ID and staging/production #### Blender ID and staging/production
The above steps use local development setup as example. For staging/production, create an OAuth2 application in Blender ID using
For staging/production the steps are the same, the only differences being Admin Blender-ID OAuth2 applications -> Add:
the names of the app and the webhook,
and `http://extensions.local:8111` being replaced with the appropriate base URL. * Redirect URIs: `https://staging.extensions.blender.org/oauth/authorized` (`https://extensions.blender.org` for production);
* Client type: "Confidential";
* Authorization grant type: "Authorization code";
* Name: "Blender Extensions Staging" (or "Blender Extensions" for production);
Copy client ID and secret and save them as `BID_OAUTH_CLIENT` and `BID_OAUTH_SECRET` into a `.env` file:
export BID_OAUTH_CLIENT=<CLIENT ID HERE>
export BID_OAUTH_SECRET=<SECRET HERE>
Create a webhook using Admin Blender-ID API Webhooks > Add:
* Name: "Blender Extensions Staging" (or "Blender Extensions" for production)";
* URL: `https://staging.extensions.blender.org/webhooks/user-modified/` (or `https://extensions.blender.org/webhooks/user-modified/` for production);
* App: choose the app created in the previous step;
Copy webhook's secret into the `.env` file as `BID_WEBHOOK_USER_MODIFIED_SECRET`:
export BID_WEBHOOK_USER_MODIFIED_SECRET=<WEBHOOK SECRET HERE>
## Pre-commit hooks ## Pre-commit hooks

View File

@ -4,3 +4,9 @@ from django.apps import AppConfig
class AbuseConfig(AppConfig): class AbuseConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'abuse' name = 'abuse'
def ready(self):
from actstream import registry
import abuse.signals # noqa: F401
registry.register(self.get_model('AbuseReport'))

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2024-04-18 13:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('abuse', '0004_abusereport_rating_abusereport_type'),
]
operations = [
migrations.AlterField(
model_name='abusereport',
name='type',
field=models.PositiveSmallIntegerField(choices=[(1, 'Extension'), (2, 'User'), (3, 'Rating')], default=1),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.11 on 2024-04-18 08:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('abuse', '0005_alter_abusereport_type'),
]
operations = [
migrations.RemoveField(
model_name='abusereport',
name='date_deleted',
),
]

View File

@ -8,14 +8,14 @@ from django.urls import reverse
from extended_choices import Choices from extended_choices import Choices
from geoip2.errors import GeoIP2Error from geoip2.errors import GeoIP2Error
from constants.base import ABUSE_TYPE, ABUSE_TYPE_EXTENSION, ABUSE_TYPE_REVIEW from constants.base import ABUSE_TYPE, ABUSE_TYPE_EXTENSION, ABUSE_TYPE_RATING
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin
import extensions.fields import extensions.fields
User = get_user_model() User = get_user_model()
class AbuseReport(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Model): class AbuseReport(CreatedModifiedMixin, TrackChangesMixin, models.Model):
TYPE = ABUSE_TYPE TYPE = ABUSE_TYPE
REASONS = Choices( REASONS = Choices(
@ -34,6 +34,7 @@ class AbuseReport(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, mode
) )
# NULL if the reporter is anonymous. # NULL if the reporter is anonymous.
# FIXME? make non-null
reporter = models.ForeignKey( reporter = models.ForeignKey(
User, User,
null=True, null=True,
@ -100,7 +101,7 @@ class AbuseReport(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, mode
reporter_id=user_id, reporter_id=user_id,
extension_id=extension_id, extension_id=extension_id,
rating_id=rating_id, rating_id=rating_id,
type=ABUSE_TYPE_REVIEW, type=ABUSE_TYPE_RATING,
).exists() ).exists()
def get_absolute_url(self): def get_absolute_url(self):

47
abuse/signals.py Normal file
View File

@ -0,0 +1,47 @@
import logging
from actstream import action
from django.db.models.signals import post_save
from django.dispatch import receiver
from abuse.models import AbuseReport
from constants.activity import Verb
from constants.base import (
ABUSE_TYPE_EXTENSION,
ABUSE_TYPE_RATING,
ABUSE_TYPE_USER,
)
logger = logging.getLogger(__name__)
@receiver(post_save, sender=AbuseReport)
def _create_action_from_report(
sender: object,
instance: AbuseReport,
created: bool,
raw: bool,
**kwargs: object,
) -> None:
if not created:
return
if raw:
return
if instance.type == ABUSE_TYPE_EXTENSION:
verb = Verb.REPORTED_EXTENSION
elif instance.type == ABUSE_TYPE_RATING:
verb = Verb.REPORTED_RATING
elif instance.type == ABUSE_TYPE_USER:
# TODO?
return
else:
logger.warning(f'ignoring an unexpected AbuseReport type={instance.type}')
return
action.send(
instance.reporter,
verb=verb,
target=instance.extension,
action_object=instance,
)

21
abuse/tests/test_abuse.py Normal file
View File

@ -0,0 +1,21 @@
from django.test import TestCase
from common.tests.factories.extensions import create_approved_version
from common.tests.factories.users import UserFactory
POST_DATA = {
'message': 'test message',
'reason': '127',
'version': '',
}
class ReportTest(TestCase):
def test_report_twice(self):
version = create_approved_version()
user = UserFactory()
self.client.force_login(user)
url = version.extension.get_report_url()
_ = self.client.post(url, POST_DATA)
response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200)

View File

@ -5,10 +5,10 @@ from django.http import Http404
from django.views.generic import DetailView from django.views.generic import DetailView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.views.generic.edit import CreateView from django.views.generic.edit import CreateView
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404, redirect
from .forms import ReportForm from .forms import ReportForm
from constants.base import ABUSE_TYPE_EXTENSION, ABUSE_TYPE_REVIEW from constants.base import ABUSE_TYPE_EXTENSION, ABUSE_TYPE_RATING
from abuse.models import AbuseReport from abuse.models import AbuseReport
from ratings.models import Rating from ratings.models import Rating
from extensions.models import Extension, Version from extensions.models import Extension, Version
@ -37,15 +37,20 @@ class ReportList(
class ReportExtensionView( class ReportExtensionView(
LoginRequiredMixin, LoginRequiredMixin,
extensions.views.mixins.ListedExtensionMixin, extensions.views.mixins.ListedExtensionMixin,
UserPassesTestMixin,
CreateView, CreateView,
): ):
model = AbuseReport model = AbuseReport
form_class = ReportForm form_class = ReportForm
def test_func(self) -> bool: def get(self, request, *args, **kwargs):
# TODO: best to redirect to existing report or show a friendly message extension = get_object_or_404(Extension.objects.listed, slug=self.kwargs['slug'])
return not AbuseReport.exists(user_id=self.request.user.pk, extension_id=self.extension.id) report = AbuseReport.objects.filter(
reporter_id=self.request.user.pk, extension_id=extension.id
).first()
if report is not None:
return redirect('abuse:view-report', pk=report.pk)
else:
return super().get(request, *args, **kwargs)
def form_valid(self, form): def form_valid(self, form):
"""Link newly created rating to latest version and current user.""" """Link newly created rating to latest version and current user."""
@ -104,7 +109,7 @@ class ReportReviewView(
form.instance.extension = self.extension form.instance.extension = self.extension
form.instance.rating = self.rating form.instance.rating = self.rating
form.instance.extension_version = self.version.version form.instance.extension_version = self.version.version
form.instance.type = ABUSE_TYPE_REVIEW form.instance.type = ABUSE_TYPE_RATING
return super().form_valid(form) return super().form_valid(form)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):

View File

@ -1,5 +1,4 @@
""" """Django settings for blender_extensions project.
Django settings for blender_extensions project.
Generated by 'django-admin startproject' using Django 4.0.6. Generated by 'django-admin startproject' using Django 4.0.6.
@ -55,6 +54,7 @@ INSTALLED_APPS = [
'common', 'common',
'files', 'files',
'loginas', 'loginas',
'notifications',
'pipeline', 'pipeline',
'ratings', 'ratings',
'rangefilter', 'rangefilter',
@ -74,6 +74,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.flatpages', 'django.contrib.flatpages',
'django.contrib.humanize', 'django.contrib.humanize',
'actstream',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -144,7 +145,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC' TIME_ZONE = 'Europe/Amsterdam'
USE_I18N = True USE_I18N = True
@ -173,6 +174,30 @@ STATICFILES_STORAGE = 'pipeline.storage.PipelineManifestStorage'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'default': {'format': '%(asctime)-15s %(levelname)8s %(name)s %(message)s'},
'verbose': {
'format': (
'%(asctime)s %(levelname)8s [%(filename)s:%(lineno)d '
'%(funcName)s] %(name)s %(message)s '
),
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
},
'loggers': {
'django': {'level': 'INFO'},
},
'root': {'level': 'INFO', 'handlers': ['console']},
}
PIPELINE = { PIPELINE = {
'JS_COMPRESSOR': 'pipeline.compressors.jsmin.JSMinCompressor', 'JS_COMPRESSOR': 'pipeline.compressors.jsmin.JSMinCompressor',
'CSS_COMPRESSOR': 'pipeline.compressors.NoopCompressor', 'CSS_COMPRESSOR': 'pipeline.compressors.NoopCompressor',
@ -229,9 +254,13 @@ if SENTRY_DSN:
BLENDER_ID = { BLENDER_ID = {
# MUST end in a slash: # MUST end in a slash:
'BASE_URL': os.environ.get('BID_BASE_URL', 'http://id.local:8000/'), 'BASE_URL': os.environ.get('BID_BASE_URL', 'http://id.local:8000/'),
'OAUTH_CLIENT': os.environ.get('BID_OAUTH_CLIENT'), 'OAUTH_CLIENT': os.environ.get('BID_OAUTH_CLIENT', 'BLENDER-EXTENSIONS-DEV'),
'OAUTH_SECRET': os.environ.get('BID_OAUTH_SECRET'), 'OAUTH_SECRET': os.environ.get(
'WEBHOOK_USER_MODIFIED_SECRET': os.environ.get('BID_WEBHOOK_USER_MODIFIED_SECRET'), 'BID_OAUTH_SECRET', 'DEVELOPMENT-ONLY NON SECRET NEVER USE IN PRODUCTION'
),
'WEBHOOK_USER_MODIFIED_SECRET': os.environ.get(
'BID_WEBHOOK_USER_MODIFIED_SECRET', 'DEVELOPMENT-ONLY NON SECRET NEVER USE IN PRODUCTION'
),
} }
TAGGIT_CASE_INSENSITIVE = True TAGGIT_CASE_INSENSITIVE = True
@ -241,8 +270,6 @@ ACTSTREAM_SETTINGS = {
'FETCH_RELATIONS': True, 'FETCH_RELATIONS': True,
} }
CSRF_FAILURE_VIEW = 'common.views.errors.csrf_failure'
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
} }
@ -259,7 +286,7 @@ SPECTACULAR_SETTINGS = {
} }
# Fallback user for logging # Fallback user for logging
SYSTEM_USER_ID = 1 SYSTEM_USER_ID = os.environ.get('SYSTEM_USER_ID', 1)
if TESTING: if TESTING:
# Avoid "ValueError: Missing staticfiles manifest entry for" # Avoid "ValueError: Missing staticfiles manifest entry for"
@ -294,3 +321,7 @@ EMAIL_HOST = os.getenv('EMAIL_HOST')
EMAIL_PORT = os.getenv('EMAIL_PORT', '587') EMAIL_PORT = os.getenv('EMAIL_PORT', '587')
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER') EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD') EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD')
ACTSTREAM_SETTINGS = {
'MANAGER': 'actstream.managers.ActionManager',
}

View File

@ -39,6 +39,7 @@ urlpatterns = [
path('', include('users.urls')), path('', include('users.urls')),
path('', include('teams.urls')), path('', include('teams.urls')),
path('', include('reviewers.urls')), path('', include('reviewers.urls')),
path('', include('notifications.urls')),
path('api/swagger/', RedirectView.as_view(url='/api/v1/swagger/')), path('api/swagger/', RedirectView.as_view(url='/api/v1/swagger/')),
path('api/v1/', SpectacularAPIView.as_view(), name='schema_v1'), path('api/v1/', SpectacularAPIView.as_view(), name='schema_v1'),
path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'), path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'),
@ -52,7 +53,7 @@ handler404 = common.views.errors.ErrorView.as_view(status=404)
handler500 = common.views.errors.ErrorView.as_view(status=500) handler500 = common.views.errors.ErrorView.as_view(status=500)
if settings.DEBUG: if settings.DEBUG:
urlpatterns += [ urlpatterns = [
re_path( re_path(
r'^media/(?P<path>.*)$', r'^media/(?P<path>.*)$',
serve, serve,
@ -61,4 +62,4 @@ if settings.DEBUG:
}, },
), ),
path('__debug__/', include('debug_toolbar.urls')), path('__debug__/', include('debug_toolbar.urls')),
] ] + urlpatterns

View File

@ -212,9 +212,6 @@ class LogEntryAdmin(admin.ModelAdmin):
def has_change_permission(self, request, obj=None): def has_change_permission(self, request, obj=None):
return False return False
def has_delete_permission(self, request, obj=None):
return False
def has_view_permission(self, request, obj=None): def has_view_permission(self, request, obj=None):
return request.user.is_superuser return request.user.is_superuser

View File

@ -2,7 +2,7 @@ from typing import Optional, Dict, List
import json import json
import logging import logging
from django.contrib.admin.models import CHANGE, LogEntry from django.contrib.admin.models import CHANGE, ACTION_FLAG_CHOICES, LogEntry
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
import django.conf import django.conf
import django.db.models import django.db.models
@ -10,6 +10,7 @@ import django.db.models
from common.middleware import threadlocal from common.middleware import threadlocal
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ACTION_FLAG_DISPLAY = {k: v for k, v in ACTION_FLAG_CHOICES}
def attach_log_entry( def attach_log_entry(
@ -32,13 +33,15 @@ def attach_log_entry(
if user_id is None: if user_id is None:
request = threadlocal.get_current_request() request = threadlocal.get_current_request()
if request: # N.B.: request.user.pk can be None after login of a new user
user_id = request.user.pk if request and request.user.is_authenticated else None if request and request.user.is_authenticated and request.user.pk:
user_id = request.user.pk
else: else:
user_id = django.conf.settings.SYSTEM_USER_ID user_id = django.conf.settings.SYSTEM_USER_ID
object_repr = repr(instance) object_repr = repr(instance)
# Check that user refered to by the entry actually exists logger.info('%s of "%s": %s', ACTION_FLAG_DISPLAY[action_flag], object_repr, message)
# Check that user referred to by the entry actually exists
if not User.objects.filter(pk=user_id).exists(): if not User.objects.filter(pk=user_id).exists():
logger.error( logger.error(
'Cannot record change "%s" to %s: user pk=%s does not exist', 'Cannot record change "%s" to %s: user pk=%s does not exist',

View File

@ -1,3 +1,4 @@
import logging
import random import random
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
@ -7,7 +8,7 @@ from common.tests.factories.files import FileFactory
from common.tests.factories.teams import TeamFactory from common.tests.factories.teams import TeamFactory
from files.models import File from files.models import File
from constants.version_permissions import VERSION_PERMISSION_FILE, VERSION_PERMISSION_NETWORK from constants.version_permissions import VERSION_PERMISSION_FILE, VERSION_PERMISSION_NETWORK
from constants.licenses import LICENSE_GPL2 from constants.licenses import LICENSE_GPL2, LICENSE_GPL3
from extensions.models import Extension, Tag from extensions.models import Extension, Tag
FILE_SOURCES = { FILE_SOURCES = {
@ -54,12 +55,22 @@ to setup the `addon preferences`.
... ...
''' '''
LICENSES = (LICENSE_GPL2.id, LICENSE_GPL3.id)
class Command(BaseCommand): class Command(BaseCommand):
help = 'Generate fake data with extensions, users and versions using test factories.' help = 'Generate fake data with extensions, users and versions using test factories.'
def handle(self, *args, **options): def handle(self, *args, **options):
verbosity = int(options['verbosity'])
root_logger = logging.getLogger('root')
if verbosity > 2:
root_logger.setLevel(logging.DEBUG)
elif verbosity > 1:
root_logger.setLevel(logging.INFO)
else:
root_logger.setLevel(logging.WARNING)
tags = { tags = {
type_id: list(Tag.objects.filter(type=type_id).values_list('name', flat=True)) type_id: list(Tag.objects.filter(type=type_id).values_list('name', flat=True))
for type_id, _ in Extension.TYPES for type_id, _ in Extension.TYPES
@ -100,7 +111,7 @@ class Command(BaseCommand):
# Create a few publicly listed extensions # Create a few publicly listed extensions
for i in range(10): for i in range(10):
extension__type = random.choice(Extension.TYPES)[0] extension__type = random.choice(Extension.TYPES)[0]
create_approved_version( version = create_approved_version(
file__status=File.STATUSES.APPROVED, file__status=File.STATUSES.APPROVED,
# extension__status=Extension.STATUSES.APPROVED, # extension__status=Extension.STATUSES.APPROVED,
extension__type=extension__type, extension__type=extension__type,
@ -116,16 +127,20 @@ class Command(BaseCommand):
) )
], ],
) )
for i in range(random.randint(1, len(LICENSES))):
version.licenses.add(LICENSES[i])
# Create a few unlisted extension versions # Create a few unlisted extension versions
for i in range(5): for i in range(5):
extension__type = random.choice(Extension.TYPES)[0] extension__type = random.choice(Extension.TYPES)[0]
create_version( version = create_version(
file__status=random.choice( file__status=random.choice(
(File.STATUSES.DISABLED, File.STATUSES.DISABLED_BY_AUTHOR) (File.STATUSES.DISABLED, File.STATUSES.DISABLED_BY_AUTHOR)
), ),
tags=random.sample(tags[extension__type], k=1), tags=random.sample(tags[extension__type], k=1),
) )
for i in range(random.randint(1, len(LICENSES))):
version.licenses.add(LICENSES[i])
example_version.extension.average_score = 5.0 example_version.extension.average_score = 5.0
example_version.extension.save(update_fields={'average_score'}) example_version.extension.save(update_fields={'average_score'})

View File

@ -3,7 +3,7 @@ import copy
import logging import logging
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models, transaction from django.db import models
from django.shortcuts import reverse from django.shortcuts import reverse
from django.utils import timezone from django.utils import timezone
@ -59,19 +59,23 @@ class TrackChangesMixin(models.Model):
track_changes_to_fields: Set[str] track_changes_to_fields: Set[str]
def _compare(self, old_instance: object) -> bool: def _was_modified(self, old_instance: object, update_fields=None) -> bool:
"""Returns True if model fields have changed. """Returns True if the record is modified.
Only checks fields listed in self.track_changes_to_fields. Only checks fields listed in self.track_changes_to_fields.
""" """
for field in self.track_changes_to_fields: for field in self.track_changes_to_fields:
# If update_fields was given and this field was NOT in it,
# its value definitely won't be changed:
if update_fields is not None and field not in update_fields:
continue
old_val = getattr(old_instance, field, ...) old_val = getattr(old_instance, field, ...)
new_val = getattr(self, field, ...) new_val = getattr(self, field, ...)
if old_val != new_val: if old_val != new_val:
return True return True
return False return False
def pre_save_record(self) -> Tuple[bool, OldStateType]: def pre_save_record(self, *args, **kwargs) -> Tuple[bool, OldStateType]:
"""Tracks the previous state of this object. """Tracks the previous state of this object.
Only records fields listed in self.track_changes_to_fields. Only records fields listed in self.track_changes_to_fields.
@ -86,7 +90,8 @@ class TrackChangesMixin(models.Model):
except type(self).DoesNotExist: except type(self).DoesNotExist:
return True, {} return True, {}
was_modified = self._compare(db_instance) update_fields = kwargs.get('update_fields')
was_modified = self._was_modified(db_instance, update_fields=update_fields)
old_instance_data = { old_instance_data = {
attr: copy.deepcopy(getattr(db_instance, attr)) for attr in self.track_changes_to_fields attr: copy.deepcopy(getattr(db_instance, attr)) for attr in self.track_changes_to_fields
} }
@ -147,43 +152,3 @@ class TrackChangesMixin(models.Model):
self.name = markdown.sanitize(self.name) self.name = markdown.sanitize(self.name)
if update_fields is not None: if update_fields is not None:
kwargs['update_fields'] = kwargs['update_fields'].union({'name'}) kwargs['update_fields'] = kwargs['update_fields'].union({'name'})
class SoftDeleteMixin(models.Model):
"""Model with soft-deletion functionality."""
class Meta:
abstract = True
date_deleted = models.DateTimeField(null=True, blank=True, editable=False)
@property
def is_deleted(self) -> bool:
return self.date_deleted is not None
@transaction.atomic
def delete(self, hard=False):
if hard:
super().delete()
else:
self.date_deleted = timezone.now()
self.save()
if hasattr(self, 'file'):
# .file should always exist but we don't want to break delete regardless
self.file.delete()
logger.warning('%r pk=%r deleted', self.__class__, self.pk)
def delete_queryset(self, request, queryset):
"""Given a queryset, soft-delete it from the database."""
queryset.update(date_deleted=timezone.now())
def undelete(self, save=True):
if not self.date_deleted:
logger.warning('%r pk=%r is not deleted, cannot undelete', self.__class__, self.pk)
return
self.date_deleted = None
if save:
self.save()
logger.warning('%r pk=%r deleted', self.__class__, self.pk)

View File

@ -40,6 +40,12 @@ $container-width: map-get($container-max-widths, 'xl')
+media-xs +media-xs
width: 60px width: 60px
/* Temporarily here until it can be moved to web-assets v2. */
.nav-global-links-right
gap: 0 var(--spacer-2)
.navbar-search
margin: 0
.navbar-search .navbar-search
width: 160px width: 160px

View File

@ -135,7 +135,7 @@
{% if user.is_authenticated %} {% if user.is_authenticated %}
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<button id="navbarDropdown" aria-expanded="false" aria-haspopup="true" data-toggle-menu-id="nav-account-dropdown" role="button" class="nav-link dropdown-toggle js-dropdown-toggle"> <button id="navbarDropdown" aria-expanded="false" aria-haspopup="true" data-toggle-menu-id="nav-account-dropdown" role="button" class="nav-link dropdown-toggle js-dropdown-toggle">
<span>{% include "users/components/profile_display.html" with user=user %}</span> <i class="i-user"></i>
<i class="i-chevron-down"></i> <i class="i-chevron-down"></i>
</button> </button>
<ul id="nav-account-dropdown" aria-labelledby="navbarDropdown" class="dropdown-menu dropdown-menu-right js-dropdown-menu"> <ul id="nav-account-dropdown" aria-labelledby="navbarDropdown" class="dropdown-menu dropdown-menu-right js-dropdown-menu">

View File

@ -1,10 +1,7 @@
from urllib.parse import urljoin, urlparse from urllib.parse import urlparse
import json import json
import logging import logging
import os
from django.conf import settings
from django.contrib.sites.shortcuts import get_current_site
from django.template import Library, loader from django.template import Library, loader
from django.template.defaultfilters import stringfilter from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -23,47 +20,11 @@ register = Library()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@register.filter
def absolutify(url: str, request=None) -> str:
"""Return an absolute URL."""
if url and url.startswith(('http://', 'https://')):
return url
proto = 'http' if settings.DEBUG else 'https'
return urljoin(f'{proto}://', get_current_site(request).domain, url)
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def absolute_url(context, path: str) -> str: def absolute_url(context, path: str) -> str:
"""Return an absolute URL of a given path.""" """Return an absolute URL of a given path."""
request = context.get('request') request = context.get('request')
return absolutify(path, request=request) return utils.absolutify(path, request=request)
# A (temporary?) copy of this is in services/utils.py. See bug 1055654.
def user_media_path(what):
"""Make it possible to override storage paths in settings.
By default, all storage paths are in the MEDIA_ROOT.
This is backwards compatible.
"""
default = os.path.join(settings.MEDIA_ROOT, what)
key = f'{what.upper()}_PATH'
return getattr(settings, key, default)
# A (temporary?) copy of this is in services/utils.py. See bug 1055654.
def user_media_url(what):
"""
Generate default media url, and make possible to override it from
settings.
"""
default = f'{settings.MEDIA_URL}{what}/'
key = '{}_URL'.format(what.upper().replace('-', '_'))
return getattr(settings, key, default)
class PaginationRenderer: class PaginationRenderer:

View File

@ -94,4 +94,5 @@ def create_version(**kwargs) -> 'Version':
def create_approved_version(**kwargs) -> 'Version': def create_approved_version(**kwargs) -> 'Version':
version = create_version(**kwargs) version = create_version(**kwargs)
version.extension.approve() version.extension.approve()
version.refresh_from_db()
return version return version

View File

@ -1,6 +1,7 @@
import random import random
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from factory.django import DjangoModelFactory from factory.django import DjangoModelFactory
import factory import factory
@ -22,6 +23,8 @@ class OAuthUserTokenFactory(DjangoModelFactory):
class Meta: class Meta:
model = OAuthToken model = OAuthToken
oauth_user_id = factory.Sequence(lambda n: n + 899999)
user = factory.SubFactory('common.tests.factories.users.UserFactory') user = factory.SubFactory('common.tests.factories.users.UserFactory')
@ -40,3 +43,10 @@ class UserFactory(DjangoModelFactory):
oauth_tokens = factory.RelatedFactoryList(OAuthUserTokenFactory, factory_related_name='user') oauth_tokens = factory.RelatedFactoryList(OAuthUserTokenFactory, factory_related_name='user')
oauth_info = factory.RelatedFactory(OAuthUserInfoFactory, factory_related_name='user') oauth_info = factory.RelatedFactory(OAuthUserInfoFactory, factory_related_name='user')
def create_moderator():
user = UserFactory()
moderators = Group.objects.get(name='moderators')
user.groups.add(moderators)
return user

View File

@ -22,11 +22,3 @@ class ErrorView(TemplateView):
response = self.render_to_response(context) response = self.render_to_response(context)
response.status_code = self.status response.status_code = self.status
return response return response
def csrf_failure(request, reason=''):
import django.views.csrf
return django.views.csrf.csrf_failure(
request, reason=reason, template_name='errors/403_csrf.html'
)

18
constants/activity.py Normal file
View File

@ -0,0 +1,18 @@
class Verb:
"""These constants are used to dispatch Action records,
changing the values will result in a mismatch with historical values stored in db.
"""
APPROVED = 'approved'
COMMENTED = 'commented'
RATED_EXTENSION = 'rated extension'
REPORTED_EXTENSION = 'reported extension'
REPORTED_RATING = 'reported rating'
REQUESTED_CHANGES = 'requested changes'
REQUESTED_REVIEW = 'requested review'
class Flag:
AUTHOR = 'author'
MODERATOR = 'moderator'
REVIEWER = 'reviewer'

View File

@ -58,8 +58,13 @@ EXTENSION_TYPE_PLURAL = {
EXTENSION_TYPE_CHOICES.THEME: _('Themes'), EXTENSION_TYPE_CHOICES.THEME: _('Themes'),
} }
EXTENSION_SLUGS_PATH = '|'.join(EXTENSION_TYPE_SLUGS.values()) EXTENSION_SLUGS_PATH = '|'.join(EXTENSION_TYPE_SLUGS.values())
EXTENSION_SLUG_TYPES = {v: k for k, v in EXTENSION_TYPE_SLUGS_SINGULAR.items()}
VALID_SOURCE_EXTENSIONS = ('zip',) ALLOWED_EXTENSION_MIMETYPES = ('application/zip', )
# FIXME: this controls the initial widget rendered server-side, and server-side validation
# but not the additional JS-appended preview file inputs.
# If this list changes, the "accept" attribute also has to be updated in appendImageUploadForm.
ALLOWED_PREVIEW_MIMETYPES = ('image/jpg', 'image/jpeg', 'image/png', 'image/webp', 'video/mp4')
# Rating scores # Rating scores
RATING_SCORE_CHOICES = Choices( RATING_SCORE_CHOICES = Choices(
@ -88,10 +93,10 @@ TEAM_ROLE_CHOICES = (
# Abuse # Abuse
ABUSE_TYPE_EXTENSION = 1 ABUSE_TYPE_EXTENSION = 1
ABUSE_TYPE_USER = 2 ABUSE_TYPE_USER = 2
ABUSE_TYPE_REVIEW = 3 ABUSE_TYPE_RATING = 3
ABUSE_TYPE = Choices( ABUSE_TYPE = Choices(
('ABUSE_EXTENSION', ABUSE_TYPE_EXTENSION, "Extension"), ('ABUSE_EXTENSION', ABUSE_TYPE_EXTENSION, "Extension"),
('ABUSE_USER', ABUSE_TYPE_USER, "User"), ('ABUSE_USER', ABUSE_TYPE_USER, "User"),
('ABUSE_REVIEW', ABUSE_TYPE_REVIEW, "Review"), ('ABUSE_RATING', ABUSE_TYPE_RATING, "Rating"),
) )

View File

@ -53,7 +53,6 @@ class ExtensionAdmin(admin.ModelAdmin):
'date_created', 'date_created',
'date_status_changed', 'date_status_changed',
'date_approved', 'date_approved',
'date_deleted',
'date_modified', 'date_modified',
'average_score', 'average_score',
'text_ratings_count', 'text_ratings_count',
@ -76,7 +75,6 @@ class ExtensionAdmin(admin.ModelAdmin):
'date_status_changed', 'date_status_changed',
'date_approved', 'date_approved',
'date_modified', 'date_modified',
'date_deleted',
), ),
'name', 'name',
'slug', 'slug',
@ -184,8 +182,8 @@ class VersionAdmin(admin.ModelAdmin):
class MaintainerAdmin(admin.ModelAdmin): class MaintainerAdmin(admin.ModelAdmin):
model = Maintainer model = Maintainer
list_display = ('extension', 'user', 'date_deleted') list_display = ('extension', 'user')
readonly_fields = ('extension', 'user', 'date_deleted') readonly_fields = ('extension', 'user')
class LicenseAdmin(admin.ModelAdmin): class LicenseAdmin(admin.ModelAdmin):

View File

@ -6,4 +6,7 @@ class ExtensionsConfig(AppConfig):
name = 'extensions' name = 'extensions'
def ready(self): def ready(self):
from actstream import registry
import extensions.signals # noqa: F401 import extensions.signals # noqa: F401
registry.register(self.get_model('Extension'))

View File

@ -1,6 +1,10 @@
import logging import logging
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _
from files.validators import FileMIMETypeValidator
from constants.base import ALLOWED_PREVIEW_MIMETYPES
import extensions.models import extensions.models
import files.models import files.models
@ -33,13 +37,25 @@ EditPreviewFormSet = forms.inlineformset_factory(
class AddPreviewFileForm(forms.ModelForm): class AddPreviewFileForm(forms.ModelForm):
msg_unexpected_file_type = _('Choose a JPEG, PNG or WebP image, or an MP4 video')
class Meta: class Meta:
model = files.models.File model = files.models.File
fields = ('caption', 'source') fields = ('caption', 'source')
widgets = {
'source': forms.ClearableFileInput(attrs={'accept': 'image/*,video/*'}),
}
source = forms.FileField(
allow_empty_file=False,
required=True,
validators=[
FileMIMETypeValidator(
allowed_mimetypes=ALLOWED_PREVIEW_MIMETYPES,
message=msg_unexpected_file_type,
),
],
widget=forms.ClearableFileInput(
attrs={'accept': ','.join(ALLOWED_PREVIEW_MIMETYPES)},
),
)
caption = forms.CharField(max_length=255, required=False) caption = forms.CharField(max_length=255, required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -60,9 +76,6 @@ class AddPreviewFileForm(forms.ModelForm):
): ):
logger.warning('Found an existing %s pk=%s', model, existing_image.pk) logger.warning('Found an existing %s pk=%s', model, existing_image.pk)
self.instance = existing_image self.instance = existing_image
# Undelete the instance, if necessary
if self.instance.is_deleted:
self.instance.undelete(save=False)
# Fill in missing fields from request and the source file # Fill in missing fields from request and the source file
self.instance.user = self.request.user self.instance.user = self.request.user
@ -109,6 +122,12 @@ class ExtensionUpdateForm(forms.ModelForm):
) )
class ExtensionDeleteForm(forms.ModelForm):
class Meta:
model = extensions.models.Extension
fields = []
class VersionForm(forms.ModelForm): class VersionForm(forms.ModelForm):
class Meta: class Meta:
model = extensions.models.Version model = extensions.models.Version
@ -129,7 +148,7 @@ class VersionForm(forms.ModelForm):
return self.initial['file'] return self.initial['file']
class DeleteViewForm(forms.ModelForm): class VersionDeleteForm(forms.ModelForm):
class Meta: class Meta:
model = extensions.models.Version model = extensions.models.Version
fields = [] fields = []

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.11 on 2024-04-18 08:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extensions', '0025_alter_tag_type'),
]
operations = [
migrations.RemoveField(
model_name='extension',
name='date_deleted',
),
migrations.RemoveField(
model_name='maintainer',
name='date_deleted',
),
migrations.RemoveField(
model_name='version',
name='date_deleted',
),
]

View File

@ -10,7 +10,7 @@ from django.db.models import F, Q, Count
from django.urls import reverse from django.urls import reverse
from common.fields import FilterableManyToManyField from common.fields import FilterableManyToManyField
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin
from constants.base import ( from constants.base import (
AUTHOR_ROLE_CHOICES, AUTHOR_ROLE_CHOICES,
AUTHOR_ROLE_DEV, AUTHOR_ROLE_DEV,
@ -106,36 +106,27 @@ class License(CreatedModifiedMixin, models.Model):
class ExtensionManager(models.Manager): class ExtensionManager(models.Manager):
@property
def exclude_deleted(self):
return self.filter(date_deleted__isnull=True)
@property @property
def listed(self): def listed(self):
return self.exclude_deleted.filter( return self.filter(
status=self.model.STATUSES.APPROVED, status=self.model.STATUSES.APPROVED,
is_listed=True, is_listed=True,
) )
@property @property
def unlisted(self): def unlisted(self):
return self.exclude_deleted.exclude(status=self.model.STATUSES.APPROVED) return self.exclude(status=self.model.STATUSES.APPROVED)
def authored_by(self, user_id: int): def authored_by(self, user_id: int):
return self.exclude_deleted.filter( return self.filter(maintainer__user_id=user_id)
maintainer__user_id=user_id, maintainer__date_deleted__isnull=True
)
def listed_or_authored_by(self, user_id: int): def listed_or_authored_by(self, user_id: int):
return self.exclude_deleted.filter( return self.filter(
Q(status=self.model.STATUSES.APPROVED) Q(status=self.model.STATUSES.APPROVED) | Q(maintainer__user_id=user_id)
| Q(maintainer__user_id=user_id, maintainer__date_deleted__isnull=True)
).distinct() ).distinct()
class Extension( class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model):
CreatedModifiedMixin, RatingMixin, TrackChangesMixin, SoftDeleteMixin, models.Model
):
track_changes_to_fields = { track_changes_to_fields = {
'status', 'status',
'name', 'name',
@ -175,7 +166,6 @@ class Extension(
User, User,
through='Maintainer', through='Maintainer',
related_name='extensions', related_name='extensions',
q_filter=Q(maintainer__date_deleted__isnull=True),
) )
team = models.ForeignKey('teams.Team', null=True, blank=True, on_delete=models.SET_NULL) team = models.ForeignKey('teams.Team', null=True, blank=True, on_delete=models.SET_NULL)
@ -195,6 +185,10 @@ class Extension(
def type_slug(self) -> str: def type_slug(self) -> str:
return EXTENSION_TYPE_SLUGS[self.type] return EXTENSION_TYPE_SLUGS[self.type]
@property
def status_slug(self) -> str:
return utils.slugify(EXTENSION_STATUS_CHOICES[self.status - 1][1])
def clean(self) -> None: def clean(self) -> None:
if not self.slug: if not self.slug:
self.slug = utils.slugify(self.name) self.slug = utils.slugify(self.name)
@ -230,15 +224,35 @@ class Extension(
self.status = self.STATUSES.APPROVED self.status = self.STATUSES.APPROVED
self.save() self.save()
@property
def cannot_be_deleted_reasons(self) -> List[str]:
"""Return a list of reasons why this extension cannot be deleted."""
reasons = []
if self.is_listed:
reasons.append('is_listed')
if self.ratings.count() > 0:
reasons.append('has_ratings')
if self.abusereport_set.count() > 0:
reasons.append('has_abuse_reports')
for v in self.versions.all():
reasons.extend(v.cannot_be_deleted_reasons)
return reasons
def get_absolute_url(self): def get_absolute_url(self):
return reverse('extensions:detail', args=[self.type_slug, self.slug]) return reverse('extensions:detail', args=[self.type_slug, self.slug])
def get_draft_url(self):
return reverse('extensions:draft', args=[self.type_slug, self.slug])
def get_manage_url(self): def get_manage_url(self):
return reverse('extensions:manage', args=[self.type_slug, self.slug]) return reverse('extensions:manage', args=[self.type_slug, self.slug])
def get_manage_versions_url(self): def get_manage_versions_url(self):
return reverse('extensions:manage-versions', args=[self.type_slug, self.slug]) return reverse('extensions:manage-versions', args=[self.type_slug, self.slug])
def get_delete_url(self):
return reverse('extensions:delete', args=[self.type_slug, self.slug])
def get_new_version_url(self): def get_new_version_url(self):
return reverse('extensions:new-version', args=[self.type_slug, self.slug]) return reverse('extensions:new-version', args=[self.type_slug, self.slug])
@ -276,7 +290,6 @@ class Extension(
"""Retrieve the latest version.""" """Retrieve the latest version."""
return ( return (
self.versions.filter( self.versions.filter(
date_deleted__isnull=True,
file__status__in=self.valid_file_statuses, file__status__in=self.valid_file_statuses,
file__isnull=False, file__isnull=False,
) )
@ -293,7 +306,7 @@ class Extension(
If the add-on has not been created yet or is deleted, it returns None. If the add-on has not been created yet or is deleted, it returns None.
""" """
if not self.id or self.is_deleted: if not self.id:
return None return None
try: try:
return self.version return self.version
@ -303,14 +316,9 @@ class Extension(
def can_request_review(self): def can_request_review(self):
"""Return whether an add-on can request a review or not.""" """Return whether an add-on can request a review or not."""
if ( if self.is_disabled or self.status in (
self.is_deleted self.STATUSES.APPROVED,
or self.is_disabled self.STATUSES.AWAITING_REVIEW,
or self.status
in (
self.STATUSES.APPROVED,
self.STATUSES.AWAITING_REVIEW,
)
): ):
return False return False
@ -319,7 +327,7 @@ class Extension(
return latest_version is not None and not latest_version.file.reviewed return latest_version is not None and not latest_version.file.reviewed
@property @property
def is_approved(self) -> str: def is_approved(self) -> bool:
return self.status == self.STATUSES.APPROVED return self.status == self.STATUSES.APPROVED
@property @property
@ -341,9 +349,7 @@ class Extension(
"""Return True if given user is listed as a maintainer.""" """Return True if given user is listed as a maintainer."""
if user is None or user.is_anonymous: if user is None or user.is_anonymous:
return False return False
return self.authors.filter( return self.authors.filter(maintainer__user_id=user.pk).exists()
maintainer__user_id=user.pk, maintainer__date_deleted__isnull=True
).exists()
def can_rate(self, user) -> bool: def can_rate(self, user) -> bool:
"""Return True if given user can rate this extension. """Return True if given user can rate this extension.
@ -411,17 +417,13 @@ class Tag(CreatedModifiedMixin, models.Model):
class VersionManager(models.Manager): class VersionManager(models.Manager):
@property
def exclude_deleted(self):
return self.filter(date_deleted__isnull=True)
@property @property
def listed(self): def listed(self):
return self.exclude_deleted.filter(file__status=FILE_STATUS_CHOICES.APPROVED) return self.filter(file__status=FILE_STATUS_CHOICES.APPROVED)
@property @property
def unlisted(self): def unlisted(self):
return self.exclude_deleted.exclude(file__status=FILE_STATUS_CHOICES.APPROVED) return self.exclude(file__status=FILE_STATUS_CHOICES.APPROVED)
def update_or_create(self, *args, **kwargs): def update_or_create(self, *args, **kwargs):
# Stash the ManyToMany to be created after the Version has a valid ID already # Stash the ManyToMany to be created after the Version has a valid ID already
@ -438,11 +440,10 @@ class VersionManager(models.Manager):
return version, result return version, result
class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, SoftDeleteMixin, models.Model): class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model):
track_changes_to_fields = { track_changes_to_fields = {
'blender_version_min', 'blender_version_min',
'blender_version_max', 'blender_version_max',
'date_deleted',
'permissions', 'permissions',
'version', 'version',
'licenses', 'licenses',
@ -565,18 +566,20 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, SoftDeleteMi
def __str__(self) -> str: def __str__(self) -> str:
return f'{self.extension} v{self.version}' return f'{self.extension} v{self.version}'
@property
def is_listed(self): def is_listed(self):
# To be public, a version must not be deleted, must belong to a public # To be public, version file must have a public status.
# extension, and its attached file must have a public status. return self.file is not None and self.file.status == self.file.STATUSES.APPROVED
try:
return ( @property
not self.is_deleted def cannot_be_deleted_reasons(self) -> List[str]:
and self.extension.is_listed """Return a list of reasons why this version cannot be deleted."""
and self.file is not None reasons = []
and self.file.status == self.file.STATUSES.APPROVED if self.is_listed:
) reasons.append('version_is_listed')
except models.ObjectDoesNotExist: if self.ratings.count() > 0:
return False reasons.append('version_has_ratings')
return reasons
@property @property
def pending_rejection(self): def pending_rejection(self):
@ -636,7 +639,7 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, SoftDeleteMi
) )
class Maintainer(CreatedModifiedMixin, SoftDeleteMixin, models.Model): class Maintainer(CreatedModifiedMixin, models.Model):
extension = models.ForeignKey(Extension, on_delete=models.CASCADE) extension = models.ForeignKey(Extension, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
role = models.SmallIntegerField(default=AUTHOR_ROLE_DEV, choices=AUTHOR_ROLE_CHOICES) role = models.SmallIntegerField(default=AUTHOR_ROLE_DEV, choices=AUTHOR_ROLE_CHOICES)
@ -660,6 +663,11 @@ class Preview(CreatedModifiedMixin, models.Model):
ordering = ('position', 'date_created') ordering = ('position', 'date_created')
unique_together = [['extension', 'file']] unique_together = [['extension', 'file']]
@property
def cannot_be_deleted_reasons(self) -> List[str]:
"""Return a list of reasons why this preview cannot be deleted."""
return []
class ExtensionReviewerFlags(models.Model): class ExtensionReviewerFlags(models.Model):
extension = models.OneToOneField( extension = models.OneToOneField(

View File

@ -1,20 +1,42 @@
from typing import Union from typing import Union
import logging
from django.db.models.signals import pre_save, post_save, post_delete from actstream.actions import follow, unfollow
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, post_save, pre_delete, post_delete
from django.dispatch import receiver from django.dispatch import receiver
import django.dispatch
from constants.activity import Flag
import extensions.models import extensions.models
import extensions.tasks
import files.models import files.models
version_changed = django.dispatch.Signal() logger = logging.getLogger(__name__)
version_uploaded = django.dispatch.Signal() 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))
@receiver(post_delete, sender=extensions.models.Preview) @receiver(post_delete, sender=extensions.models.Preview)
def _delete_file(sender: object, instance: extensions.models.Preview, **kwargs: object) -> None: @receiver(post_delete, sender=extensions.models.Version)
instance.file.delete() def _delete_file(sender: object, instance: object, **kwargs: object) -> None:
f = instance.file
args = {'f_id': f.pk, 'h': f.hash, 'pk': instance.pk, 'sender': sender, 's': f.source.name}
logger.info('Deleting file pk=%(f_id)s s=%(s)s hash=%(h)s linked to %(sender)s pk=%(pk)s', args)
f.delete()
# TODO: this doesn't mean that the file was deleted from disk
@receiver(pre_save, sender=extensions.models.Extension) @receiver(pre_save, sender=extensions.models.Extension)
@ -22,9 +44,10 @@ def _delete_file(sender: object, instance: extensions.models.Preview, **kwargs:
def _record_changes( def _record_changes(
sender: object, sender: object,
instance: Union[extensions.models.Extension, extensions.models.Version], instance: Union[extensions.models.Extension, extensions.models.Version],
update_fields: object,
**kwargs: object, **kwargs: object,
) -> None: ) -> None:
was_changed, old_state = instance.pre_save_record() was_changed, old_state = instance.pre_save_record(update_fields=update_fields)
if hasattr(instance, 'name'): if hasattr(instance, 'name'):
instance.sanitize('name', was_changed, old_state, **kwargs) instance.sanitize('name', was_changed, old_state, **kwargs)
@ -39,15 +62,10 @@ def _update_search_index(sender, instance, **kw):
pass # TODO: update search index pass # TODO: update search index
def send_notifications(sender=None, instance=None, signal=None, **kw):
pass # TODO: send email notification about new version upload
def extension_should_be_listed(extension): def extension_should_be_listed(extension):
return ( return (
extension.latest_version is not None extension.latest_version is not None
and extension.latest_version.is_listed and extension.latest_version.is_listed
and extension.latest_version.date_deleted is None
and extension.status == extension.STATUSES.APPROVED and extension.status == extension.STATUSES.APPROVED
) )
@ -82,8 +100,69 @@ def _set_is_listed(
if extension.status == extensions.models.Extension.STATUSES.APPROVED and not new_is_listed: if extension.status == extensions.models.Extension.STATUSES.APPROVED and not new_is_listed:
extension.status = extensions.models.Extension.STATUSES.INCOMPLETE extension.status = extensions.models.Extension.STATUSES.INCOMPLETE
logger.info('Extension pk=%s becomes listed', extension.pk)
extension.is_listed = new_is_listed extension.is_listed = new_is_listed
extension.save() extension.save()
version_uploaded.connect(send_notifications, dispatch_uid='send_notifications') @receiver(post_save, sender=extensions.models.Extension)
def _setup_followers(
sender: object,
instance: extensions.models.Extension,
created: bool,
**kwargs: object,
) -> None:
if not created:
return
for user in instance.authors.all():
follow(user, instance, send_action=False, flag=Flag.AUTHOR)
for user in Group.objects.get(name='moderators').user_set.all():
follow(user, instance, send_action=False, flag=Flag.MODERATOR)
@receiver(m2m_changed, sender=extensions.models.Extension.authors.through)
def _update_authors_follow(instance, action, model, reverse, pk_set, **kwargs):
if action not in ['post_add', 'post_remove']:
return
if model == extensions.models.Extension and not reverse:
targets = extensions.models.Extension.objects.filter(pk__in=pk_set)
users = [instance]
else:
targets = [instance]
users = User.objects.filter(pk__in=pk_set)
for user in users:
for extension in targets:
if action == 'post_remove':
unfollow(user, extension, send_action=False, flag=Flag.AUTHOR)
elif action == 'post_add':
follow(user, extension, send_action=False, flag=Flag.AUTHOR)
@receiver(post_save, sender=extensions.models.Preview)
@receiver(post_save, sender=extensions.models.Version)
def _auto_approve_subsequent_uploads(
sender: object,
instance: Union[extensions.models.Preview, extensions.models.Version],
created: bool,
raw: bool,
**kwargs: object,
):
if raw:
return
if not created:
return
if not instance.file_id:
return
# N.B.: currently, subsequent version and preview uploads get approved automatically,
# if extension is currently listed (meaning, it was approved by a human already).
extension = instance.extension
file = instance.file
if extension.is_listed:
file.status = files.models.File.STATUSES.APPROVED
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'})

View File

@ -41,7 +41,7 @@ function appendImageUploadForm() {
<input class="js-input-img-caption form-control" id="${formsetPrefix}-${i}-caption" type="text" maxlength="255" name="${formsetPrefix}-${i}-caption" placeholder="Describe the preview"> <input class="js-input-img-caption form-control" id="${formsetPrefix}-${i}-caption" type="text" maxlength="255" name="${formsetPrefix}-${i}-caption" placeholder="Describe the preview">
</div> </div>
<div class="align-items-center d-flex justify-content-between"> <div class="align-items-center d-flex justify-content-between">
<input accept="image/*" class="form-control js-input-img" id="id_${formsetPrefix}-${i}-source" type="file" name="${formsetPrefix}-${i}-source"> <input accept="image/jpg,image/jpeg,image/png,image/webp,video/mp4" class="form-control js-input-img" id="id_${formsetPrefix}-${i}-source" type="file" name="${formsetPrefix}-${i}-source">
<ul class="pt-0"> <ul class="pt-0">
<li> <li>
<button class="btn btn-link btn-sm js-btn-reset-img-upload-form ps-2 pe-0"><i class="i-refresh"></i> Reset</button> <button class="btn btn-link btn-sm js-btn-reset-img-upload-form ps-2 pe-0"><i class="i-refresh"></i> Reset</button>

View File

@ -0,0 +1,31 @@
{% extends "common/base.html" %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-8 mx-auto my-4">
<div class="box">
<h2>
{% blocktranslate with extension_name=extension_name %} Delete {{ extension_name }}?{% endblocktranslate %}
</h2>
<p>
{% blocktranslate with extension_name=extension_name %}
All extension previews and versions files will be deleted.
{% endblocktranslate %}
</p>
<div class="btn-row-fluid">
<a href="#" class="btn js-btn-back">
<i class="i-cancel"></i>
<span>{% trans 'Cancel' %}</span>
</a>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-block btn-danger">
<i class="i-trash"></i>
<span>{% trans 'Confirm Deletion' %}</span>
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock content %}

View File

@ -5,6 +5,12 @@
{% block page_title %}{{ extension.name }}{% endblock page_title %} {% block page_title %}{{ extension.name }}{% endblock page_title %}
{% block content %} {% block content %}
{% if extension.latest_version %}
{% with latest=extension.latest_version %}
{% include "files/components/scan_details.html" with file=latest.file %}
{% endwith %}
{% endif %}
{% has_maintainer extension as is_maintainer %} {% has_maintainer extension as is_maintainer %}
{% with latest=extension.latest_version %} {% with latest=extension.latest_version %}
@ -58,7 +64,7 @@
{# Permissions #} {# Permissions #}
{% block extension_permissions %} {% block extension_permissions %}
{% if extension.get_type_display|lower == 'add-on' %} {% if extension.type_slug == 'add-on' %}
<hr class="my-4"> <hr class="my-4">
<section id="permissions" class="ext-detail-permissions"> <section id="permissions" class="ext-detail-permissions">
<h2 class="mb-3">{% trans "Permissions" %}</h2> <h2 class="mb-3">{% trans "Permissions" %}</h2>

View File

@ -82,10 +82,23 @@
<section class="card p-3 mt-3"> <section class="card p-3 mt-3">
<div class="btn-col"> <div class="btn-col">
<button type="submit" class="btn btn-primary"> <button type="submit" name="save_draft" class="btn btn-primary btn-warning">
<i class="i-check"></i> <i class="i-check"></i>
<span>{% trans 'Save Draft' %}</span> <span>{% trans 'Save Draft' %}</span>
</button> </button>
<button type="submit" name="submit_draft" class="btn btn-primary">
<i class="i-send"></i>
<span>{% trans 'Submit for Approval' %}</span>
</button>
{% with cannot_be_deleted_reasons=extension.cannot_be_deleted_reasons %}
{% if not cannot_be_deleted_reasons %}
<a href="{{ extension.get_delete_url }}" class="btn btn-danger">
<i class="i-trash"></i>
<span>{% trans 'Delete Extension' %}</span>
</a>
{% endif %}
{% endwith %}
</div> </div>
</section> </section>
</div> </div>

View File

@ -100,21 +100,12 @@
<button id="btn-save" type="submit" class="btn btn-primary"> <button id="btn-save" type="submit" class="btn btn-primary">
<i class="i-check"></i> <i class="i-check"></i>
<span> <span>
{% if extenson.is_approved %}
{% trans 'Save Draft' %}
{% else %}
{% trans 'Save Changes' %} {% trans 'Save Changes' %}
{% endif %}
</span> </span>
</button> </button>
<hr> <hr>
{% if extension.status == extension. %}
<button type="submit" class="btn btn-block disabled" disabled>{% trans 'Mark ready for review' %}</button>
{% elif 'Incomplete' != extension.get_status_display %}
{% endif %}
<a href="{{ extension.get_new_version_url }}" class="btn"> <a href="{{ extension.get_new_version_url }}" class="btn">
<i class="i-upload"></i> <i class="i-upload"></i>
<span>{% trans 'Upload New Version' %}</span> <span>{% trans 'Upload New Version' %}</span>
@ -124,6 +115,11 @@
<span>{% trans 'Version History' %}</span> <span>{% trans 'Version History' %}</span>
</a> </a>
<a href="{{ extension.get_delete_url }}" class="btn btn-danger">
<i class="i-trash"></i>
<span>{% trans 'Delete Extension' %}</span>
</a>
{% if request.user.is_staff %} {% if request.user.is_staff %}
<a href="{% url 'admin:extensions_extension_change' extension.pk %}" class="btn btn-admin"> <a href="{% url 'admin:extensions_extension_change' extension.pk %}" class="btn btn-admin">
<span>Admin</span> <span>Admin</span>

View File

@ -8,9 +8,8 @@
{% blocktranslate with extension_name=extension_name %} Delete {{ extension_name }} {{ version }}?{% endblocktranslate %} {% blocktranslate with extension_name=extension_name %} Delete {{ extension_name }} {{ version }}?{% endblocktranslate %}
</h2> </h2>
<p> <p>
{% blocktranslate with extension_name=extension_name version=version.version %} {% blocktranslate with extension_name=extension_name %}
By deleting <strong>{{ extension_name }} {{ version }}</strong> you will lose the files, Files belonging to this version will be deleted.
download count, reviews as well ratings for this version.
{% endblocktranslate %} {% endblocktranslate %}
</p> </p>
<div class="btn-row-fluid"> <div class="btn-row-fluid">

View File

@ -21,7 +21,7 @@
<div class="row ext-version-history pb-3"> <div class="row ext-version-history pb-3">
<div class="col"> <div class="col">
{% for version in extension.versions.exclude_deleted %} {% for version in extension.versions.all %}
{% if version.is_listed or is_maintainer %} {% if version.is_listed or is_maintainer %}
<details {% if forloop.counter == 1 %}open{% endif %} id="v{{ version.version|slugify }}"> <details {% if forloop.counter == 1 %}open{% endif %} id="v{{ version.version|slugify }}">
<summary> <summary>
@ -103,10 +103,14 @@
<a href="{{ version.update_url }}" class="btn"> <a href="{{ version.update_url }}" class="btn">
<i class="i-edit"></i><span>Edit</span> <i class="i-edit"></i><span>Edit</span>
</a> </a>
<a href="{{ version.get_delete_url }}" class="btn btn-danger"> {% with cannot_be_deleted_reasons=version.cannot_be_deleted_reasons %}
<i class="i-trash"></i> {% if not cannot_be_deleted_reasons %}
<span>{% trans "Delete Version" %}</span> <a href="{{ version.get_delete_url }}" class="btn btn-danger">
</a> <i class="i-trash"></i>
<span>{% trans "Delete Version" %}</span>
</a>
{% endif %}
{% endwith %}
{% endif %} {% endif %}
</div> </div>
</div> </div>

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
wat

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 B

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 B

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 B

After

Width:  |  Height:  |  Size: 327 B

View File

@ -0,0 +1,114 @@
from django.test import TestCase
from common.tests.factories.extensions import create_approved_version, create_version
from common.tests.factories.files import FileFactory
from common.tests.factories.users import UserFactory
import extensions.models
import files.models
class DeleteTest(TestCase):
fixtures = ['dev', 'licenses']
def test_unlisted_unrated_extension_can_be_deleted_by_author(self):
version = create_version(
file__status=files.models.File.STATUSES.AWAITING_REVIEW,
ratings=[],
extension__previews=[
FileFactory(
type=files.models.File.TYPES.IMAGE,
source='images/b0/b03fa981527593fbe15b28cf37c020220c3d83021999eab036b87f3bca9c9168.png',
)
],
)
extension = version.extension
version_file = version.file
self.assertEqual(version_file.get_status_display(), 'Awaiting Review')
self.assertEqual(extension.get_status_display(), 'Incomplete')
self.assertFalse(extension.is_listed)
self.assertEqual(extension.cannot_be_deleted_reasons, [])
preview_file = extension.previews.first()
self.assertIsNotNone(preview_file)
url = extension.get_delete_url()
user = extension.authors.first()
self.client.force_login(user)
response = self.client.post(url)
self.assertEqual(response.status_code, 302)
# All relevant records should have been deleted
with self.assertRaises(extensions.models.Extension.DoesNotExist):
extension.refresh_from_db()
with self.assertRaises(extensions.models.Version.DoesNotExist):
version.refresh_from_db()
with self.assertRaises(files.models.File.DoesNotExist):
version_file.refresh_from_db()
with self.assertRaises(files.models.File.DoesNotExist):
preview_file.refresh_from_db()
self.assertIsNone(extensions.models.Extension.objects.filter(pk=extension.pk).first())
self.assertIsNone(extensions.models.Version.objects.filter(pk=version.pk).first())
self.assertIsNone(files.models.File.objects.filter(pk=version_file.pk).first())
self.assertIsNone(files.models.File.objects.filter(pk=preview_file.pk).first())
# 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):
version = create_approved_version(ratings=[])
self.assertTrue(version.is_listed)
extension = version.extension
self.assertTrue(extension.is_listed)
self.assertEqual(extension.get_status_display(), 'Approved')
self.assertEqual(version.cannot_be_deleted_reasons, ['version_is_listed'])
self.assertEqual(extension.cannot_be_deleted_reasons, ['is_listed', 'version_is_listed'])
url = extension.get_delete_url()
user = extension.authors.first()
self.client.force_login(user)
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
def test_rated_extension_cannot_be_deleted(self):
version = create_version(file__status=files.models.File.STATUSES.AWAITING_REVIEW)
self.assertFalse(version.is_listed)
extension = version.extension
self.assertFalse(extension.is_listed)
self.assertEqual(extension.get_status_display(), 'Incomplete')
self.assertEqual(version.cannot_be_deleted_reasons, ['version_has_ratings'])
self.assertEqual(
extension.cannot_be_deleted_reasons, ['has_ratings', 'version_has_ratings']
)
url = extension.get_delete_url()
user = extension.authors.first()
self.client.force_login(user)
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
def test_reported_extension_cannot_be_deleted(self): # TODO
pass
def test_extension_with_ratings_cannot_be_deleted(self):
version = create_approved_version()
extension = version.extension
self.assertEqual(extension.status, extension.STATUSES.APPROVED)
url = extension.get_delete_url()
user = extension.authors.first()
self.client.force_login(user)
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
def test_random_user_cant_delete(self):
extension = create_approved_version().extension
url = extension.get_delete_url()
user = UserFactory()
self.client.force_login(user)
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
extension.refresh_from_db()

View File

@ -20,10 +20,10 @@ import toml
META_DATA = { META_DATA = {
"id": "an_id", "id": "an_id",
"name": "Theme", "name": "A random add-on",
"tagline": "A theme", "tagline": "An add-on",
"version": "0.1.0", "version": "0.1.0",
"type": "theme", "type": "add-on",
"license": [LICENSE_GPL3.slug], "license": [LICENSE_GPL3.slug],
"blender_version_min": "4.2.0", "blender_version_min": "4.2.0",
"blender_version_max": "4.2.0", "blender_version_max": "4.2.0",
@ -61,7 +61,7 @@ class CreateFileTest(TestCase):
extension__extension_id=extension_id, extension__extension_id=extension_id,
version='0.1.5-alpha+f52258de', version='0.1.5-alpha+f52258de',
file=FileFactory( file=FileFactory(
type=File.TYPES.THEME, type=File.TYPES.BPY,
status=File.STATUSES.APPROVED, status=File.STATUSES.APPROVED,
), ),
) )
@ -74,6 +74,14 @@ class CreateFileTest(TestCase):
version = combined_meta_data.get("version", "0.1.0") version = combined_meta_data.get("version", "0.1.0")
extension_id = combined_meta_data.get("id", "foobar").strip() extension_id = combined_meta_data.get("id", "foobar").strip()
type_slug = combined_meta_data['type']
init_path = None
if type_slug == 'add-on':
# Add the required __init__.py file
init_path = os.path.join(self.temp_directory, '__init__.py')
with open(init_path, 'w') as init_file:
init_file.write('')
with open(manifest_path, "w") as manifest_file: with open(manifest_path, "w") as manifest_file:
toml.dump(combined_meta_data, manifest_file) toml.dump(combined_meta_data, manifest_file)
@ -81,6 +89,10 @@ class CreateFileTest(TestCase):
with zipfile.ZipFile(output_path, "w") as my_zip: with zipfile.ZipFile(output_path, "w") as my_zip:
arcname = f"{extension_id}-{version}/{os.path.basename(manifest_path)}" arcname = f"{extension_id}-{version}/{os.path.basename(manifest_path)}"
my_zip.write(manifest_path, arcname=arcname) my_zip.write(manifest_path, arcname=arcname)
if init_path:
# Write the __init__.py file too
arcname = f"{extension_id}-{version}/{os.path.basename(init_path)}"
my_zip.write(init_path, arcname=arcname)
os.remove(manifest_path) os.remove(manifest_path)
return output_path return output_path
@ -105,14 +117,9 @@ class ValidateManifestTest(CreateFileTest):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertDictEqual( error = response.context['form'].errors.get('source')[0]
response.context['form'].errors, self.assertIn('id-with-hyphens', error)
{ self.assertIn('No hyphens', error)
'source': [
'Invalid id from extension manifest: "id-with-hyphens". No hyphens are allowed.'
]
},
)
def test_validation_manifest_extension_id_spaces(self): def test_validation_manifest_extension_id_spaces(self):
self.assertEqual(Extension.objects.count(), 0) self.assertEqual(Extension.objects.count(), 0)
@ -130,14 +137,8 @@ class ValidateManifestTest(CreateFileTest):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertDictEqual( error = response.context['form'].errors.get('source')[0]
response.context['form'].errors, self.assertIn('"id with spaces"', error)
{
'source': [
'Invalid id from extension manifest: "id with spaces". Use a valid id consisting of Unicode letters, numbers or underscores.'
]
},
)
def test_validation_manifest_extension_id_clash(self): def test_validation_manifest_extension_id_clash(self):
"""Test if we add two extensions with the same extension_id""" """Test if we add two extensions with the same extension_id"""
@ -161,14 +162,9 @@ class ValidateManifestTest(CreateFileTest):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertDictEqual( error = response.context['form'].errors.get('source')[0]
response.context['form'].errors, self.assertIn('blender_kitsu', error)
{ self.assertIn('already being used', error)
'source': [
'The extension id in the manifest ("blender_kitsu") is already being used by another extension.'
]
},
)
def test_validation_manifest_extension_id_mismatch(self): def test_validation_manifest_extension_id_mismatch(self):
"""Test if we try to add a new version to an extension with a mismatched extension_id""" """Test if we try to add a new version to an extension with a mismatched extension_id"""
@ -192,14 +188,10 @@ class ValidateManifestTest(CreateFileTest):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertDictEqual( error = response.context['form'].errors.get('source')[0]
response.context['form'].errors, self.assertIn('non_kitsu', error)
{ self.assertIn('blender_kitsu', error)
'source': [ self.assertIn('doesn\'t match the expected one', error)
'The extension id in the manifest ("non_kitsu") doesn\'t match the expected one for this extension ("blender_kitsu").'
]
},
)
def test_validation_manifest_extension_id_repeated_version(self): def test_validation_manifest_extension_id_repeated_version(self):
"""Test if we try to add a version to an extension without changing the version number""" """Test if we try to add a version to an extension without changing the version number"""
@ -268,6 +260,53 @@ class ValidateManifestTest(CreateFileTest):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
def test_validation_manifest_unsafe_id_hyphen(self):
"""Make sure the error message for id_hyphen is sane
This is not a particular important check, but it uses the same logic of other parts of the code.
So I'm using this test as a way to make sure all this logic is sound
"""
self.assertEqual(Extension.objects.count(), 0)
user = UserFactory()
self.client.force_login(user)
file_data = {
"id": "id-with-hyphens",
}
bad_file = self._create_file_from_data("theme.zip", file_data, self.user)
with open(bad_file, 'rb') as fp:
response = self.client.post(
self._get_submit_url(), {'source': fp, 'agreed_with_terms': True}
)
self.assertEqual(response.status_code, 200)
error = response.context['form'].errors.get('source')
self.assertEqual(len(error), 1)
self.assertIn('"id-with-hyphens"', error[0])
def test_name_left_as_is(self):
user = UserFactory()
self.client.force_login(user)
file_data = {
# If we ever need to restrict content of Extension's name,
# it should be done at the manifest validation step.
"name": "Name. - With Extra spaces and other characters Ж",
}
extension_file = self._create_file_from_data("theme.zip", file_data, self.user)
with open(extension_file, 'rb') as fp:
response = self.client.post(
self._get_submit_url(), {'source': fp, 'agreed_with_terms': True}
)
self.assertEqual(response.status_code, 302)
file = File.objects.first()
extension = file.extension
self.assertEqual(extension.slug, 'an-id')
self.assertEqual(extension.name, 'Name. - With Extra spaces and other characters Ж')
class ValidateManifestFields(TestCase): class ValidateManifestFields(TestCase):
fixtures = ['licenses', 'version_permissions'] fixtures = ['licenses', 'version_permissions']
@ -368,7 +407,7 @@ class ValidateManifestFields(TestCase):
self.assertEqual( self.assertEqual(
e.exception.messages, e.exception.messages,
[ [
'Manifest value error: blender_version_min should be at least "4.2.0"', 'Manifest value error: <code>blender_version_min</code> should be at least "4.2.0"',
], ],
) )
@ -378,7 +417,7 @@ class ValidateManifestFields(TestCase):
self.assertEqual( self.assertEqual(
e.exception.messages, e.exception.messages,
[ [
'Manifest value error: blender_version_min should be at least "4.2.0"', 'Manifest value error: <code>blender_version_min</code> should be at least "4.2.0"',
], ],
) )
@ -395,8 +434,9 @@ class ValidateManifestFields(TestCase):
with self.assertRaises(ValidationError) as e: with self.assertRaises(ValidationError) as e:
ManifestValidator(data) ManifestValidator(data)
message_begin = "Manifest value error: license expects a list of supported licenses, e.g.: ['SPDX:GPL-2.0-or-later']. Visit" message_begin = "Manifest value error: <code>license</code> expects a list of"
self.assertIn(message_begin, e.exception.messages[0]) self.assertIn(message_begin, e.exception.messages[0])
self.assertIn('[\'SPDX:GPL-2.0-or-later\']', e.exception.messages[0])
data['license'] = ['SPDX:GPL-2.0-only'] data['license'] = ['SPDX:GPL-2.0-only']
with self.assertRaises(ValidationError) as e: with self.assertRaises(ValidationError) as e:
@ -417,8 +457,9 @@ class ValidateManifestFields(TestCase):
with self.assertRaises(ValidationError) as e: with self.assertRaises(ValidationError) as e:
ManifestValidator(data) ManifestValidator(data)
message_begin = "Manifest value error: tags expects a list of supported add-on tags, e.g.: ['Animation', 'Sequencer']. Visit" message_begin = "Manifest value error: <code>tags</code> expects a list of"
self.assertIn(message_begin, e.exception.messages[0]) self.assertIn(message_begin, e.exception.messages[0])
self.assertIn('[\'Animation\', \'Sequencer\']', e.exception.messages[0])
data['tags'] = ['Dark'] data['tags'] = ['Dark']
with self.assertRaises(ValidationError) as e: with self.assertRaises(ValidationError) as e:
@ -430,6 +471,7 @@ class ValidateManifestFields(TestCase):
**self.mandatory_fields, **self.mandatory_fields,
**self.optional_fields, **self.optional_fields,
} }
data.pop('permissions')
data['type'] = 'theme' data['type'] = 'theme'
data['tags'] = ['Light'] data['tags'] = ['Light']
@ -439,8 +481,9 @@ class ValidateManifestFields(TestCase):
with self.assertRaises(ValidationError) as e: with self.assertRaises(ValidationError) as e:
ManifestValidator(data) ManifestValidator(data)
message_begin = "Manifest value error: tags expects a list of supported theme tags, e.g.: ['Dark', 'Accessibility']. Visit" message_begin = "Manifest value error: <code>tags</code> expects a list of"
self.assertIn(message_begin, e.exception.messages[0]) self.assertIn(message_begin, e.exception.messages[0])
self.assertIn('[\'Dark\', \'Accessibility\']', e.exception.messages[0])
data['tags'] = ['Render'] data['tags'] = ['Render']
with self.assertRaises(ValidationError) as e: with self.assertRaises(ValidationError) as e:
@ -463,22 +506,30 @@ class ValidateManifestFields(TestCase):
with self.assertRaises(ValidationError) as e: with self.assertRaises(ValidationError) as e:
ManifestValidator(data) ManifestValidator(data)
message_begin = "Manifest value error: permissions expects a list of supported permissions, e.g.: ['files', 'network']. The supported permissions are: " message_begin = "Manifest value error: <code>permissions</code> expects a list of"
self.assertIn(message_begin, e.exception.messages[0]) self.assertIn(message_begin, e.exception.messages[0])
self.assertIn('[\'files\', \'network\']', e.exception.messages[0])
# Check the licenses that will always be around. # Check the permissions that will always be around.
message_end = e.exception.messages[0][len(message_begin) :] message_end = e.exception.messages[0][len(message_begin) :]
self.assertIn('files', message_end) self.assertIn('files', message_end)
self.assertIn('network', message_end) self.assertIn('network', message_end)
# Check for any other license that we may add down the line. # Check for any other permission that we may add down the line.
for permission in VersionPermission.objects.all(): for permission in VersionPermission.objects.all():
self.assertIn(permission.slug, message_end) self.assertIn(permission.slug, message_end)
data['permissions'] = [] data['permissions'] = []
with self.assertRaises(ValidationError) as e: with self.assertRaises(ValidationError) as e:
ManifestValidator(data) ManifestValidator(data)
self.assertEqual(1, len(e.exception.messages))
# Make sure permissions are only defined for add-ons, but fail for themes.
data['permissions'] = ['files']
data['type'] = EXTENSION_TYPE_SLUGS_SINGULAR[EXTENSION_TYPE_CHOICES.THEME]
data['tags'] = []
with self.assertRaises(ValidationError) as e:
ManifestValidator(data)
self.assertEqual(1, len(e.exception.messages)) self.assertEqual(1, len(e.exception.messages))
def test_tagline(self): def test_tagline(self):
@ -516,6 +567,7 @@ class ValidateManifestFields(TestCase):
**self.mandatory_fields, **self.mandatory_fields,
**self.optional_fields, **self.optional_fields,
} }
data.pop('permissions')
# Good cops. # Good cops.
data['type'] = EXTENSION_TYPE_SLUGS_SINGULAR[EXTENSION_TYPE_CHOICES.BPY] data['type'] = EXTENSION_TYPE_SLUGS_SINGULAR[EXTENSION_TYPE_CHOICES.BPY]
@ -539,6 +591,22 @@ class ValidateManifestFields(TestCase):
ManifestValidator(data) ManifestValidator(data)
self.assertEqual(1, len(e.exception.messages)) self.assertEqual(1, len(e.exception.messages))
def test_schema_version(self):
data = {
**self.mandatory_fields,
**self.optional_fields,
}
data['schema_version'] = "1.0.0"
ManifestValidator(data)
data['schema_version'] = "0.0.9"
with self.assertRaises(ValidationError) as e:
ManifestValidator(data)
self.assertEqual(1, len(e.exception.messages))
self.assertIn('0.0.9', e.exception.messages[0])
self.assertIn('not supported', e.exception.messages[0])
class VersionPermissionsTest(CreateFileTest): class VersionPermissionsTest(CreateFileTest):
fixtures = ['licenses', 'version_permissions'] fixtures = ['licenses', 'version_permissions']

View File

@ -29,6 +29,7 @@ EXPECTED_EXTENSION_DATA = {
'size_bytes': 53959, 'size_bytes': 53959,
'tags': ['Sequencer'], 'tags': ['Sequencer'],
'version_str': '0.1.0', 'version_str': '0.1.0',
'slug': 'edit-breakdown',
}, },
'blender_gis-2.2.8.zip': { 'blender_gis-2.2.8.zip': {
'metadata': { 'metadata': {
@ -43,6 +44,7 @@ EXPECTED_EXTENSION_DATA = {
'size_bytes': 434471, 'size_bytes': 434471,
'tags': ['3D View'], 'tags': ['3D View'],
'version_str': '2.2.8', 'version_str': '2.2.8',
'slug': 'blender-gis',
}, },
'amaranth-1.0.8.zip': { 'amaranth-1.0.8.zip': {
'metadata': { 'metadata': {
@ -57,8 +59,30 @@ EXPECTED_EXTENSION_DATA = {
'size_bytes': 72865, 'size_bytes': 72865,
'tags': [], 'tags': [],
'version_str': '1.0.8', 'version_str': '1.0.8',
'slug': 'amaranth',
}, },
} }
EXPECTED_VALIDATION_ERRORS = {
'empty.txt': {'source': ['Only .zip files are accepted.']},
'empty.zip': {'source': ['The submitted file is empty.']},
'invalid-archive.zip': {'source': ['Only .zip files are accepted.']},
'invalid-manifest-path.zip': {
'source': [
'The manifest file should be at the top level of the archive, or one level deep.',
],
},
'invalid-addon-no-init.zip': {
'source': ['An add-on should have an __init__.py file.'],
},
'invalid-addon-dir-no-init.zip': {
'source': ['An add-on should have an __init__.py file.'],
},
'invalid-no-manifest.zip': {
'source': ['The manifest file is missing.'],
},
'invalid-manifest-toml.zip': {'source': ['Could not parse the manifest file.']},
'invalid-theme-multiple-xmls.zip': {'source': ['A theme should have exactly one XML file.']},
}
class SubmitFileTest(TestCase): class SubmitFileTest(TestCase):
@ -75,6 +99,7 @@ class SubmitFileTest(TestCase):
blender_version_min: str, blender_version_min: str,
size_bytes: int, size_bytes: int,
file_hash: str, file_hash: str,
slug: str,
**other_metadata, **other_metadata,
): ):
self.assertEqual(File.objects.count(), 0) self.assertEqual(File.objects.count(), 0)
@ -88,6 +113,9 @@ class SubmitFileTest(TestCase):
self.assertEqual(File.objects.count(), 1) self.assertEqual(File.objects.count(), 1)
file = File.objects.first() file = File.objects.first()
self.assertEqual(response['Location'], file.get_submit_url()) self.assertEqual(response['Location'], file.get_submit_url())
extension = file.extension
self.assertEqual(extension.slug, slug)
self.assertEqual(extension.name, name)
self.assertEqual(file.original_name, file_name) self.assertEqual(file.original_name, file_name)
self.assertEqual(file.size_bytes, size_bytes) self.assertEqual(file.size_bytes, size_bytes)
self.assertEqual(file.original_hash, file_hash) self.assertEqual(file.original_hash, file_hash)
@ -116,33 +144,28 @@ class SubmitFileTest(TestCase):
{'agreed_with_terms': ['This field is required.']}, {'agreed_with_terms': ['This field is required.']},
) )
def test_validation_errors_invalid_extension(self): def test_validation_errors(self):
self.assertEqual(Extension.objects.count(), 0) self.assertEqual(Extension.objects.count(), 0)
user = UserFactory() user = UserFactory()
self.client.force_login(user) self.client.force_login(user)
with open(TEST_FILES_DIR / 'empty.txt', 'rb') as fp: for test_archive, extected_errors in EXPECTED_VALIDATION_ERRORS.items():
response = self.client.post(self.url, {'source': fp, 'agreed_with_terms': True}) with self.subTest(test_archive=test_archive):
with open(TEST_FILES_DIR / test_archive, 'rb') as fp:
response = self.client.post(self.url, {'source': fp, 'agreed_with_terms': True})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertDictEqual( self.assertDictEqual(response.context['form'].errors, extected_errors)
response.context['form'].errors,
{'source': ['File extension “txt” is not allowed. Allowed extensions are: zip.']},
)
def test_validation_errors_empty_file(self): def test_addon_without_top_level_directory(self):
self.assertEqual(Extension.objects.count(), 0) self.assertEqual(Extension.objects.count(), 0)
user = UserFactory() user = UserFactory()
self.client.force_login(user) self.client.force_login(user)
with open(TEST_FILES_DIR / 'empty.zip', 'rb') as fp: with open(TEST_FILES_DIR / 'addon-without-dir.zip', 'rb') as fp:
response = self.client.post(self.url, {'source': fp, 'agreed_with_terms': True}) response = self.client.post(self.url, {'source': fp, 'agreed_with_terms': True})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 302)
self.assertDictEqual(
response.context['form'].errors,
{'source': ['The submitted file is empty.']},
)
def test_theme_file(self): def test_theme_file(self):
self.assertEqual(File.objects.count(), 0) self.assertEqual(File.objects.count(), 0)
@ -224,12 +247,24 @@ class SubmitFinaliseTest(TestCase):
hash=file_data['file_hash'], hash=file_data['file_hash'],
metadata=file_data['metadata'], metadata=file_data['metadata'],
) )
self.version = create_version(
file=self.file,
extension__name=file_data['metadata']['name'],
extension__slug=file_data['metadata']['id'].replace("_", "-"),
extension__website=None,
tagline=file_data['metadata']['tagline'],
version=file_data['metadata']['version'],
blender_version_min=file_data['metadata']['blender_version_min'],
schema_version=file_data['metadata']['schema_version'],
)
def test_get_finalise_addon_redirects_if_anonymous(self): def test_get_finalise_addon_redirects_if_anonymous(self):
response = self.client.post(self.file.get_submit_url(), {}) response = self.client.post(self.file.get_submit_url(), {})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], f'/oauth/login?next=/submit/{self.file.pk}/') self.assertEqual(
response['Location'], f'/oauth/login?next=/add-ons/{self.file.extension.slug}/draft/'
)
def test_get_finalise_addon_not_allowed_if_different_user(self): def test_get_finalise_addon_not_allowed_if_different_user(self):
user = UserFactory() user = UserFactory()
@ -237,7 +272,9 @@ class SubmitFinaliseTest(TestCase):
response = self.client.post(self.file.get_submit_url(), {}) response = self.client.post(self.file.get_submit_url(), {})
self.assertEqual(response.status_code, 403) # Technically this could (should) be a 403, but changing this means changing
# the MaintainedExtensionMixin which is used in multiple places.
self.assertEqual(response.status_code, 404)
def test_post_finalise_addon_validation_errors(self): def test_post_finalise_addon_validation_errors(self):
self.client.force_login(self.file.user) self.client.force_login(self.file.user)
@ -258,8 +295,8 @@ class SubmitFinaliseTest(TestCase):
def test_post_finalise_addon_creates_addon_with_version_awaiting_review(self): def test_post_finalise_addon_creates_addon_with_version_awaiting_review(self):
self.assertEqual(File.objects.count(), 1) self.assertEqual(File.objects.count(), 1)
self.assertEqual(Extension.objects.count(), 0) self.assertEqual(Extension.objects.count(), 1)
self.assertEqual(Version.objects.count(), 0) self.assertEqual(Version.objects.count(), 1)
self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 0) self.assertEqual(File.objects.filter(type=File.TYPES.IMAGE).count(), 0)
self.client.force_login(self.file.user) self.client.force_login(self.file.user)
@ -279,6 +316,8 @@ class SubmitFinaliseTest(TestCase):
'form-0-caption': ['First Preview Caption Text'], 'form-0-caption': ['First Preview Caption Text'],
'form-1-id': '', 'form-1-id': '',
'form-1-caption': ['Second Preview Caption Text'], 'form-1-caption': ['Second Preview Caption Text'],
# Submit for Approval.
'submit_draft': '',
} }
file_name1 = 'test_preview_image_0001.png' file_name1 = 'test_preview_image_0001.png'
file_name2 = 'test_preview_image_0002.png' file_name2 = 'test_preview_image_0002.png'
@ -313,7 +352,7 @@ class SubmitFinaliseTest(TestCase):
self.assertEqual(version.blender_version_max, None) self.assertEqual(version.blender_version_max, None)
self.assertEqual(version.schema_version, '1.0.0') self.assertEqual(version.schema_version, '1.0.0')
self.assertEqual(version.release_notes, data['release_notes']) self.assertEqual(version.release_notes, data['release_notes'])
self.assertEqual(version.file.get_status_display(), 'Approved') self.assertEqual(version.file.get_status_display(), 'Awaiting Review')
# We cannot check for the ManyToMany yet (tags, licences, permissions) # We cannot check for the ManyToMany yet (tags, licences, permissions)
# Check that author can access the page they are redirected to # Check that author can access the page they are redirected to
@ -357,7 +396,7 @@ class NewVersionTest(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertDictEqual( self.assertDictEqual(
response.context['form'].errors, response.context['form'].errors,
{'source': ['File extension “txt” is not allowed. Allowed extensions are: zip.']}, {'source': ['Only .zip files are accepted.']},
) )
def test_validation_errors_empty_file(self): def test_validation_errors_empty_file(self):

View File

@ -100,7 +100,7 @@ class UpdateTest(TestCase):
'form-TOTAL_FORMS': ['2'], 'form-TOTAL_FORMS': ['2'],
} }
file_name1 = 'test_preview_image_0001.png' file_name1 = 'test_preview_image_0001.png'
file_name2 = 'test_preview_image_0002.png' file_name2 = 'test_preview_image_0001.webp'
url = extension.get_manage_url() url = extension.get_manage_url()
user = extension.authors.first() user = extension.authors.first()
self.client.force_login(user) self.client.force_login(user)
@ -134,15 +134,15 @@ class UpdateTest(TestCase):
) )
self.assertEqual( self.assertEqual(
file2.original_hash, file2.original_hash,
'sha256:f8ef448d66e2506055e2586d4cb20dc8758b19cd6e8b052231fcbcad2e2be4b3', 'sha256:213648f19f0cc7ef8e266e87a0a7a66f0079eb80de50d539895466e645137616',
) )
self.assertEqual( self.assertEqual(
file2.hash, 'sha256:f8ef448d66e2506055e2586d4cb20dc8758b19cd6e8b052231fcbcad2e2be4b3' file2.hash, 'sha256:213648f19f0cc7ef8e266e87a0a7a66f0079eb80de50d539895466e645137616'
) )
self.assertEqual(file1.original_name, file_name1) self.assertEqual(file1.original_name, file_name1)
self.assertEqual(file2.original_name, file_name2) self.assertEqual(file2.original_name, file_name2)
self.assertEqual(file1.size_bytes, 1163) self.assertEqual(file1.size_bytes, 1163)
self.assertEqual(file2.size_bytes, 1693) self.assertEqual(file2.size_bytes, 154)
self.assertTrue( self.assertTrue(
file1.source.url.startswith( file1.source.url.startswith(
'/media/images/64/643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340', '/media/images/64/643e15eb6c4831173bbcf71b8c85efc70cf3437321bf2559b39aa5e9acfd5340',
@ -151,10 +151,10 @@ class UpdateTest(TestCase):
self.assertTrue(file1.source.url.endswith('.png')) self.assertTrue(file1.source.url.endswith('.png'))
self.assertTrue( self.assertTrue(
file2.source.url.startswith( file2.source.url.startswith(
'/media/images/f8/f8ef448d66e2506055e2586d4cb20dc8758b19cd6e8b052231fcbcad2e2be4b3', '/media/images/21/213648f19f0cc7ef8e266e87a0a7a66f0079eb80de50d539895466e645137616',
) )
) )
self.assertTrue(file2.source.url.endswith('.png')) self.assertTrue(file2.source.url.endswith('.webp'))
for f in (file1, file2): for f in (file1, file2):
self.assertEqual(f.user_id, user.pk) self.assertEqual(f.user_id, user.pk)
@ -203,3 +203,80 @@ class UpdateTest(TestCase):
response.context['add_preview_formset'].forms[0].errors, response.context['add_preview_formset'].forms[0].errors,
{'source': ['File with this Hash already exists.']}, {'source': ['File with this Hash already exists.']},
) )
def test_post_upload_validation_error_unexpected_preview_format_gif(self):
extension = create_approved_version().extension
data = {
**POST_DATA,
'form-TOTAL_FORMS': ['2'],
}
file_name1 = 'test_preview_image_0001.gif'
file_name2 = 'test_preview_image_0001.png'
url = extension.get_manage_url()
user = extension.authors.first()
self.client.force_login(user)
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1, open(
TEST_FILES_DIR / file_name2, 'rb'
) as fp2:
files = {
'form-0-source': fp1,
'form-1-source': fp2,
}
response = self.client.post(url, {**data, **files})
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.context['add_preview_formset'].forms[0].errors,
{'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']},
)
def test_post_upload_validation_error_unexpected_preview_format_tif(self):
extension = create_approved_version().extension
data = {
**POST_DATA,
'form-TOTAL_FORMS': ['2'],
}
file_name1 = 'test_preview_image_0001.png'
file_name2 = 'test_preview_image_0001.tif'
url = extension.get_manage_url()
user = extension.authors.first()
self.client.force_login(user)
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1, open(
TEST_FILES_DIR / file_name2, 'rb'
) as fp2:
files = {
'form-0-source': fp1,
'form-1-source': fp2,
}
response = self.client.post(url, {**data, **files})
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.context['add_preview_formset'].forms[1].errors,
{'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']},
)
def test_post_upload_validation_error_unexpected_preview_format_renamed_gif(self):
extension = create_approved_version().extension
data = {
**POST_DATA,
'form-TOTAL_FORMS': ['1'],
}
file_name1 = 'test_preview_image_renamed_gif.png'
url = extension.get_manage_url()
user = extension.authors.first()
self.client.force_login(user)
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1:
files = {
'form-0-source': fp1,
}
response = self.client.post(url, {**data, **files})
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.context['add_preview_formset'].forms[0].errors,
{'source': ['Choose a JPEG, PNG or WebP image, or an MP4 video']},
)

View File

@ -52,8 +52,10 @@ class PublicViewsTest(_BaseTestCase):
response = self.client.get(url, HTTP_ACCEPT=HTTP_ACCEPT) response = self.client.get(url, HTTP_ACCEPT=HTTP_ACCEPT)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json') self.assertEqual(response['Content-Type'], 'application/json')
self.assertEqual(len(response.json()), 3) json = response.json()
for _, v in response.json().items(): self.assertEqual(len(json['data']), 3)
for v in json['data']:
self.assertIn('id', v)
self.assertIn('name', v) self.assertIn('name', v)
self.assertIn('tagline', v) self.assertIn('tagline', v)
self.assertIn('version', v) self.assertIn('version', v)
@ -96,27 +98,6 @@ class ExtensionDetailViewTest(_BaseTestCase):
self._check_detail_page(response, extension) self._check_detail_page(response, extension)
def test_cannot_view_deleted_extension_anonymously(self):
extension = _create_extension()
extension.delete()
self.assertTrue(extension.is_deleted)
url = extension.get_absolute_url()
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_can_view_deleted_extension_if_staff(self):
staff_user = UserFactory(is_staff=True)
extension = _create_extension()
extension.delete()
self.assertTrue(extension.is_deleted)
self.client.force_login(staff_user)
response = self.client.get(extension.get_absolute_url())
self._check_detail_page(response, extension)
def test_can_view_unlisted_extension_if_maintaner(self): def test_can_view_unlisted_extension_if_maintaner(self):
extension = _create_extension() extension = _create_extension()
@ -133,6 +114,14 @@ class ExtensionDetailViewTest(_BaseTestCase):
self._check_detail_page(response, extension) self._check_detail_page(response, extension)
def test_can_view_publicly_listed_extension_versions_anonymously(self):
extension = _create_extension()
extension.approve()
response = self.client.get(extension.get_versions_url())
self._check_detail_page(response, extension)
def test_can_view_publicly_listed_extension_ratings_anonymously(self): def test_can_view_publicly_listed_extension_ratings_anonymously(self):
extension = _create_extension() extension = _create_extension()
extension.approve() extension.approve()
@ -157,8 +146,16 @@ class ExtensionManageViewTest(_BaseTestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
def test_cannot_view_manage_extension_page_for_drafts(self):
extension = _create_extension()
response = self.client.get(extension.get_manage_url())
self.assertEqual(response.status_code, 302)
def test_can_view_manage_extension_page_if_maintaner(self): def test_can_view_manage_extension_page_if_maintaner(self):
extension = _create_extension() extension = _create_extension()
extension.approve()
self.client.force_login(extension.authors.first()) self.client.force_login(extension.authors.first())
response = self.client.get(extension.get_manage_url()) response = self.client.get(extension.get_manage_url())
@ -183,7 +180,7 @@ class ListedExtensionsTest(_BaseTestCase):
self.assertEqual(response['Content-Type'], 'application/json') self.assertEqual(response['Content-Type'], 'application/json')
# Basic sanity check to make sure we are getting the result of listed # Basic sanity check to make sure we are getting the result of listed
listed_count = len(response.json()) listed_count = len(response.json()['data'])
self.assertEqual(Extension.objects.listed.count(), listed_count) self.assertEqual(Extension.objects.listed.count(), listed_count)
return listed_count return listed_count
@ -191,25 +188,11 @@ class ListedExtensionsTest(_BaseTestCase):
create_approved_version(extension=self.extension) create_approved_version(extension=self.extension)
self.assertEqual(self._listed_extensions_count(), 1) self.assertEqual(self._listed_extensions_count(), 1)
def test_delete_extension(self):
self.extension.delete()
self.assertEqual(self._listed_extensions_count(), 0)
def test_moderate_extension(self): def test_moderate_extension(self):
self.extension.status = Extension.STATUSES.DISABLED self.extension.status = Extension.STATUSES.DISABLED
self.extension.save() self.extension.save()
self.assertEqual(self._listed_extensions_count(), 0) self.assertEqual(self._listed_extensions_count(), 0)
def test_soft_delete_only_version(self):
self.version.date_deleted = '1994-01-02 0:0:0+00:00'
self.version.save()
self.assertFalse(self.extension.is_listed)
self.assertEqual(self._listed_extensions_count(), 0)
def test_delete_only_version(self):
self.version.delete()
self.assertEqual(self._listed_extensions_count(), 0)
def test_moderate_only_version(self): def test_moderate_only_version(self):
self.version.file.status = File.STATUSES.DISABLED self.version.file.status = File.STATUSES.DISABLED
self.version.file.save() self.version.file.save()

View File

@ -7,7 +7,6 @@ from extensions.views import api, public, submit, manage
app_name = 'extensions' app_name = 'extensions'
urlpatterns = [ urlpatterns = [
path('submit/', submit.UploadFileView.as_view(), name='submit'), path('submit/', submit.UploadFileView.as_view(), name='submit'),
path('submit/<int:pk>/', submit.SubmitFileView.as_view(), name='submit-finalise'),
# TODO: move /accounts pages under its own app when User model is replaced # TODO: move /accounts pages under its own app when User model is replaced
path('accounts/extensions/', manage.ManageListView.as_view(), name='manage-list'), path('accounts/extensions/', manage.ManageListView.as_view(), name='manage-list'),
# FIXME: while there's no profile page, redirect to My Extensions instead # FIXME: while there's no profile page, redirect to My Extensions instead
@ -19,7 +18,6 @@ urlpatterns = [
path('api/v1/extensions/', api.ExtensionsAPIView.as_view(), name='api'), path('api/v1/extensions/', api.ExtensionsAPIView.as_view(), name='api'),
# Public pages # Public pages
path('', public.HomeView.as_view(), name='home'), path('', public.HomeView.as_view(), name='home'),
path('', api.ExtensionsAPIView.as_view(), name='home-api'),
path('search/', public.SearchView.as_view(), name='search'), path('search/', public.SearchView.as_view(), name='search'),
path('author/<int:user_id>/', public.SearchView.as_view(), name='by-author'), path('author/<int:user_id>/', public.SearchView.as_view(), name='by-author'),
path('search/', public.SearchView.as_view(), name='search'), path('search/', public.SearchView.as_view(), name='search'),
@ -35,11 +33,21 @@ urlpatterns = [
rf'^(?P<type_slug>{EXTENSION_SLUGS_PATH})/', rf'^(?P<type_slug>{EXTENSION_SLUGS_PATH})/',
include( include(
[ [
path(
'<slug:slug>/draft/',
manage.DraftExtensionView.as_view(),
name='draft',
),
path( path(
'<slug:slug>/manage/', '<slug:slug>/manage/',
manage.UpdateExtensionView.as_view(), manage.UpdateExtensionView.as_view(),
name='manage', name='manage',
), ),
path(
'<slug:slug>/delete/',
manage.DeleteExtensionView.as_view(),
name='delete',
),
path( path(
'<slug:slug>/manage/versions/', '<slug:slug>/manage/versions/',
manage.ManageVersionsView.as_view(), manage.ManageVersionsView.as_view(),

View File

@ -53,6 +53,7 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
return {} return {}
data = { data = {
'id': instance.extension_id,
'schema_version': instance.latest_version.schema_version, 'schema_version': instance.latest_version.schema_version,
'name': instance.name, 'name': instance.name,
'version': instance.latest_version.version, 'version': instance.latest_version.version,
@ -75,16 +76,12 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
'tags': [str(tag) for tag in instance.latest_version.tags.all()], 'tags': [str(tag) for tag in instance.latest_version.tags.all()],
} }
return {instance.extension_id: clean_json_dictionary_from_optional_fields(data)} return clean_json_dictionary_from_optional_fields(data)
class ExtensionsAPIView(APIView): class ExtensionsAPIView(APIView):
serializer_class = ListedExtensionsSerializer serializer_class = ListedExtensionsSerializer
@staticmethod
def _convert_list_to_dict(data):
return {k: v for d in data for k, v in d.items()}
@extend_schema( @extend_schema(
parameters=[ parameters=[
OpenApiParameter( OpenApiParameter(
@ -99,5 +96,12 @@ class ExtensionsAPIView(APIView):
serializer = self.serializer_class( serializer = self.serializer_class(
Extension.objects.listed, blender_version=blender_version, request=request, many=True Extension.objects.listed, blender_version=blender_version, request=request, many=True
) )
data_as_dict = self._convert_list_to_dict(serializer.data) data = serializer.data
return Response(data_as_dict) return Response(
{
# TODO implement extension blocking by moderators
'blocklist': [],
'data': data,
'version': 'v1',
}
)

View File

@ -5,20 +5,28 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404, reverse from django.shortcuts import get_object_or_404, reverse
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, UpdateView, DeleteView from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
from .mixins import ExtensionQuerysetMixin, OwnsFileMixin, MaintainedExtensionMixin from .mixins import (
ExtensionQuerysetMixin,
OwnsFileMixin,
MaintainedExtensionMixin,
DraftVersionMixin,
DraftMixin,
)
from extensions.forms import ( from extensions.forms import (
EditPreviewFormSet, EditPreviewFormSet,
AddPreviewFormSet, AddPreviewFormSet,
ExtensionDeleteForm,
ExtensionUpdateForm, ExtensionUpdateForm,
VersionForm, VersionForm,
DeleteViewForm, VersionDeleteForm,
) )
from extensions.models import Extension, Version from extensions.models import Extension, Version
from files.forms import FileForm from files.forms import FileForm
from files.models import File from files.models import File
from reviewers.models import ApprovalActivity
from stats.models import ExtensionView from stats.models import ExtensionView
import ratings.models import ratings.models
@ -90,6 +98,7 @@ class UpdateExtensionView(
LoginRequiredMixin, LoginRequiredMixin,
MaintainedExtensionMixin, MaintainedExtensionMixin,
SuccessMessageMixin, SuccessMessageMixin,
DraftMixin,
UpdateView, UpdateView,
): ):
model = Extension model = Extension
@ -152,14 +161,45 @@ class UpdateExtensionView(
return self.form_invalid(form, edit_preview_formset, add_preview_formset) return self.form_invalid(form, edit_preview_formset, add_preview_formset)
class DeleteExtensionView(
LoginRequiredMixin,
UserPassesTestMixin,
DeleteView,
):
model = Extension
template_name = 'extensions/confirm_delete.html'
form_class = ExtensionDeleteForm
def get_success_url(self):
return reverse('extensions:manage-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['extension_name'] = self.object.name
context['confirm_url'] = self.object.get_delete_url()
return context
def test_func(self) -> bool:
obj = self.get_object()
# Only maintainers allowed
if not obj.has_maintainer(self.request.user):
return False
# Unless this extension cannot be deleted anymore
cannot_be_deleted_reasons = obj.cannot_be_deleted_reasons
if len(cannot_be_deleted_reasons) > 0:
return False
return True
class VersionDeleteView( class VersionDeleteView(
LoginRequiredMixin, LoginRequiredMixin,
MaintainedExtensionMixin, MaintainedExtensionMixin,
UserPassesTestMixin,
DeleteView, DeleteView,
): ):
model = Version model = Version
template_name = 'extensions/version_confirm_delete.html' template_name = 'extensions/version_confirm_delete.html'
form_class = DeleteViewForm form_class = VersionDeleteForm
def get_success_url(self): def get_success_url(self):
return reverse( return reverse(
@ -194,6 +234,14 @@ class VersionDeleteView(
context['confirm_url'] = version.get_delete_url() context['confirm_url'] = version.get_delete_url()
return context return context
def test_func(self) -> bool:
obj = self.get_object()
# Unless this version cannot be deleted anymore
cannot_be_deleted_reasons = obj.cannot_be_deleted_reasons
if len(cannot_be_deleted_reasons) > 0:
return False
return True
class ManageVersionsView( class ManageVersionsView(
LoginRequiredMixin, LoginRequiredMixin,
@ -293,3 +341,95 @@ class UpdateVersionView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
def test_func(self) -> bool: def test_func(self) -> bool:
# Only maintainers are allowed to perform this # Only maintainers are allowed to perform this
return self.get_object().extension.has_maintainer(self.request.user) return self.get_object().extension.has_maintainer(self.request.user)
class DraftExtensionView(
LoginRequiredMixin,
MaintainedExtensionMixin,
DraftVersionMixin,
UserPassesTestMixin,
SuccessMessageMixin,
FormView,
):
template_name = 'extensions/draft_finalise.html'
form_class = VersionForm
@property
def success_message(self) -> str:
if self.extension.status == Extension.STATUSES.INCOMPLETE:
return "Updated successfully"
return "Submitted to the Approval Queue"
def test_func(self) -> bool:
return self.extension.status == Extension.STATUSES.INCOMPLETE
def get_form_kwargs(self):
form_kwargs = super().get_form_kwargs()
form_kwargs['instance'] = self.extension.versions.first()
return form_kwargs
def get_initial(self):
"""Return initial values for the version, based on the file."""
initial = super().get_initial()
if self.version:
initial['file'] = self.version.file
initial.update(**self.version.file.parsed_version_fields)
return initial
def get_context_data(self, form=None, extension_form=None, add_preview_formset=None, **kwargs):
"""Add all the additional forms to the context."""
context = super().get_context_data(**kwargs)
if not (add_preview_formset and extension_form):
extension_form = ExtensionUpdateForm(instance=self.extension)
add_preview_formset = AddPreviewFormSet(extension=self.extension, request=self.request)
context['extension_form'] = extension_form
context['add_preview_formset'] = add_preview_formset
return context
def post(self, request, *args, **kwargs):
"""Handle bound forms and valid/invalid logic with the extra forms."""
form = self.get_form()
extension_form = ExtensionUpdateForm(
self.request.POST, self.request.FILES, instance=self.extension
)
add_preview_formset = AddPreviewFormSet(
self.request.POST, self.request.FILES, extension=self.extension, request=self.request
)
if form.is_valid() and extension_form.is_valid() and add_preview_formset.is_valid():
return self.form_valid(form, extension_form, add_preview_formset)
return self.form_invalid(form, extension_form, add_preview_formset)
@transaction.atomic
def form_valid(self, form, extension_form, add_preview_formset):
"""Save all the forms in correct order.
Extension must be saved first.
"""
try:
# Send the extension and version to the review
if 'submit_draft' in self.request.POST:
extension_form.instance.status = extension_form.instance.STATUSES.AWAITING_REVIEW
extension_form.save()
add_preview_formset.save()
form.save()
if 'submit_draft' in self.request.POST:
# TODO allow to submit a custom message via the form
ApprovalActivity(
user=self.request.user,
extension=extension_form.instance,
type=ApprovalActivity.ActivityType.AWAITING_REVIEW,
message="initial submission",
).save()
return super().form_valid(form)
except forms.ValidationError as e:
if 'hash' in e.error_dict:
add_preview_formset.forms[0].add_error('source', e.error_dict['hash'])
return self.form_invalid(form, extension_form, add_preview_formset)
def form_invalid(self, form, extension_form, add_preview_formset):
return self.render_to_response(
self.get_context_data(form, extension_form, add_preview_formset)
)
def get_success_url(self):
return self.extension.get_manage_url()

View File

@ -1,5 +1,5 @@
from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.auth.mixins import UserPassesTestMixin
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404, redirect
from extensions.models import Extension from extensions.models import Extension
from files.models import File from files.models import File
@ -50,7 +50,29 @@ class ExtensionMixin:
"""Fetch an extension by slug in the URL before dispatching the view.""" """Fetch an extension by slug in the URL before dispatching the view."""
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
self.extension = get_object_or_404( self.extension = get_object_or_404(Extension, slug=self.kwargs['slug'])
Extension.objects.exclude_deleted, slug=self.kwargs['slug']
)
return super().dispatch(*args, **kwargs) return super().dispatch(*args, **kwargs)
class DraftVersionMixin:
"""Fetch the version object which is being edited as a draft."""
def dispatch(self, *args, **kwargs):
self.version = self.extension.versions.first()
return super().dispatch(*args, **kwargs)
class DraftMixin:
"""If the extension is incomplete, returns the FinalizeDraftView"""
def dispatch(self, request, *args, **kwargs):
extension = (
Extension.objects.listed_or_authored_by(user_id=self.request.user.pk)
.filter(status=Extension.STATUSES.INCOMPLETE)
.first()
)
if not extension:
return super().dispatch(request, *args, **kwargs)
return redirect(extension.get_draft_url())

View File

@ -1,25 +1,18 @@
import logging import logging
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction from django.db import transaction
from django.shortcuts import reverse from django.views.generic.edit import CreateView
from django.views.generic.edit import CreateView, FormView
from .mixins import OwnsFileMixin from .mixins import DraftMixin
from extensions.models import Version, Extension from extensions.models import Version, Extension
from extensions.forms import (
ExtensionUpdateForm,
VersionForm,
AddPreviewFormSet,
)
from files.forms import FileForm from files.forms import FileForm
from files.models import File from files.models import File
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class UploadFileView(LoginRequiredMixin, CreateView): class UploadFileView(LoginRequiredMixin, DraftMixin, CreateView):
model = File model = File
template_name = 'extensions/submit.html' template_name = 'extensions/submit.html'
form_class = FileForm form_class = FileForm
@ -30,30 +23,12 @@ class UploadFileView(LoginRequiredMixin, CreateView):
return kwargs return kwargs
def get_success_url(self): def get_success_url(self):
return reverse('extensions:submit-finalise', kwargs={'pk': self.object.pk}) return self.extension.get_draft_url()
@transaction.atomic
class SubmitFileView(LoginRequiredMixin, OwnsFileMixin, FormView): def form_valid(self, form):
template_name = 'extensions/submit_finalise.html' """Create an extension and a version already, associated with the user."""
form_class = VersionForm self.file = form.instance
def _get_extension(self) -> 'Extension':
# Get the existing Extension and Version, if they were partially saved already
existing_version = getattr(self.file, 'version', None)
if existing_version:
extension = existing_version.extension
assert (
extension.can_request_review
), 'TODO: cannot request review for extension: {extension.get_status_display}'
assert not extension.authors.count() or extension.authors.filter(
user__id=self.request.user.pk
), 'TODO: cannot request review for extension: maintained by someone else'
logger.warning(
'Found existing version pk=%s for file pk=%s',
existing_version.pk,
self.file.pk,
)
return extension
parsed_extension_fields = self.file.parsed_extension_fields parsed_extension_fields = self.file.parsed_extension_fields
if parsed_extension_fields: if parsed_extension_fields:
@ -69,72 +44,21 @@ class SubmitFileView(LoginRequiredMixin, OwnsFileMixin, FormView):
extension.pk, extension.pk,
self.file.pk, self.file.pk,
) )
return extension return False
return Extension.objects.update_or_create(type=self.file.type, **parsed_extension_fields)[0]
def _get_version(self, extension) -> 'Version': # Make sure an extension has a user associated to it from the beginning, otherwise
return Version.objects.update_or_create( # it will prevent it from being re-uploaded and yet not show on My Extensions.
extension=extension, file=self.file, **self.file.parsed_version_fields self.extension = Extension.objects.update_or_create(
type=self.file.type, **parsed_extension_fields
)[0]
self.extension.authors.add(self.request.user)
self.extension.save()
# Need to save the form to be able to use the file to create the version.
self.object = self.file = form.save()
Version.objects.update_or_create(
extension=self.extension, file=self.file, **self.file.parsed_version_fields
)[0] )[0]
def get_form_kwargs(self): return super().form_valid(form)
form_kwargs = super().get_form_kwargs()
form_kwargs['instance'] = self._get_version(self.extension)
return form_kwargs
def get_initial(self):
"""Return initial values for the version, based on the file."""
initial = super().get_initial()
initial['file'] = self.file
initial.update(**self.file.parsed_version_fields)
return initial
def get_context_data(self, form=None, extension_form=None, add_preview_formset=None, **kwargs):
"""Add all the additional forms to the context."""
context = super().get_context_data(**kwargs)
if not (add_preview_formset and extension_form):
extension_form = ExtensionUpdateForm(instance=self.extension)
add_preview_formset = AddPreviewFormSet(extension=self.extension, request=self.request)
context['extension_form'] = extension_form
context['add_preview_formset'] = add_preview_formset
return context
def post(self, request, *args, **kwargs):
"""Handle bound forms and valid/invalid logic with the extra forms."""
form = self.get_form()
extension_form = ExtensionUpdateForm(
self.request.POST, self.request.FILES, instance=self.extension
)
add_preview_formset = AddPreviewFormSet(
self.request.POST, self.request.FILES, extension=self.extension, request=self.request
)
if form.is_valid() and extension_form.is_valid() and add_preview_formset.is_valid():
return self.form_valid(form, extension_form, add_preview_formset)
return self.form_invalid(form, extension_form, add_preview_formset)
@transaction.atomic
def form_valid(self, form, extension_form, add_preview_formset):
"""Save all the forms in correct order.
Extension must be saved first.
"""
try:
# Send the extension and version to the review
extension_form.instance.status = extension_form.instance.STATUSES.AWAITING_REVIEW
extension_form.save()
self.extension.authors.add(self.request.user)
add_preview_formset.save()
form.save()
return super().form_valid(form)
except forms.ValidationError as e:
if 'hash' in e.error_dict:
add_preview_formset.forms[0].add_error('source', e.error_dict['hash'])
return self.form_invalid(form, extension_form, add_preview_formset)
def form_invalid(self, form, extension_form, add_preview_formset):
return self.render_to_response(
self.get_context_data(form, extension_form, add_preview_formset)
)
def get_success_url(self):
return self.file.extension.get_manage_url()

View File

@ -1,6 +1,28 @@
from django.contrib import admin from django.contrib import admin
import background_task.admin
import background_task.models
from .models import File from .models import File, FileValidation
import files.signals
def scan_selected_files(self, request, queryset):
"""Scan selected files."""
for instance in queryset:
files.signals.schedule_scan(instance)
class FileValidationInlineAdmin(admin.StackedInline):
model = FileValidation
readonly_fields = ('date_created', 'date_modified', 'is_ok', 'results')
extra = 0
def _nope(self, request, obj):
return False
has_add_permission = _nope
has_change_permission = _nope
has_delete_permission = _nope
@admin.register(File) @admin.register(File)
@ -9,20 +31,19 @@ class FileAdmin(admin.ModelAdmin):
save_on_top = True save_on_top = True
list_filter = ( list_filter = (
'validation__is_ok',
'type', 'type',
'status', 'status',
'date_status_changed', 'date_status_changed',
'date_approved', 'date_approved',
'date_deleted',
) )
list_display = ('original_name', 'extension', 'user', 'date_created', 'type', 'status') list_display = ('original_name', 'extension', 'user', 'date_created', 'type', 'status', 'is_ok')
list_select_related = ('version__extension', 'user') list_select_related = ('version__extension', 'user')
readonly_fields = ( readonly_fields = (
'id', 'id',
'date_created', 'date_created',
'date_deleted',
'date_modified', 'date_modified',
'date_approved', 'date_approved',
'date_status_changed', 'date_status_changed',
@ -61,7 +82,6 @@ class FileAdmin(admin.ModelAdmin):
'date_modified', 'date_modified',
'date_status_changed', 'date_status_changed',
'date_approved', 'date_approved',
'date_deleted',
) )
}, },
), ),
@ -77,3 +97,56 @@ class FileAdmin(admin.ModelAdmin):
}, },
), ),
) )
inlines = [FileValidationInlineAdmin]
actions = [scan_selected_files]
def is_ok(self, obj):
return obj.validation.is_ok if hasattr(obj, 'validation') else None
is_ok.boolean = True
try:
admin.site.unregister(background_task.models.Task)
admin.site.unregister(background_task.models.CompletedTask)
except admin.site.NotRegistered:
pass
class TaskMixin:
"""Modify a few properties of background tasks displayed in admin."""
def no_errors(self, obj):
"""Replace background_task's "has_error".
Make Django's red/green boolean icons less confusing
in the context of "there's an error during task run".
"""
return not bool(obj.last_error)
no_errors.boolean = True
@admin.register(background_task.models.Task)
@admin.register(background_task.models.CompletedTask)
class TaskAdmin(background_task.admin.TaskAdmin, TaskMixin):
date_hierarchy = 'run_at'
list_display = [
'run_at',
'task_name',
'task_params',
'attempts',
'no_errors',
'locked_by',
'locked_by_pid_running',
]
list_filter = (
'task_name',
'run_at',
'failed_at',
'locked_at',
'attempts',
'creator_content_type',
)
search_fields = ['task_name', 'task_params', 'last_error', 'verbose_name']

View File

@ -5,16 +5,14 @@ import tempfile
from django import forms from django import forms
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from .validators import ( from .validators import (
CustomFileExtensionValidator,
ExtensionIDManifestValidator, ExtensionIDManifestValidator,
FileMIMETypeValidator,
ManifestValidator, ManifestValidator,
) )
from constants.base import ( from constants.base import EXTENSION_SLUG_TYPES, ALLOWED_EXTENSION_MIMETYPES
EXTENSION_TYPE_SLUGS_SINGULAR,
VALID_SOURCE_EXTENSIONS,
)
import files.models import files.models
import files.utils as utils import files.utils as utils
@ -25,6 +23,22 @@ logger = logging.getLogger(__name__)
class FileForm(forms.ModelForm): class FileForm(forms.ModelForm):
msg_only_zip_files = _('Only .zip files are accepted.')
# Mimicking how django.forms.fields.Field handles validation error messages.
# TODO: maybe this should be a custom SourceFileField with all these validators and messages
error_messages = {
'invalid_manifest_path': _(
'The manifest file should be at the top level of the archive, or one level deep.'
),
# TODO: surface TOML parsing errors?
'invalid_manifest_toml': _('Could not parse the manifest file.'),
'invalid_missing_init': _('An add-on should have an __init__.py file.'),
'missing_or_multiple_theme_xml': _('A theme should have exactly one XML file.'),
'invalid_zip_archive': msg_only_zip_files,
'missing_manifest_toml': _('The manifest file is missing.'),
}
class Meta: class Meta:
model = files.models.File model = files.models.File
fields = ('source', 'type', 'metadata', 'agreed_with_terms', 'user') fields = ('source', 'type', 'metadata', 'agreed_with_terms', 'user')
@ -32,8 +46,16 @@ class FileForm(forms.ModelForm):
source = forms.FileField( source = forms.FileField(
allow_empty_file=False, allow_empty_file=False,
required=True, required=True,
validators=[CustomFileExtensionValidator(allowed_extensions=VALID_SOURCE_EXTENSIONS)], validators=[
help_text=('Only .zip file are accepted.'), FileMIMETypeValidator(
allowed_mimetypes=ALLOWED_EXTENSION_MIMETYPES,
message=error_messages['invalid_zip_archive'],
),
],
widget=forms.ClearableFileInput(
attrs={'accept': ','.join(ALLOWED_EXTENSION_MIMETYPES)}
),
help_text=msg_only_zip_files,
) )
agreed_with_terms = forms.BooleanField( agreed_with_terms = forms.BooleanField(
initial=False, initial=False,
@ -117,22 +139,19 @@ class FileForm(forms.ModelForm):
errors = [] errors = []
if not zipfile.is_zipfile(file_path): if not zipfile.is_zipfile(file_path):
errors.append('File is not .zip') raise forms.ValidationError(self.error_messages['invalid_zip_archive'])
manifest = utils.read_manifest_from_zip(file_path) manifest, error_codes = utils.read_manifest_from_zip(file_path)
for code in error_codes:
errors.append(forms.ValidationError(self.error_messages[code]))
if errors:
self.add_error('source', errors)
if manifest is None: if manifest:
errors.append('A valid manifest file could not be found')
else:
ManifestValidator(manifest) ManifestValidator(manifest)
ExtensionIDManifestValidator(manifest, self.extension) ExtensionIDManifestValidator(manifest, self.extension)
extension_types = {v: k for k, v in EXTENSION_TYPE_SLUGS_SINGULAR.items()} self.cleaned_data['metadata'] = manifest
if errors: self.cleaned_data['type'] = EXTENSION_SLUG_TYPES[manifest['type']]
raise forms.ValidationError({'source': errors}, code='invalid')
self.cleaned_data['metadata'] = manifest
# TODO: Error handling
self.cleaned_data['type'] = extension_types[manifest['type']]
return self.cleaned_data return self.cleaned_data

View File

@ -0,0 +1,40 @@
# Generated by Django 4.2.11 on 2024-04-12 09:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0004_alter_file_status'),
]
operations = [
migrations.RenameField(
model_name='filevalidation',
old_name='validation',
new_name='results',
),
migrations.AlterField(
model_name='filevalidation',
name='results',
field=models.JSONField(),
),
migrations.RemoveField(
model_name='filevalidation',
name='errors',
),
migrations.RemoveField(
model_name='filevalidation',
name='notices',
),
migrations.RemoveField(
model_name='filevalidation',
name='warnings',
),
migrations.RenameField(
model_name='filevalidation',
old_name='is_valid',
new_name='is_ok',
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.11 on 2024-04-18 08:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('files', '0005_rename_validation_filevalidation_results_and_more'),
]
operations = [
migrations.RemoveField(
model_name='file',
name='date_deleted',
),
]

View File

@ -0,0 +1,47 @@
# Generated by Django 4.2.11 on 2024-04-18 10:10
import logging
from django.db import migrations, models
logger = logging.getLogger(__name__)
def change_first_uploads_to_awaiting_review(apps, schema_editor):
from constants.base import FILE_STATUS_CHOICES
File = apps.get_model('files', 'File')
to_update = []
for obj in File.objects.filter(status=FILE_STATUS_CHOICES.APPROVED):
# Unless this is a subsequent upload of a version/preview of an already approved extension,
# it should have a default status (Awaiting Review).
extension = (
obj.extension_preview.first() and obj.extension_preview.first().extension
or (hasattr(obj, 'version') and obj.version.extension)
)
if not extension or extension.is_listed:
continue
logger.info(
'Will change file pk=%s status from %s to %s', obj.pk, obj.get_status_display(),
'Awaiting Review',
)
obj.status = FILE_STATUS_CHOICES.AWAITING_REVIEW
to_update.append(obj)
logger.info('Updating %s files', len(to_update))
File.objects.bulk_update(to_update, batch_size=500, fields={'status'})
class Migration(migrations.Migration):
dependencies = [
('files', '0006_remove_file_date_deleted'),
]
operations = [
migrations.AlterField(
model_name='file',
name='status',
field=models.PositiveSmallIntegerField(choices=[(2, 'Awaiting Review'), (3, 'Approved'), (4, 'Disabled by staff'), (5, 'Disabled by author')], default=2),
),
migrations.RunPython(change_first_uploads_to_awaiting_review),
]

View File

@ -1,15 +1,12 @@
from pathlib import Path from pathlib import Path
from typing import Dict, Any from typing import Dict, Any
import logging import logging
import mimetypes
import re
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.urls import reverse
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin
from files.utils import get_sha256 from files.utils import get_sha256, guess_mimetype_from_ext
from constants.base import ( from constants.base import (
FILE_STATUS_CHOICES, FILE_STATUS_CHOICES,
FILE_TYPE_CHOICES, FILE_TYPE_CHOICES,
@ -51,8 +48,8 @@ def thumbnail_upload_to(instance, filename):
return path return path
class File(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Model): class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
track_changes_to_fields = {'status', 'size_bytes', 'hash', 'date_deleted'} track_changes_to_fields = {'status', 'size_bytes', 'hash'}
TYPES = FILE_TYPE_CHOICES TYPES = FILE_TYPE_CHOICES
STATUSES = FILE_STATUS_CHOICES STATUSES = FILE_STATUS_CHOICES
@ -79,7 +76,7 @@ class File(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Mode
'as guessed from its contents.' 'as guessed from its contents.'
), ),
) )
status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.APPROVED) status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.AWAITING_REVIEW)
user = models.ForeignKey( user = models.ForeignKey(
User, related_name='files', null=False, blank=False, on_delete=models.CASCADE User, related_name='files', null=False, blank=False, on_delete=models.CASCADE
@ -138,8 +135,7 @@ class File(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Mode
self.original_name = self.source.name self.original_name = self.source.name
if not self.content_type: if not self.content_type:
content_type, _ = mimetypes.guess_type(self.original_name) self.content_type = guess_mimetype_from_ext(self.original_name)
self.content_type = content_type
if self.content_type: if self.content_type:
if 'image' in self.content_type: if 'image' in self.content_type:
self.type = self.TYPES.IMAGE self.type = self.TYPES.IMAGE
@ -180,14 +176,10 @@ class File(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Mode
data = self.metadata data = self.metadata
extension_id = data.get('id') extension_id = data.get('id')
original_name = data.get('name', self.original_name) name = data.get('name', self.original_name)
name_as_path = Path(original_name)
for suffix in name_as_path.suffixes:
original_name = original_name.replace(suffix, '')
name = re.sub(r'[-_ ]+', ' ', original_name)
return { return {
'name': name, 'name': name,
'slug': utils.slugify(name), 'slug': utils.slugify(extension_id),
'extension_id': extension_id, 'extension_id': extension_id,
'website': data.get('website'), 'website': data.get('website'),
} }
@ -209,15 +201,12 @@ class File(CreatedModifiedMixin, TrackChangesMixin, SoftDeleteMixin, models.Mode
} }
def get_submit_url(self) -> str: def get_submit_url(self) -> str:
return reverse('extensions:submit-finalise', kwargs={'pk': self.pk}) return self.extension.get_draft_url()
class FileValidation(CreatedModifiedMixin, TrackChangesMixin, models.Model): class FileValidation(CreatedModifiedMixin, TrackChangesMixin, models.Model):
track_changes_to_fields = {'is_valid', 'errors', 'warnings', 'notices', 'validation'} track_changes_to_fields = {'is_ok', 'results'}
file = models.OneToOneField(File, related_name='validation', on_delete=models.CASCADE) file = models.OneToOneField(File, related_name='validation', on_delete=models.CASCADE)
is_valid = models.BooleanField(default=False) is_ok = models.BooleanField(default=False)
errors = models.IntegerField(default=0) results = models.JSONField()
warnings = models.IntegerField(default=0)
notices = models.IntegerField(default=0)
validation = models.TextField()

View File

@ -1,11 +1,40 @@
from django.db.models.signals import pre_save import logging
from django.db.models.signals import pre_save, post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
import files.models import files.models
import files.tasks
logger = logging.getLogger(__name__)
@receiver(pre_save, sender=files.models.File) @receiver(pre_save, sender=files.models.File)
def _record_changes(sender: object, instance: files.models.File, **kwargs: object) -> None: def _record_changes(
was_changed, old_state = instance.pre_save_record() sender: object, instance: files.models.File, update_fields: object, **kwargs: object
) -> None:
was_changed, old_state = instance.pre_save_record(update_fields=update_fields)
instance.record_status_change(was_changed, old_state, **kwargs) instance.record_status_change(was_changed, old_state, **kwargs)
def schedule_scan(file: files.models.File) -> None:
"""Schedule a scan of a given file."""
logger.info('Scheduling a scan for file pk=%s', file.pk)
verbose_name = f'clamdscan of "{file.source.name}"'
files.tasks.clamdscan(file_id=file.pk, creator=file, verbose_name=verbose_name)
@receiver(post_save, sender=files.models.File)
def _scan_new_file(
sender: object, instance: files.models.File, created: bool, **kwargs: object
) -> None:
if not created:
return
schedule_scan(instance)
@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)

29
files/tasks.py Normal file
View File

@ -0,0 +1,29 @@
import logging
import os.path
from background_task import background
from background_task.tasks import TaskSchedule
from django.conf import settings
import files.models
import files.utils
logger = logging.getLogger(__name__)
@background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING})
def clamdscan(file_id: int):
"""Run a scan of a given file and save its output as a FileValidation record."""
file = files.models.File.objects.get(pk=file_id)
abs_path = os.path.join(settings.MEDIA_ROOT, file.source.path)
scan_status, scan_found = files.utils.run_clamdscan(abs_path)
logger.info('File pk=%s scanned: %s', file.pk, (scan_status, scan_found))
scan_result = {'clamdscan': [scan_status, scan_found]}
is_ok = scan_status == 'OK'
file_validation, is_new = files.models.FileValidation.objects.get_or_create(
file=file, defaults={'results': scan_result, 'is_ok': is_ok}
)
if not is_new:
file_validation.results = scan_result
file_validation.is_ok = is_ok
file_validation.save(update_fields={'results', 'is_ok', 'date_modified'})

View File

@ -0,0 +1,21 @@
{% 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 %}
{% endif %}

View File

@ -0,0 +1,10 @@
{% 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 %}
{% endif %}

View File

@ -45,7 +45,6 @@ class FileTest(TestCase):
'status': 2, 'status': 2,
'hash': 'foobar', 'hash': 'foobar',
'size_bytes': 7149, 'size_bytes': 7149,
'date_deleted': None,
}, },
} }
}, },

107
files/tests/test_signals.py Normal file
View File

@ -0,0 +1,107 @@
import os
import shutil
import tempfile
import unittest
from background_task.models import Task
from django.conf import settings
from django.test import TestCase, override_settings
from common.tests.factories.files import FileFactory
import files.models
import files.tasks
@unittest.skipUnless(shutil.which('clamd'), 'requires clamd')
@override_settings(MEDIA_ROOT='/tmp/')
class FileScanTest(TestCase):
def setUp(self):
super().setUp()
self.temp_directory = tempfile.mkdtemp(prefix=settings.MEDIA_ROOT)
def tearDown(self):
super().tearDown()
shutil.rmtree(self.temp_directory)
def test_scan_flags_found_invalid(self):
test_file_path = os.path.join(self.temp_directory, 'test_file.zip')
test_content = (
b'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' # noqa: W605
)
with open(test_file_path, 'wb+') as test_file:
test_file.write(test_content)
file = FileFactory(source=test_file_path)
self.assertFalse(hasattr(file, 'validation'))
# A background task should have been created
task = Task.objects.created_by(creator=file).first()
self.assertIsNotNone(task)
self.assertEqual(task.task_name, 'files.tasks.clamdscan')
self.assertEqual(task.task_params, f'[[], {{"file_id": {file.pk}}}]')
# Actually run the task as if by background runner
task_args, task_kwargs = task.params()
files.tasks.clamdscan.task_function(*task_args, **task_kwargs)
file.refresh_from_db()
self.assertFalse(file.validation.is_ok)
result = file.validation.results['clamdscan']
self.assertEqual(result, ['FOUND', 'Win.Test.EICAR_HDB-1'])
def test_scan_flags_found_invalid_updates_existing_validation(self):
test_file_path = os.path.join(self.temp_directory, 'test_file.zip')
test_content = (
b'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' # noqa: W605
)
with open(test_file_path, 'wb+') as test_file:
test_file.write(test_content)
file = FileFactory(source=test_file_path)
# Make sure validation record exists before scanner runs
existing_validation = files.models.FileValidation(file=file, results={})
existing_validation.save()
self.assertTrue(hasattr(file, 'validation'))
old_date_modified = existing_validation.date_modified
# A background task should have been created
task = Task.objects.created_by(creator=file).first()
self.assertIsNotNone(task)
self.assertEqual(task.task_name, 'files.tasks.clamdscan')
self.assertEqual(task.task_params, f'[[], {{"file_id": {file.pk}}}]')
# Actually run the task as if by background runner
task_args, task_kwargs = task.params()
files.tasks.clamdscan.task_function(*task_args, **task_kwargs)
self.assertFalse(file.validation.is_ok)
file.validation.refresh_from_db()
result = file.validation.results['clamdscan']
self.assertEqual(result, ['FOUND', 'Win.Test.EICAR_HDB-1'])
self.assertEqual(existing_validation.pk, file.validation.pk)
existing_validation.refresh_from_db()
self.assertGreater(existing_validation.date_modified, old_date_modified)
def test_scan_flags_nothing_found_valid(self):
test_file_path = os.path.join(self.temp_directory, 'test_file.zip')
with open(test_file_path, 'wb+') as test_file:
test_file.write(b'some file')
file = FileFactory(source=test_file_path)
self.assertFalse(hasattr(file, 'validation'))
# A background task should have been created
task = Task.objects.created_by(creator=file).first()
self.assertIsNotNone(task)
self.assertEqual(task.task_name, 'files.tasks.clamdscan')
self.assertEqual(task.task_params, f'[[], {{"file_id": {file.pk}}}]')
# Actually run the task as if by background runner
task_args, task_kwargs = task.params()
files.tasks.clamdscan.task_function(*task_args, **task_kwargs)
file.refresh_from_db()
self.assertTrue(file.validation.is_ok)
result = file.validation.results['clamdscan']
self.assertEqual(result, ['OK', None])

View File

@ -1,6 +1,6 @@
from django.test import TestCase from django.test import TestCase
from files.utils import find_file_inside_zip_list from files.utils import find_path_by_name, find_exact_path, filter_paths_by_ext
class UtilsTest(TestCase): class UtilsTest(TestCase):
@ -10,7 +10,7 @@ class UtilsTest(TestCase):
name_list = [ name_list = [
"blender_manifest.toml", "blender_manifest.toml",
] ]
manifest_file = find_file_inside_zip_list(self.manifest, name_list) manifest_file = find_path_by_name(name_list, self.manifest)
self.assertEqual(manifest_file, "blender_manifest.toml") self.assertEqual(manifest_file, "blender_manifest.toml")
def test_find_manifest_nested(self): def test_find_manifest_nested(self):
@ -23,21 +23,21 @@ class UtilsTest(TestCase):
"foobar-1.0.3/manifest.toml", "foobar-1.0.3/manifest.toml",
"foobar-1.0.3/manifest.json", "foobar-1.0.3/manifest.json",
] ]
manifest_file = find_file_inside_zip_list(self.manifest, name_list) manifest_file = find_path_by_name(name_list, self.manifest)
self.assertEqual(manifest_file, "foobar-1.0.3/blender_manifest.toml") self.assertEqual(manifest_file, "foobar-1.0.3/blender_manifest.toml")
def test_find_manifest_no_zipped_folder(self): def test_find_manifest_no_zipped_folder(self):
name_list = [ name_list = [
"foobar-1.0.3/blender_manifest.toml", "foobar-1.0.3/blender_manifest.toml",
] ]
manifest_file = find_file_inside_zip_list(self.manifest, name_list) manifest_file = find_path_by_name(name_list, self.manifest)
self.assertEqual(manifest_file, "foobar-1.0.3/blender_manifest.toml") self.assertEqual(manifest_file, "foobar-1.0.3/blender_manifest.toml")
def test_find_manifest_no_manifest(self): def test_find_manifest_no_manifest(self):
name_list = [ name_list = [
"foobar-1.0.3/", "foobar-1.0.3/",
] ]
manifest_file = find_file_inside_zip_list(self.manifest, name_list) manifest_file = find_path_by_name(name_list, self.manifest)
self.assertEqual(manifest_file, None) self.assertEqual(manifest_file, None)
def test_find_manifest_with_space(self): def test_find_manifest_with_space(self):
@ -47,5 +47,54 @@ class UtilsTest(TestCase):
"foobar-1.0.3/blender_manifest.toml.txt", "foobar-1.0.3/blender_manifest.toml.txt",
"blender_manifest.toml/my_files.py", "blender_manifest.toml/my_files.py",
] ]
manifest_file = find_file_inside_zip_list(self.manifest, name_list) manifest_file = find_path_by_name(name_list, self.manifest)
self.assertEqual(manifest_file, None) self.assertEqual(manifest_file, None)
def test_find_exact_path_found(self):
name_list = [
'foobar-1.0.3/theme.xml',
'foobar-1.0.3/theme1.xml',
'foobar-1.0.3/theme2.txt',
'foobar-1.0.3/__init__.py',
'foobar-1.0.3/foobar/__init__.py',
'foobar-1.0.3/foobar-1.0.3/__init__.py',
'blender_manifest.toml',
]
path = find_exact_path(name_list, 'foobar-1.0.3/__init__.py')
self.assertEqual(path, 'foobar-1.0.3/__init__.py')
def test_find_exact_path_nothing_found(self):
name_list = [
'foobar-1.0.3/theme.xml',
'foobar-1.0.3/theme1.xml',
'foobar-1.0.3/theme2.txt',
'foobar-1.0.3/foobar/__init__.py',
'foobar-1.0.3/foobar-1.0.3/__init__.py',
'blender_manifest.toml',
]
path = find_exact_path(name_list, 'foobar-1.0.3/__init__.py')
self.assertIsNone(path)
def test_filter_paths_by_ext_found(self):
name_list = [
'foobar-1.0.3/theme.xml',
'foobar-1.0.3/theme1.xml',
'foobar-1.0.3/theme2.txt',
'foobar-1.0.3/__init__.py',
'foobar-1.0.3/foobar-1.0.3/__init__.py',
'blender_manifest.toml',
]
paths = filter_paths_by_ext(name_list, '.xml')
self.assertEqual(list(paths), ['foobar-1.0.3/theme.xml', 'foobar-1.0.3/theme1.xml'])
def test_filter_paths_by_ext_nothing_found(self):
name_list = [
'foobar-1.0.3/theme.xml',
'foobar-1.0.3/theme1.md.xml',
'foobar-1.0.3/theme2.txt',
'foobar-1.0.3/__init__.py',
'foobar-1.0.3/foobar-1.0.3/__init__.py',
'blender_manifest.toml',
]
paths = filter_paths_by_ext(name_list, '.md')
self.assertEqual(list(paths), [])

View File

@ -1,12 +1,17 @@
from pathlib import Path from pathlib import Path
import hashlib import hashlib
import io import io
import os
import logging import logging
import zipfile import mimetypes
import os
import os.path
import toml import toml
import typing
import zipfile
from lxml import etree from lxml import etree
import clamd
import magic
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MODULE_DIR = Path(__file__).resolve().parent MODULE_DIR = Path(__file__).resolve().parent
@ -46,33 +51,124 @@ def get_sha256_from_value(value: str):
return hash_.hexdigest() return hash_.hexdigest()
def find_file_inside_zip_list(file_to_read: str, name_list: list) -> str: def find_path_by_name(paths: typing.List[str], name: str) -> typing.Optional[str]:
"""Return the first occurance of file_to_read insize a zip name_list""" """Return the first occurrence of file name in a given list of paths."""
for file_path in name_list: for file_path in paths:
# Remove leading/trailing whitespace from file path # Remove leading/trailing whitespace from file path
file_path_stripped = file_path.strip() file_path_stripped = file_path.strip()
# Check if the basename of the stripped path is equal to the target file name # Check if the basename of the stripped path is equal to the target file name
if os.path.basename(file_path_stripped) == file_to_read: if os.path.basename(file_path_stripped) == name:
return file_path_stripped return file_path_stripped
return None return None
def find_exact_path(paths: typing.List[str], exact_path: str) -> typing.Optional[str]:
"""Return a first path equal to a given one if it exists in a given list of paths."""
matching_paths = (path for path in paths if path == exact_path)
return next(matching_paths, None)
def filter_paths_by_ext(paths: typing.List[str], ext: str) -> typing.Iterable[str]:
"""Generate a list of paths having a given extension from a given list of paths."""
for file_path in paths:
# Get file path's extension
_, file_path_ext = os.path.splitext(file_path)
# Check if this file's extension matches the extension we are looking for
if file_path_ext.lower() == ext.lower():
yield file_path
def read_manifest_from_zip(archive_path): def read_manifest_from_zip(archive_path):
file_to_read = 'blender_manifest.toml' """Read and validate extension's manifest file and contents of the archive.
In any extension archive, a valid `blender_manifest.toml` file is expected
to be found at the top level of the archive, or inside a single nested directory.
Additionally, depending on the extension type defined in the manifest,
the archive is expected to have a particular file structure:
* for themes, a single XML file is expected next to the manifest;
* for add-ons, the following structure is expected:
```
some-addon.zip
an-optional-dir
blender_manifest.toml
__init__.py
(...)
```
"""
manifest_name = 'blender_manifest.toml'
error_codes = []
try: try:
with zipfile.ZipFile(archive_path) as myzip: with zipfile.ZipFile(archive_path) as myzip:
manifest_filepath = find_file_inside_zip_list(file_to_read, myzip.namelist()) bad_file = myzip.testzip()
if bad_file is not None:
logger.error('Bad file in ZIP')
error_codes.append('invalid_zip_archive')
return None, error_codes
file_list = myzip.namelist()
manifest_filepath = find_path_by_name(file_list, manifest_name)
if manifest_filepath is None: if manifest_filepath is None:
logger.info(f"File '{file_to_read}' not found in the archive.") logger.info(f"File '{manifest_name}' not found in the archive.")
return None error_codes.append('missing_manifest_toml')
return None, error_codes
# Manifest file is expected to be no deeper than one directory down
if os.path.dirname(os.path.dirname(manifest_filepath)) != '':
error_codes.append('invalid_manifest_path')
return None, error_codes
# Extract the file content # Extract the file content
with myzip.open(manifest_filepath) as file_content: with myzip.open(manifest_filepath) as file_content:
# TODO: handle TOML loading error
toml_content = toml.loads(file_content.read().decode()) toml_content = toml.loads(file_content.read().decode())
return toml_content
# If manifest was parsed successfully, do additional type-specific validation
type_slug = toml_content['type']
if type_slug == 'theme':
theme_xmls = filter_paths_by_ext(file_list, '.xml')
if len(list(theme_xmls)) != 1:
error_codes.append('missing_or_multiple_theme_xml')
elif type_slug == 'add-on':
# __init__.py is expected to be next to the manifest
expected_init_path = os.path.join(os.path.dirname(manifest_filepath), '__init__.py')
init_filepath = find_exact_path(file_list, expected_init_path)
if not init_filepath:
error_codes.append('invalid_missing_init')
return toml_content, error_codes
except toml.decoder.TomlDecodeError as e:
logger.error(f"Manifest Error: {e.msg}")
error_codes.append('invalid_manifest_toml')
except Exception as e: except Exception as e:
logger.error(f"Error extracting from archive: {e}") logger.error(f"Error extracting from archive: {e}")
return None error_codes.append('invalid_zip_archive')
return None, error_codes
def guess_mimetype_from_ext(file_name: str) -> str:
"""Guess MIME-type from the extension of the given file name."""
mimetype_from_ext, _ = mimetypes.guess_type(file_name)
return mimetype_from_ext
def guess_mimetype_from_content(file_obj) -> str:
"""Guess MIME-type based on a portion of the given file's bytes."""
mimetype_from_bytes = magic.from_buffer(file_obj.read(2048), mime=True)
# This file might be read again by validation or other utilities
file_obj.seek(0)
return mimetype_from_bytes
def run_clamdscan(abs_path: str) -> tuple:
logger.info('Scanning file at path=%s', abs_path)
clamd_socket = clamd.ClamdUnixSocket()
with open(abs_path, 'rb') as f:
result = clamd_socket.instream(f)['stream']
logger.info('File at path=%s scanned: %s', abs_path, result)
return result

View File

@ -1,41 +1,56 @@
from pathlib import Path
from semantic_version import Version from semantic_version import Version
import logging import logging
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator, validate_unicode_slug from django.core.validators import validate_unicode_slug
from django.utils.deconstruct import deconstructible
from django.utils.html import escape
from django.utils.safestring import mark_safe
from extensions.models import Extension, License, VersionPermission, Tag from extensions.models import Extension, License, VersionPermission, Tag
from constants.base import EXTENSION_TYPE_SLUGS_SINGULAR, EXTENSION_TYPE_CHOICES from constants.base import EXTENSION_TYPE_SLUGS_SINGULAR, EXTENSION_TYPE_CHOICES
from files.utils import guess_mimetype_from_ext, guess_mimetype_from_content
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CustomFileExtensionValidator(FileExtensionValidator): @deconstructible
"""Allows extensions such as tar.gz.""" class FileMIMETypeValidator:
code = 'invalid_mimetype'
def __init__(self, allowed_mimetypes, message, code=None):
self.allowed_mimetypes = {_type.lower() for _type in allowed_mimetypes}
if message is not None:
self.message = message
if code is not None:
self.code = code
def __call__(self, value): def __call__(self, value):
suffixes = Path(value.name).suffixes # Guess MIME-type based on extension first
# Possible extensions without a leading dot mimetype_from_ext = guess_mimetype_from_ext(value.name)
extensions = { if mimetype_from_ext not in self.allowed_mimetypes:
''.join(suffixes[-1:])[1:].lower(), raise ValidationError(self.message, code=self.code)
''.join(suffixes)[1:].lower(), # Guess MIME-type based on file's content
''.join(suffixes[1:])[1:].lower(), mimetype_from_bytes = guess_mimetype_from_content(value)
} if mimetype_from_bytes not in self.allowed_mimetypes:
if self.allowed_extensions is not None and all( raise ValidationError(self.message, code=self.code)
extension not in self.allowed_extensions for extension in extensions if mimetype_from_ext != mimetype_from_bytes:
): # This shouldn't happen, but libmagic's and mimetypes' mappings
full_extension = ''.join(suffixes).lower()[1:] # might differ from distro to distro.
raise ValidationError( logger.exception(
self.message, "MIME-type from extension (%s) doesn't match content (%s)",
code=self.code, mimetype_from_ext,
params={ mimetype_from_bytes,
"extension": full_extension,
"allowed_extensions": ", ".join(self.allowed_extensions),
"value": value,
},
) )
raise ValidationError(self.message, code=self.code)
def __eq__(self, other):
return (
isinstance(other, self.__class__)
and self.allowed_mimetypes == other.allowed_mimetypes
and self.message == other.message
and self.code == other.code
)
class ExtensionIDManifestValidator: class ExtensionIDManifestValidator:
@ -54,7 +69,7 @@ class ExtensionIDManifestValidator:
raise ValidationError( raise ValidationError(
{ {
'source': [ 'source': [
_('Missing field in blender_manifest.toml: "id"'), mark_safe('Missing field in blender_manifest.toml: <code>id</code>'),
], ],
}, },
code='invalid', code='invalid',
@ -64,9 +79,9 @@ class ExtensionIDManifestValidator:
raise ValidationError( raise ValidationError(
{ {
'source': [ 'source': [
_( mark_safe(
f'Invalid id from extension manifest: "{extension_id}". ' "Invalid <code>id</code> from extension manifest: "
'No hyphens are allowed.' f'"{escape(extension_id)}". No hyphens are allowed.'
), ),
], ],
}, },
@ -79,9 +94,10 @@ class ExtensionIDManifestValidator:
raise ValidationError( raise ValidationError(
{ {
'source': [ 'source': [
_( mark_safe(
f'Invalid id from extension manifest: "{extension_id}". ' f'Invalid <code>id</code> from extension manifest: '
'Use a valid id consisting of Unicode letters, numbers or underscores.' f'"{escape(extension_id)}". '
f'Use a valid id consisting of Unicode letters, numbers or underscores.'
), ),
], ],
}, },
@ -93,9 +109,10 @@ class ExtensionIDManifestValidator:
raise ValidationError( raise ValidationError(
{ {
'source': [ 'source': [
_( mark_safe(
f'The extension id in the manifest ("{extension_id}") ' f'The extension <code>id</code> in the manifest '
'is already being used by another extension.' f'("{escape(extension_id)}") '
f'is already being used by another extension.'
), ),
], ],
}, },
@ -105,10 +122,10 @@ class ExtensionIDManifestValidator:
raise ValidationError( raise ValidationError(
{ {
'source': [ 'source': [
_( mark_safe(
f'The extension id in the manifest ("{extension_id}") ' f'The extension <code>id</code> in the manifest '
'doesn\'t match the expected one for this extension ' f'("{escape(extension_id)}") doesn\'t match the expected one for'
f'("{extension_to_be_updated.extension_id}").' f'this extension ("{escape(extension_to_be_updated.extension_id)}").'
), ),
], ],
}, },
@ -130,7 +147,9 @@ class SimpleValidator(ManifestFieldValidator):
if not hasattr(cls, '_type') or not hasattr(cls, '_type_name'): if not hasattr(cls, '_type') or not hasattr(cls, '_type_name'):
assert not "SimpleValidator must be inherited not be used directly." assert not "SimpleValidator must be inherited not be used directly."
if type(value) != cls._type: if type(value) != cls._type:
return f'Manifest value error: {name} should be of type: {cls._type_name}' return mark_safe(
f'Manifest value error: <code>{name}</code> should be of type: {cls._type_name}'
)
class StringValidator(SimpleValidator): class StringValidator(SimpleValidator):
@ -166,10 +185,10 @@ class LicenseValidator(ListValidator):
if not is_error: if not is_error:
return return
error_message = ( error_message = mark_safe(
f'Manifest value error: {name} expects a list of supported licenses, ' f'Manifest value error: <code>license</code> expects a list of '
f'e.g.: {cls.example}. Visit ' f'<a href="https://docs.blender.org/manual/en/dev/extensions/licenses.html">'
'https://docs.blender.org/manual/en/dev/extensions/licenses.html to learn more.' f'supported licenses</a>. e.g., {cls.example}.'
) )
return error_message return error_message
@ -196,12 +215,11 @@ class TagsValidatorBase:
return return
error_message = ( error_message = (
f'Manifest value error: {name} expects a list of supported {type_name} ' f'Manifest value error: <code>tags</code> expects a list of '
f'tags, e.g.: {cls.example}. ' f'<a href="https://docs.blender.org/manual/en/dev/extensions/tags.html" '
'Visit https://docs.blender.org/manual/en/dev/extensions/tags.html to learn more.' f'target="_blank"> supported {type_name} tags</a>. e.g., {cls.example}. '
) )
return mark_safe(error_message)
return error_message
class TagsAddonsValidator(TagsValidatorBase): class TagsAddonsValidator(TagsValidatorBase):
@ -252,7 +270,7 @@ class TypeValidator:
return return
error_message = ( error_message = (
f'Manifest value error: {name} expects one of the supported types. ' f'Manifest value error: <code>{name}</code> expects one of the supported types. '
f'The supported types are: ' f'The supported types are: '
) )
@ -260,7 +278,7 @@ class TypeValidator:
error_message += f'"{_type}", ' error_message += f'"{_type}", '
error_message = error_message[:-2] + '.' error_message = error_message[:-2] + '.'
return error_message return mark_safe(error_message)
class PermissionsValidator: class PermissionsValidator:
@ -271,6 +289,20 @@ class PermissionsValidator:
"""Return error message if doesn´t contain a valid list of permissions.""" """Return error message if doesn´t contain a valid list of permissions."""
is_error = False is_error = False
extension_type = manifest.get('type')
is_theme = extension_type == EXTENSION_TYPE_SLUGS_SINGULAR[EXTENSION_TYPE_CHOICES.THEME]
is_bpy = extension_type == EXTENSION_TYPE_SLUGS_SINGULAR[EXTENSION_TYPE_CHOICES.BPY]
if is_theme:
error_message = mark_safe(
'Manifest value error: <code>permissions</code> not required for themes.'
)
return error_message
# Let the wrong type error be handled elsewhere.
if not is_bpy:
return
if (type(value) != list) or (not value): if (type(value) != list) or (not value):
is_error = True is_error = True
else: else:
@ -284,15 +316,15 @@ class PermissionsValidator:
return return
error_message = ( error_message = (
f'Manifest value error: {name} expects a list of supported permissions, ' f'Manifest value error: <code>permissions</code> expects a list of '
f'e.g.: {cls.example}. The supported permissions are: ' f'supported permissions. e.g.: {cls.example}. The supported permissions are: '
) )
for permission_ob in VersionPermission.objects.all(): for permission_ob in VersionPermission.objects.all():
error_message += f'{permission_ob.slug}, ' error_message += f'{permission_ob.slug}, '
error_message = error_message[:-2] + '.' error_message = error_message[:-2] + '.'
return error_message return mark_safe(error_message)
class VersionValidator: class VersionValidator:
@ -306,9 +338,32 @@ class VersionValidator:
except (ValueError, TypeError): except (ValueError, TypeError):
# ValueError happens when passing an invalid version, like "2.9" # ValueError happens when passing an invalid version, like "2.9"
# TypeError happens when e.g., passing an integer # TypeError happens when e.g., passing an integer
return ( return mark_safe(
f'Manifest value error: {name} should follow a semantic version, ' f'Manifest value error: <code>{name}</code> should follow a '
f'e.g., "{cls.example}". Visit https://semver.org/ to learn more.' f'<a href="https://semver.org/" target="_blank">semantic version</a>. '
f'e.g., "{cls.example}"'
)
class SchemaVersionValidator(VersionValidator):
example = '1.0.0'
@classmethod
def validate(cls, *, name: str, value: str, manifest: dict) -> str:
"""Return error message if cannot validate, otherwise returns nothing"""
if err_message := super().validate(name=name, value=value, manifest=manifest):
return err_message
valid_schemas = {
"1.0.0",
}
if value not in valid_schemas:
# TODO make a user manual page with the list of the different schemas.
return mark_safe(
f'Manifest value error: <code>schema</code> version ({escape(value)}) '
f'<a href="https://docs.blender.org/manual/en/dev/extensions/'
f'getting_started.html#manifest" target="_blank">not supported</a>.'
) )
@ -330,7 +385,7 @@ class VersionVersionValidator(VersionValidator):
version = extension.versions.filter(version=value).first() version = extension.versions.filter(version=value).first()
if version: if version:
return ( return mark_safe(
f'The version {value} was already uploaded for this extension ' f'The version {value} was already uploaded for this extension '
f'({extension.name})' f'({extension.name})'
) )
@ -347,7 +402,9 @@ class VersionMinValidator(VersionValidator):
# Extensions were created in 4.2.0 # Extensions were created in 4.2.0
if Version(value) < Version('4.2.0'): if Version(value) < Version('4.2.0'):
return 'Manifest value error: blender_version_min should be at least "4.2.0"' return mark_safe(
'Manifest value error: <code>blender_version_min</code> should be at least "4.2.0"'
)
class VersionMaxValidator(VersionValidator): class VersionMaxValidator(VersionValidator):
@ -364,18 +421,20 @@ class TaglineValidator(StringValidator):
return err_message return err_message
if not value: if not value:
return 'Manifest value error: tagline cannot be empty.' return mark_safe('Manifest value error: <code>tagline</code> cannot be empty.')
if value[-1] in {'.', '!', '?'}: if value[-1] in {'.', '!', '?'}:
return "Manifest value error: tagline cannot end with any punctuation (.!?)." return mark_safe(
"Manifest value error: <code>tagline</code> cannot end with any punctuation (.!?)."
)
# TODO: get this from the model, the following code was supposed to work, however # TODO: get this from the model, the following code was supposed to work, however
# it does not (it did for Extensions). # it does not (it did for Extensions).
# Version._meta.get_field('tagline').max_length # Version._meta.get_field('tagline').max_length
max_length = 64 max_length = 64
if len(value) > max_length: if len(value) > max_length:
return ( return mark_safe(
f'Manifest value error: tagline is too long ({len(value)}), ' f'Manifest value error: <code>tagline</code> is too long ({len(value)}), '
f'max-length: {max_length} characters' f'max-length: {max_length} characters'
) )
@ -389,7 +448,7 @@ class ManifestValidator:
'license': LicenseValidator, 'license': LicenseValidator,
'maintainer': StringValidator, 'maintainer': StringValidator,
'name': StringValidator, 'name': StringValidator,
'schema_version': VersionValidator, 'schema_version': SchemaVersionValidator,
'tagline': TaglineValidator, 'tagline': TaglineValidator,
'type': TypeValidator, 'type': TypeValidator,
'version': VersionVersionValidator, 'version': VersionVersionValidator,
@ -432,10 +491,8 @@ class ManifestValidator:
if missing_fields: if missing_fields:
errors.append( errors.append(
_( 'The following values are missing from the manifest file: '
'The following values are missing from the manifest file: ' f'{", ".join(missing_fields)}'
f'{", ".join(missing_fields)}'
)
) )
# Add the wrong field error messages to the general errors. # Add the wrong field error messages to the general errors.

20
notifications/admin.py Normal file
View File

@ -0,0 +1,20 @@
from django.contrib import admin
from notifications.models import Notification
class NotificationAdmin(admin.ModelAdmin):
readonly_fields = (
'recipient',
'action',
'email_sent',
'processed_by_mailer_at',
'read_at',
)
fields = readonly_fields
def get_queryset(self, request):
return Notification.objects.all()
admin.site.register(Notification, NotificationAdmin)

9
notifications/apps.py Normal file
View File

@ -0,0 +1,9 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'notifications'
def ready(self):
import notifications.signals # noqa: F401

View File

View File

@ -0,0 +1,35 @@
"""Create all necessary follow records."""
import logging
from actstream.actions import follow
from django.contrib.auth.models import Group
from django.core.management.base import BaseCommand
from constants.activity import Flag
from extensions.models import Extension
from reviewers.models import ApprovalActivity
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class Command(BaseCommand):
def handle(self, *args, **options): # noqa: D102
# TODO? keep a record of explicit unfollow requests to avoid re-following
extensions = Extension.objects.all()
moderators = Group.objects.get(name='moderators').user_set.all()
for extension in extensions:
authors = extension.authors.all()
for recipient in authors:
_follow_with_log(recipient, extension, Flag.AUTHOR)
for recipient in moderators:
_follow_with_log(recipient, extension, Flag.MODERATOR)
approval_activity_items = ApprovalActivity.objects.all().select_related('extension', 'user')
for item in approval_activity_items:
_follow_with_log(item.user, item.extension, Flag.REVIEWER)
def _follow_with_log(user, target, flag):
follow(user, target, send_action=False, flag=flag)
logger.info(f'{user} follows {target} with flag={flag}')

View File

@ -0,0 +1,52 @@
"""Send user notifications as emails, at most once delivery."""
import logging
from django.conf import settings
from django.core.mail import send_mail
from django.core.management.base import BaseCommand
from django.utils import timezone
from notifications.models import Notification
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class Command(BaseCommand):
def handle(self, *args, **options): # noqa: D102
unprocessed_notifications = Notification.objects.filter(processed_by_mailer_at=None)
for n in unprocessed_notifications:
logger.info(f'processing Notification pk={n.pk}')
n.processed_by_mailer_at = timezone.now()
recipient = n.recipient
if not recipient.is_subscribed_to_notification_emails:
logger.info(f'{recipient} is not subscribed, skipping')
n.save()
continue
# check that email is confirmed to avoid spamming unsuspecting email owners
if recipient.confirmed_email_at is None:
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()
logger.info(f'sending an email to {recipient}: {n.action}')
send_notification_email(n)
def send_notification_email(notification):
# TODO construct a proper phrase, depending on the verb,
# possibly share a template with NotificationsView
subject, message = notification.format_email()
send_mail(
subject,
message,
settings.DEFAULT_FROM_EMAIL,
[notification.recipient.email],
)

View File

@ -0,0 +1,33 @@
# Generated by Django 4.2.11 on 2024-04-16 15:56
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('actstream', '0003_add_follow_flag'),
]
operations = [
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('action', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='actstream.action')),
('email_sent', models.BooleanField(default=False)),
('processed_by_mailer_at', models.DateTimeField(default=None, null=True)),
('read_at', models.DateTimeField(default=None, null=True)),
],
options={
'indexes': [models.Index(fields=['processed_by_mailer_at'], name='notificatio_process_fc95bc_idx'), models.Index(fields=['recipient', 'read_at'], name='notificatio_recipie_564b1f_idx')],
'unique_together': {('recipient', 'action')},
},
),
]

View File

56
notifications/models.py Normal file
View File

@ -0,0 +1,56 @@
from actstream.models import Action
from django.contrib.auth import get_user_model
from django.db import models
from constants.activity import Verb
from utils import absolutify
User = get_user_model()
class Notification(models.Model):
"""Notification records are created in Action's post_save signal.
When a user marks a notification as read, read_at is set.
send_notification_emails management command runs periodically in background and sends all
notifications that haven't been processed yet, read_at is not checked when sending emails.
email_sent flag is used only to record the fact that we attempted to send an email.
A user can unsubscribe from notification emails in their profile settings.
"""
recipient = models.ForeignKey(User, null=False, on_delete=models.CASCADE)
action = models.ForeignKey(Action, null=False, on_delete=models.CASCADE)
email_sent = models.BooleanField(default=False, null=False)
processed_by_mailer_at = models.DateTimeField(default=None, null=True)
read_at = models.DateTimeField(default=None, null=True)
class Meta:
indexes = [
models.Index(fields=['processed_by_mailer_at']),
models.Index(fields=['recipient', 'read_at']),
]
unique_together = ['recipient', 'action']
def format_email(self):
action = self.action
subject = f'New Activity: {action.actor.full_name} {action.verb} {action.target}'
url = self.get_absolute_url()
mesage = f'{action.actor.full_name} {action.verb} {action.target}: {url}'
return (subject, mesage)
def get_absolute_url(self):
if self.action.verb == Verb.RATED_EXTENSION:
url = self.action.target.get_ratings_url()
elif self.action.verb in [
Verb.APPROVED,
Verb.COMMENTED,
Verb.REQUESTED_CHANGES,
Verb.REQUESTED_REVIEW,
]:
url = self.action.target.get_review_url()
elif self.action.action_object is not None:
url = self.action.action_object.get_absolute_url()
else:
url = self.action.target.get_absolute_url()
# TODO? url cloacking to mark visited notifications as read automatically
return absolutify(url)

57
notifications/signals.py Normal file
View File

@ -0,0 +1,57 @@
import logging
from actstream.models import Action, Follow
from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.dispatch import receiver
from constants.activity import Flag, Verb
from notifications.models import Notification
logger = logging.getLogger(__name__)
VERB2FLAGS = {
Verb.APPROVED: [Flag.AUTHOR, Flag.REVIEWER],
Verb.COMMENTED: [Flag.AUTHOR, Flag.REVIEWER],
Verb.RATED_EXTENSION: [Flag.AUTHOR],
Verb.REPORTED_EXTENSION: [Flag.MODERATOR],
Verb.REPORTED_RATING: [Flag.MODERATOR],
Verb.REQUESTED_CHANGES: [Flag.AUTHOR, Flag.REVIEWER],
Verb.REQUESTED_REVIEW: [Flag.MODERATOR, Flag.REVIEWER],
}
@receiver(post_save, sender=Action)
def _create_notifications(
sender: object,
instance: Action,
created: bool,
raw: bool,
**kwargs: object,
) -> None:
if raw:
return
if not created:
return
if not instance.target:
logger.warning(f'ignoring an unexpected Action without a target, verb={instance.verb}')
return
notifications = []
flags = VERB2FLAGS.get(instance.verb, None)
if not flags:
logger.warning(f'no follower flags for verb={instance.verb}, nobody will be notified')
return
followers = Follow.objects.for_object(instance.target).filter(flag__in=flags)
user_ids = followers.values_list('user', flat=True)
followers = get_user_model().objects.filter(id__in=user_ids)
for recipient in followers:
if recipient == instance.actor:
continue
notifications.append(Notification(recipient=recipient, action=instance))
if len(notifications) > 0:
Notification.objects.bulk_create(notifications)

View File

@ -0,0 +1,22 @@
{% extends "common/base.html" %}
{% load i18n %}
{% block page_title %}{% blocktranslate %}Notifications{% endblocktranslate %}{% endblock page_title %}
{% block content %}
{% if notification_list %}
{% for notification in notification_list %}
<div class="row">
{{ notification.action }}
{% if notification.read_at %}
{% else %}
{% blocktranslate %}Mark as read{% endblocktranslate %}
{% endif %}
</div>
{% endfor %}
{% else %}
<p>
{% blocktranslate %}You have no notifications{% endblocktranslate %}
</p>
{% endif %}
{% endblock content %}

View File

@ -0,0 +1,142 @@
from pathlib import Path
from django.test import TestCase
from django.urls import reverse
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, create_moderator
from files.models import File
from notifications.models import Notification
from reviewers.models import ApprovalActivity
TEST_FILES_DIR = Path(__file__).resolve().parent / '../../extensions/tests/files'
class TestTasks(TestCase):
fixtures = ['dev', 'licenses']
def test_ratings(self):
extension = create_approved_version(ratings=[]).extension
author = extension.authors.first()
notification_nr = Notification.objects.filter(recipient=author).count()
some_user = UserFactory()
self.client.force_login(some_user)
url = extension.get_rate_url()
response = self.client.post(url, {'score': 3, 'text': 'rating text'})
self.assertEqual(response.status_code, 302)
self.assertEqual(extension.ratings.count(), 1)
new_notification_nr = Notification.objects.filter(recipient=author).count()
self.assertEqual(new_notification_nr, notification_nr + 1)
def test_abuse(self):
extension = create_approved_version(ratings=[]).extension
moderator = create_moderator()
notification_nr = Notification.objects.filter(recipient=moderator).count()
some_user = UserFactory()
self.client.force_login(some_user)
url = extension.get_report_url()
self.client.post(
url,
{
'message': 'test message',
'reason': '127',
'version': '',
},
)
new_notification_nr = Notification.objects.filter(recipient=moderator).count()
self.assertEqual(new_notification_nr, notification_nr + 1)
def test_new_extension_submitted(self):
moderator = create_moderator()
notification_nr = Notification.objects.filter(recipient=moderator).count()
some_user = UserFactory()
file_data = {
'metadata': {
'tagline': 'Get insight on the complexity of an edit',
'id': 'edit_breakdown',
'name': 'Edit Breakdown',
'version': '0.1.0',
'blender_version_min': '4.2.0',
'type': 'add-on',
'schema_version': "1.0.0",
},
'file_hash': 'sha256:4f3664940fc41641c7136a909270a024bbcfb2f8523a06a0d22f85c459b0b1ae',
'size_bytes': 53959,
'tags': ['Sequencer'],
'version_str': '0.1.0',
'slug': 'edit-breakdown',
}
file = FileFactory(
type=File.TYPES.BPY,
user=some_user,
original_hash=file_data['file_hash'],
hash=file_data['file_hash'],
metadata=file_data['metadata'],
)
create_version(
file=file,
extension__name=file_data['metadata']['name'],
extension__slug=file_data['metadata']['id'].replace("_", "-"),
extension__website=None,
tagline=file_data['metadata']['tagline'],
version=file_data['metadata']['version'],
blender_version_min=file_data['metadata']['blender_version_min'],
schema_version=file_data['metadata']['schema_version'],
)
self.client.force_login(some_user)
data = {
# Most of these values should come from the form's initial values, set in the template
# Version fields
'release_notes': 'initial release',
# Extension fields
'description': 'Rather long and verbose description',
'support': 'https://example.com/issues',
# Previews
'form-TOTAL_FORMS': ['2'],
'form-INITIAL_FORMS': ['0'],
'form-MIN_NUM_FORMS': ['0'],
'form-MAX_NUM_FORMS': ['1000'],
'form-0-id': '',
'form-0-caption': ['First Preview Caption Text'],
'form-1-id': '',
'form-1-caption': ['Second Preview Caption Text'],
# Submit for Approval.
'submit_draft': '',
}
file_name1 = 'test_preview_image_0001.png'
file_name2 = 'test_preview_image_0002.png'
with open(TEST_FILES_DIR / file_name1, 'rb') as fp1, open(
TEST_FILES_DIR / file_name2, 'rb'
) as fp2:
files = {
'form-0-source': fp1,
'form-1-source': fp2,
}
self.client.post(file.get_submit_url(), {**data, **files})
new_notification_nr = Notification.objects.filter(recipient=moderator).count()
self.assertEqual(new_notification_nr, notification_nr + 1)
def test_approval_queue_activity(self):
extension = create_approved_version(ratings=[]).extension
author = extension.authors.first()
moderator = create_moderator()
some_user = UserFactory()
notification_nrs = {}
for user in [author, moderator, some_user]:
notification_nrs[user.pk] = Notification.objects.filter(recipient=user).count()
# both moderator and some_user start following only after their first comment
self._leave_a_comment(moderator, extension, 'need to check this')
self._leave_a_comment(some_user, extension, 'this is bad')
self._leave_a_comment(moderator, extension, 'thanks for the heads up')
new_notification_nrs = {}
for user in [author, moderator, some_user]:
new_notification_nrs[user.pk] = Notification.objects.filter(recipient=user).count()
self.assertEqual(new_notification_nrs[author.pk], notification_nrs[author.pk] + 3)
self.assertEqual(new_notification_nrs[moderator.pk], notification_nrs[moderator.pk] + 1)
self.assertEqual(new_notification_nrs[some_user.pk], notification_nrs[some_user.pk] + 1)
def _leave_a_comment(self, user, extension, text):
self.client.force_login(user)
url = reverse('reviewers:approval-comment', args=[extension.slug])
self.client.post(url, {'type': ApprovalActivity.ActivityType.COMMENT, 'message': text})

25
notifications/urls.py Normal file
View File

@ -0,0 +1,25 @@
from django.urls import path, include
import notifications.views as views
app_name = 'notifications'
urlpatterns = [
path(
'notifications/',
include(
[
path('', views.NotificationsView.as_view(), name='notifications'),
path(
'mark-read-all/',
views.MarkReadAllView.as_view(),
name='notifications-mark-read-all',
),
path(
'<int:pk>/mark-read/',
views.MarkReadView.as_view(),
name='notifications-mark-read',
),
],
),
),
]

49
notifications/views.py Normal file
View File

@ -0,0 +1,49 @@
"""Notifications pages."""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseForbidden
from django.http.response import JsonResponse
from django.utils import timezone
from django.views.generic import ListView
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView
from django.views import View
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)
class MarkReadAllView(LoginRequiredMixin, FormView):
model = Notification
raise_exception = True
def post(self, request, *args, **kwargs):
"""Mark all previously unread notifications as read."""
unread = self.model.objects.filter(recipient=request.user, read_at__isnull=True)
now = timezone.now()
for notification in unread:
notification.read_at = now
Notification.objects.bulk_update(unread, ['read_at'])
return JsonResponse({})
class MarkReadView(LoginRequiredMixin, SingleObjectMixin, View):
model = Notification
raise_exception = True
def post(self, request, *args, **kwargs):
notification = self.get_object()
if notification.recipient != request.user:
return HttpResponseForbidden()
notification.read_at = timezone.now()
notification.save(update_fields=['read_at'])
return JsonResponse({})

View File

@ -5,6 +5,7 @@
ansible.builtin.systemd: name={{ item }} daemon_reload=yes state=restarted enabled=yes ansible.builtin.systemd: name={{ item }} daemon_reload=yes state=restarted enabled=yes
with_items: with_items:
- "{{ service_name }}" - "{{ service_name }}"
- "{{ service_name }}-background"
tags: tags:
- always - always

View File

@ -9,6 +9,8 @@
- name: Installing required packages - name: Installing required packages
ansible.builtin.apt: name={{ item }} state=present ansible.builtin.apt: name={{ item }} state=present
with_items: with_items:
- clamav-daemon
- clamav-unofficial-sigs
- git - git
- libpq-dev - libpq-dev
- nginx-full - nginx-full

View File

@ -7,6 +7,12 @@
with_fileglob: with_fileglob:
- ../templates/other-services/*.service - ../templates/other-services/*.service
- name: Enabling clamav-daemon
ansible.builtin.systemd:
name: clamav-daemon
state: started
enabled: true
- name: Enabling systemd services - name: Enabling systemd services
ansible.builtin.systemd: ansible.builtin.systemd:
name: "{{ service_name }}-{{ item }}" name: "{{ service_name }}-{{ item }}"

View File

@ -11,8 +11,6 @@ ExecReload=/bin/kill -s HUP $MAINPID
Restart=always Restart=always
KillMode=mixed KillMode=mixed
Type=notify Type=notify
StandardError=syslog
StandardOutput=syslog
SyslogIdentifier={{ service_name }} SyslogIdentifier={{ service_name }}
NotifyAccess=all NotifyAccess=all
WorkingDirectory={{ dir.source }} WorkingDirectory={{ dir.source }}

View File

@ -4,6 +4,4 @@ Description=restart {{ background_service_name }} task handler
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/bin/systemctl restart {{ background_service_name }} ExecStart=/bin/systemctl restart {{ background_service_name }}
StandardError=syslog
StandardOutput=syslog
SyslogIdentifier={{ service_name }} SyslogIdentifier={{ service_name }}

View File

@ -7,11 +7,9 @@ User={{ user }}
Group={{ group }} Group={{ group }}
EnvironmentFile={{ env_file }} EnvironmentFile={{ env_file }}
ExecStart={{ dir.source }}/.venv/bin/python {{ dir.source }}/manage.py process_tasks ExecStart={{ dir.source }}/.venv/bin/python {{ dir.source }}/manage.py process_tasks
ExecStop=kill -s SIGTSTP $MAINPID
Restart=always Restart=always
KillSignal=SIGQUIT
Type=idle Type=idle
StandardError=syslog
StandardOutput=syslog
SyslogIdentifier={{ service_name }} SyslogIdentifier={{ service_name }}
NotifyAccess=all NotifyAccess=all
WorkingDirectory={{ dir.source }} WorkingDirectory={{ dir.source }}

View File

@ -7,8 +7,6 @@ User={{ user }}
Group={{ group }} Group={{ group }}
EnvironmentFile={{ env_file }} EnvironmentFile={{ env_file }}
ExecStart={{ dir.source }}/.venv/bin/python {{ dir.source }}/manage.py clearsessions ExecStart={{ dir.source }}/.venv/bin/python {{ dir.source }}/manage.py clearsessions
StandardError=syslog
StandardOutput=syslog
SyslogIdentifier={{ service_name }} SyslogIdentifier={{ service_name }}
NotifyAccess=all NotifyAccess=all
WorkingDirectory={{ dir.source }} WorkingDirectory={{ dir.source }}

View File

@ -7,8 +7,6 @@ User={{ user }}
Group={{ group }} Group={{ group }}
EnvironmentFile={{ env_file }} EnvironmentFile={{ env_file }}
ExecStart={{ dir.source }}/.venv/bin/python {{ dir.source }}/manage.py queue_deletion_request ExecStart={{ dir.source }}/.venv/bin/python {{ dir.source }}/manage.py queue_deletion_request
StandardError=syslog
StandardOutput=syslog
SyslogIdentifier={{ service_name }} SyslogIdentifier={{ service_name }}
NotifyAccess=all NotifyAccess=all
WorkingDirectory={{ dir.source }} WorkingDirectory={{ dir.source }}

View File

@ -0,0 +1,20 @@
[Unit]
Description=Send notification emails for {{ project_name }} {{ env|capitalize }}
[Service]
Type=oneshot
User={{ user }}
Group={{ group }}
EnvironmentFile={{ env_file }}
ExecStart={{ dir.source }}/.venv/bin/python {{ dir.source }}/manage.py send_notification_emails
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,8 @@
[Timer]
OnCalendar=*-*-* *:*:00
RandomizedDelaySec=3
AccuracySec=1us
Persistent=true
[Install]
WantedBy=timer.target

View File

@ -7,8 +7,6 @@ User={{ user }}
Group={{ group }} Group={{ group }}
EnvironmentFile={{ env_file }} EnvironmentFile={{ env_file }}
ExecStart={{ dir.source }}/.venv/bin/python {{ dir.source }}/manage.py write_stats ExecStart={{ dir.source }}/.venv/bin/python {{ dir.source }}/manage.py write_stats
StandardError=syslog
StandardOutput=syslog
SyslogIdentifier={{ service_name }} SyslogIdentifier={{ service_name }}
NotifyAccess=all NotifyAccess=all
WorkingDirectory={{ dir.source }} WorkingDirectory={{ dir.source }}

View File

@ -16,12 +16,12 @@ class RatingTypeFilter(admin.SimpleListFilter):
parameter_name = 'type' parameter_name = 'type'
def lookups(self, request, model_admin): def lookups(self, request, model_admin):
""" """Return a list of lookup option tuples.
Returns a list of tuples. The first element in each
tuple is the coded value for the option that will The first element in each tuple is the coded value
appear in the URL query. The second element is the for the option that will appear in the URL query.
human-readable name for the option that will appear The second element is the human-readable name for
in the right sidebar. the option that will appear in the right sidebar.
""" """
return ( return (
('rating', 'User Rating'), ('rating', 'User Rating'),
@ -29,10 +29,10 @@ class RatingTypeFilter(admin.SimpleListFilter):
) )
def queryset(self, request, queryset): def queryset(self, request, queryset):
""" """Return the filtered queryset.
Returns the filtered queryset based on the value
provided in the query string and retrievable via Filter based on the value provided in the query string
`self.value()`. and retrievable via `self.value()`.
""" """
if self.value() == 'rating': if self.value() == 'rating':
return queryset.filter(reply_to__isnull=True) return queryset.filter(reply_to__isnull=True)
@ -53,7 +53,6 @@ class RatingAdmin(admin.ModelAdmin):
readonly_fields = ( readonly_fields = (
'date_created', 'date_created',
'date_modified', 'date_modified',
'date_deleted',
'extension', 'extension',
'version', 'version',
'text', 'text',
@ -63,7 +62,7 @@ class RatingAdmin(admin.ModelAdmin):
fields = ('status',) + readonly_fields fields = ('status',) + readonly_fields
list_display = ( list_display = (
'date_created', 'date_created',
'is_deleted', 'extension',
'user', 'user',
'ip_address', 'ip_address',
'score', 'score',
@ -71,7 +70,7 @@ class RatingAdmin(admin.ModelAdmin):
'status', 'status',
'truncated_text', 'truncated_text',
) )
list_filter = ('status', RatingTypeFilter, 'score', 'date_deleted') list_filter = ('status', RatingTypeFilter, 'score')
actions = ('delete_selected',) actions = ('delete_selected',)
list_select_related = ('user',) # For extension/reply_to see get_queryset() list_select_related = ('user',) # For extension/reply_to see get_queryset()
@ -94,11 +93,5 @@ class RatingAdmin(admin.ModelAdmin):
is_reply.boolean = True is_reply.boolean = True
is_reply.admin_order_field = 'reply_to' is_reply.admin_order_field = 'reply_to'
def is_deleted(self, obj):
return bool(obj.date_deleted)
is_deleted.boolean = True
is_deleted.admin_order_field = 'date_deleted'
admin.site.register(Rating, RatingAdmin) admin.site.register(Rating, RatingAdmin)

View File

@ -6,4 +6,7 @@ class RatingsConfig(AppConfig):
name = 'ratings' name = 'ratings'
def ready(self): def ready(self):
from actstream import registry
import ratings.signals # noqa: F401 import ratings.signals # noqa: F401
registry.register(self.get_model('Rating'))

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.11 on 2024-04-18 08:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('ratings', '0004_alter_rating_status'),
]
operations = [
migrations.RemoveField(
model_name='rating',
name='date_deleted',
),
]

Some files were not shown because too many files have changed in this diff Show More