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
18 changed files with 201 additions and 44 deletions
Showing only changes of commit e3a4ad45ae - Show all commits

View File

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

View File

@ -2,7 +2,9 @@ from typing import Set, Tuple, Mapping, Any
import copy import copy
import logging import logging
from django.contrib.admin.models import DELETION
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core import serializers
from django.db import models from django.db import models
from django.shortcuts import reverse from django.shortcuts import reverse
from django.utils import timezone from django.utils import timezone
@ -45,7 +47,36 @@ class CreatedModifiedMixin(models.Model):
return f'{path}?{query}' return f'{path}?{query}'
class TrackChangesMixin(models.Model): class RecordDeletionMixin:
def serialise(self) -> dict:
data = serializers.serialize('python', [self])[0]
data['fields']['pk'] = data['pk']
return data['fields']
def record_deletion(self):
"""Create a LogEntry describing a deletion of this object."""
msg_args = {'type': type(self), 'pk': self.pk}
logger.info('Deleting %(type)s pk=%(pk)s', msg_args)
if hasattr(self, 'cannot_be_deleted_reasons'):
cannot_be_deleted_reasons = self.cannot_be_deleted_reasons
if len(cannot_be_deleted_reasons) > 0:
# This shouldn't happen: prior validation steps should have taken care of this.
msg_args['reasons'] = cannot_be_deleted_reasons
logger.error("%(type)s pk=%(pk)s is being deleted but it %(reasons)s", msg_args)
state = self.serialise()
message = [
{
'deleted': {
'name': str(self._meta.verbose_name),
'object': repr(self),
'old_state': state,
},
}
]
attach_log_entry(self, message, action_flag=DELETION)
class TrackChangesMixin(RecordDeletionMixin, models.Model):
"""Tracks changes of Django models. """Tracks changes of Django models.
Tracks which fields have changed in the save() function, so that Tracks which fields have changed in the save() function, so that

View File

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

View File

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

View File

@ -22,7 +22,7 @@ class VersionStringField(SemanticVersionField):
return str(value) return str(value)
def value_to_string(self, obj): def value_to_string(self, obj):
value = self._get_val_from_obj(obj) value = self.value_from_object(obj)
return self.get_prep_value(value) return self.get_prep_value(value)
def from_json(self, json_str): def from_json(self, json_str):

View File

@ -10,7 +10,7 @@ from django.db.models import F, Q, Count
from django.urls import reverse from django.urls import reverse
from common.fields import FilterableManyToManyField from common.fields import FilterableManyToManyField
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin from common.model_mixins import CreatedModifiedMixin, RecordDeletionMixin, TrackChangesMixin
from constants.base import ( from constants.base import (
AUTHOR_ROLE_CHOICES, AUTHOR_ROLE_CHOICES,
AUTHOR_ROLE_DEV, AUTHOR_ROLE_DEV,
@ -651,7 +651,7 @@ class Maintainer(CreatedModifiedMixin, models.Model):
] ]
class Preview(CreatedModifiedMixin, models.Model): class Preview(CreatedModifiedMixin, RecordDeletionMixin, models.Model):
extension = models.ForeignKey(Extension, on_delete=models.CASCADE) extension = models.ForeignKey(Extension, on_delete=models.CASCADE)
file = models.ForeignKey( file = models.ForeignKey(
'files.File', related_name='extension_preview', on_delete=models.CASCADE 'files.File', related_name='extension_preview', on_delete=models.CASCADE

View File

@ -18,15 +18,14 @@ User = get_user_model()
@receiver(pre_delete, sender=extensions.models.Extension) @receiver(pre_delete, sender=extensions.models.Extension)
@receiver(pre_delete, sender=extensions.models.Preview) @receiver(pre_delete, sender=extensions.models.Preview)
@receiver(pre_delete, sender=extensions.models.Version) @receiver(pre_delete, sender=extensions.models.Version)
def _log_extension_delete(sender: object, instance: object, **kwargs: object) -> None: def _log_deletion(
cannot_be_deleted_reasons = instance.cannot_be_deleted_reasons sender: object,
if len(cannot_be_deleted_reasons) > 0: instance: Union[
# This shouldn't happen: prior validation steps should have taken care of this. extensions.models.Extension, extensions.models.Version, extensions.models.Preview
# raise ValidationError({'__all__': cannot_be_deleted_reasons}) ],
args = {'sender': sender, 'pk': instance.pk, 'reasons': cannot_be_deleted_reasons} **kwargs: object,
logger.error("%(sender)s pk=%(pk)s is being deleted but it %(reasons)s", args) ) -> None:
instance.record_deletion()
logger.info('Deleting %s pk=%s "%s"', sender, instance.pk, str(instance))
@receiver(post_delete, sender=extensions.models.Preview) @receiver(post_delete, sender=extensions.models.Preview)

View File

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

View File

@ -36,5 +36,6 @@ def _scan_new_file(
@receiver(pre_delete, sender=files.models.File) @receiver(pre_delete, sender=files.models.File)
def _log_file_delete(sender: object, instance: files.models.File, **kwargs: object) -> None: @receiver(pre_delete, sender=files.models.FileValidation)
logger.info('Deleting file pk=%s source=%s', instance.pk, instance.source.name) def _log_deletion(sender: object, instance: files.models.File, **kwargs: object) -> None:
instance.record_deletion()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
import common.help_texts import common.help_texts
from extensions.models import Extension from extensions.models import Extension
from common.model_mixins import CreatedModifiedMixin from common.model_mixins import CreatedModifiedMixin, RecordDeletionMixin
from utils import absolutify, send_mail from utils import absolutify, send_mail
from constants.base import EXTENSION_TYPE_CHOICES from constants.base import EXTENSION_TYPE_CHOICES
@ -74,7 +74,7 @@ class ReviewerSubscription(CreatedModifiedMixin, models.Model):
) )
class ApprovalActivity(CreatedModifiedMixin, models.Model): class ApprovalActivity(CreatedModifiedMixin, RecordDeletionMixin, models.Model):
class ActivityType(models.TextChoices): class ActivityType(models.TextChoices):
COMMENT = "COM", _("Comment") COMMENT = "COM", _("Comment")
APPROVED = "APR", _("Approved") APPROVED = "APR", _("Approved")

View File

@ -1,6 +1,6 @@
from actstream import action from actstream import action
from actstream.actions import follow from actstream.actions import follow
from django.db.models.signals import post_save from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from constants.activity import Flag, Verb from constants.activity import Flag, Verb
@ -37,3 +37,8 @@ def _create_action_from_review_and_follow(
action_object=instance, action_object=instance,
target=instance.extension, target=instance.extension,
) )
@receiver(pre_delete, sender=ApprovalActivity)
def _log_deletion(sender: object, instance: ApprovalActivity, **kwargs: object) -> None:
instance.record_deletion()

View File

@ -168,7 +168,6 @@
<div class="btn-row ms-3 w-100 justify-content-end"> <div class="btn-row ms-3 w-100 justify-content-end">
{% if is_maintainer or request.user.is_moderator %} {% if is_maintainer or request.user.is_moderator %}
{% include "common/components/field.html" with field=form.type %} {% include "common/components/field.html" with field=form.type %}
{% endif %}
<button type="submit" id="activity-submit" class="btn btn-primary"> <button type="submit" id="activity-submit" class="btn btn-primary">
<span>{% trans "Comment" %}</span> <span>{% trans "Comment" %}</span>
</button> </button>

View File

@ -4,6 +4,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.views.generic import DetailView, FormView from django.views.generic import DetailView, FormView
from django.shortcuts import reverse from django.shortcuts import reverse
import django.forms
from files.models import File from files.models import File
from extensions.models import Extension from extensions.models import Extension
@ -39,7 +40,9 @@ class ExtensionsApprovalDetailView(DetailView):
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
form = ctx['comment_form'] = CommentForm() form = ctx['comment_form'] = CommentForm()
# Remove 'Approved' status from dropdown it not moderator # Remove 'Approved' status from dropdown it not moderator
if not (self.request.user.is_moderator or self.request.user.is_superuser): filtered_activity_types = ApprovalActivity.ActivityType.choices
user = self.request.user
if not (user.is_moderator or user.is_superuser):
filtered_activity_types = [ filtered_activity_types = [
t t
for t in ApprovalActivity.ActivityType.choices for t in ApprovalActivity.ActivityType.choices
@ -49,8 +52,17 @@ class ExtensionsApprovalDetailView(DetailView):
ApprovalActivity.ActivityType.AWAITING_CHANGES, ApprovalActivity.ActivityType.AWAITING_CHANGES,
] ]
] ]
if not self.object.has_maintainer(user):
# Other accounts can only comment
filtered_activity_types = [
t
for t in ApprovalActivity.ActivityType.choices
if t[0] == ApprovalActivity.ActivityType.COMMENT
]
form.fields['type'].choices = filtered_activity_types form.fields['type'].choices = filtered_activity_types
form.fields['type'].widget.choices = filtered_activity_types form.fields['type'].widget.choices = filtered_activity_types
if len(filtered_activity_types) == 1:
form.fields['type'].widget = django.forms.HiddenInput()
return ctx return ctx