Extensions list: sort_by parameter #159

Merged
Márton Lente merged 36 commits from filter-sort into main 2024-06-03 12:57:45 +02:00
85 changed files with 1938 additions and 607 deletions
Showing only changes of commit 02fec4db8f - Show all commits

View File

@ -47,7 +47,7 @@
<td title="{{ report.date_created }}">{{ report.date_created|naturaltime_compact }}</td> <td title="{{ report.date_created }}">{{ report.date_created|naturaltime_compact }}</td>
<td> <td>
<a href="{{ report.get_absolute_url }}" class="text-decoration-none"> <a href="{{ report.get_absolute_url }}" class="text-decoration-none">
{% include "common/components/status.html" with object=report class="d-block" %} {% include "common/components/status.html" with object=report classes="d-block" %}
</a> </a>
</td> </td>
</tr> </tr>

1
apitokens/__init__.py Normal file
View File

@ -0,0 +1 @@
default_app_config = 'apitokens.apps.TokensConfig'

6
apitokens/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class TokensConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apitokens'

View File

@ -0,0 +1,33 @@
from django.utils import timezone
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from .models import UserToken
from utils import clean_ip_address
class UserTokenAuthentication(BaseAuthentication):
def authenticate(self, request):
auth_header = request.headers.get('Authorization')
if not auth_header:
return None
try:
token_type, token_key = auth_header.split()
if token_type.lower() != 'bearer':
return None
except ValueError:
return None
try:
token_hash = UserToken.generate_hash(token_key=token_key)
token = UserToken.objects.get(token_hash=token_hash)
except UserToken.DoesNotExist:
raise AuthenticationFailed('Invalid token')
token.ip_address_last_access = clean_ip_address(request)
token.date_last_access = timezone.now()
token.save(update_fields={'ip_address_last_access', 'date_last_access'})
return (token.user, token)

View File

@ -0,0 +1,30 @@
# Generated by Django 4.2.11 on 2024-05-25 09:43
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),
]
operations = [
migrations.CreateModel(
name='UserToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('token_prefix', models.CharField(editable=False, max_length=5)),
('token_hash', models.CharField(editable=False, max_length=64, unique=True)),
('date_created', models.DateTimeField(auto_now_add=True)),
('date_last_access', models.DateTimeField(blank=True, editable=False, null=True)),
('ip_address_last_access', models.GenericIPAddressField(null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

36
apitokens/models.py Normal file
View File

@ -0,0 +1,36 @@
import hashlib
import secrets
from django.db import models
from django.urls import reverse
from users.models import User
class UserToken(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tokens')
name = models.CharField(max_length=255)
token_prefix = models.CharField(max_length=5, editable=False)
token_hash = models.CharField(max_length=64, editable=False, unique=True)
date_created = models.DateTimeField(auto_now_add=True)
date_last_access = models.DateTimeField(null=True, blank=True, editable=False)
ip_address_last_access = models.GenericIPAddressField(protocol='both', null=True)
def get_delete_url(self):
return reverse('apitokens:delete', kwargs={'pk': self.pk})
def __str__(self):
return f"{self.user.username} - {self.token_prefix} - {self.name}"
@staticmethod
def generate_hash(token_key: str) -> str:
return hashlib.sha256(token_key.encode()).hexdigest()
@staticmethod
def generate_token_key() -> str:
return secrets.token_urlsafe(32)
@classmethod
def generate_token_prefix(cls, token_key: str) -> str:
token_prefix_length = cls._meta.get_field('token_prefix').max_length
return token_key[:token_prefix_length]

View File

@ -0,0 +1,30 @@
{% 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>
Delete API Token?
</h2>
<p>
This will delete the token <b>{{ object.name }}</b> created at {{ object.date_created }}<br />
Any application which were relying on this token will require a new token.
</p>
<div class="btn-row-fluid">
<a href="{% url 'apitokens:list' %}" class="btn">
<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

@ -0,0 +1,37 @@
{% 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>
User API Token
</h2>
<p>
Create a new user token to be used with the <a href="{% url 'swagger' %}">API</a>.
</p>
<form method="post">
{% csrf_token %}
<div class="mb-2">
<label for="id_name">API Token Name:</label>
<input type="text" id="id_name" name="name" class="form-control" placeholder="Example Name" required>
{% if form.errors.name %}
<div class="warning">{{ form.errors.name }}</div>
{% endif %}
</div>
<div class="btn-row-fluid">
<a href="{% url 'apitokens:list' %}" class="btn">
<i class="i-cancel"></i>
<span>{% trans 'Cancel' %}</span>
</a>
<button type="submit" class="btn btn-block btn-success">
<i class="i-plus"></i>
<span>{% trans 'Create API Token' %}</span>
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,62 @@
{% extends 'users/settings/base.html' %}
{% block settings %}
<h1 class="mb-3">Tokens</h1>
<div class="row">
<div class="col">
<div class="mb-3">
List of user tokens to automate tasks via the <a href="{% url 'swagger' %}">API</a>.
</div>
{% if tokens %}
<table class="table table-hover">
<thead>
<tr>
<th class="text-nowrap">
Name
</th>
<th class="text-nowrap">
Token Begin
</th>
<th class="text-nowrap">
Created At
</th>
<th class="text-nowrap">
Last Access
</th>
</tr>
</thead>
<tbody>
{% for token in tokens %}
<tr>
<td class="text-nowrap w-100">{{token.name}}</td>
<td class="text-muted text-nowrap">{{token.token_prefix}}...</td>
<td class="text-nowrap">{{token.date_created}}</td>
<td class="text-nowrap">
{{ token.date_last_access|default_if_none:"-" }}
</td>
<td>
<div class="dropdown">
<button class="btn btn-link dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="token-{{ token.token_prefix }}">
<i class="i-more-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-right js-dropdown-menu" id="token-{{ token.token_prefix }}">
<li>
<a class="dropdown-item" href="{{ token.get_delete_url }}">
<i class="i-trash"></i>Delete token
</a>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<br/>
<a href="{% url 'apitokens:create' %}" class="btn btn-primary"><i class="i-plus"></i>Token</a>
</div>
</div>
{% endblock settings %}

View File

View File

@ -0,0 +1,74 @@
from datetime import datetime
from django.utils import timezone
from django.test import TestCase
from django.urls import reverse
from apitokens.models import UserToken
from common.tests.factories.users import UserFactory
from common.tests.utils import create_user_token
class UserTokenTest(TestCase):
def setUp(self) -> None:
self.user = UserFactory()
self.client.force_login(self.user)
self.assertEqual(UserToken.objects.count(), 0)
return super().setUp()
def test_token_displayed_only_once(self):
response = self.client.post(
reverse('apitokens:create'),
{
'name': 'Test Token',
},
)
self.assertEqual(response.status_code, 302)
self.assertRedirects(response, reverse('apitokens:list'))
self.assertEqual(UserToken.objects.count(), 1)
token = UserToken.objects.first()
# Check if the success message with the token value is displayed
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 2)
token_key = messages[0].message
self.assertIn(token.token_prefix, token_key)
self.assertIn('Your new token has been generated', messages[1].message)
token_hash = UserToken.generate_hash(token_key)
self.assertEqual(token, UserToken.objects.get(token_hash=token_hash))
# Verify the token value is shown only on the creation page
response = self.client.get(reverse('apitokens:list'))
self.assertNotContains(response, token_key)
def test_list_page_does_not_display_full_token_value(self):
token, token_key = create_user_token(user=self.user, name='Test Token')
response = self.client.get(reverse('apitokens:list'))
self.assertContains(response, str(token.token_prefix))
self.assertNotContains(response, str(token_key))
def test_list_page_shows_last_access_time(self):
token = UserToken.objects.create(user=self.user, name='Test Token')
# Create a timezone-aware datetime object.
date_last_access_str = '1994-01-02 10:10:36'
date_last_access_naive = datetime.strptime(date_last_access_str, '%Y-%m-%d %H:%M:%S')
date_last_access_aware = timezone.make_aware(
date_last_access_naive, timezone.get_default_timezone()
)
token.date_last_access = date_last_access_aware
# Format the datetime to match the expected response format.
formatted_date = (
date_last_access_aware.strftime('%b. %-d, %Y, %-I:%M %p')
.replace('AM', 'a.m.')
.replace('PM', 'p.m.')
)
token.save()
response = self.client.get(reverse('apitokens:list'))
self.assertContains(response, formatted_date)

11
apitokens/urls.py Normal file
View File

@ -0,0 +1,11 @@
from django.urls import path
import apitokens.views
app_name = 'apitokens'
urlpatterns = [
path('settings/tokens/', apitokens.views.TokensView.as_view(), name='list'),
path('tokens/create/', apitokens.views.CreateTokenView.as_view(), name='create'),
path('tokens/delete/<int:pk>/', apitokens.views.DeleteTokenView.as_view(), name='delete'),
]

78
apitokens/views.py Normal file
View File

@ -0,0 +1,78 @@
import logging
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.contrib import messages
from django.views.generic.edit import CreateView
from django.views.generic import ListView, DeleteView
from django.shortcuts import reverse
from django.utils.safestring import mark_safe
from django.urls import reverse_lazy
from .models import UserToken
logger = logging.getLogger(__name__)
class TokensView(LoginRequiredMixin, ListView):
model = UserToken
context_object_name = 'tokens'
def get_queryset(self):
return self.request.user.tokens.all()
class UserTokenForm(forms.ModelForm):
class Meta:
model = UserToken
fields = ('name',)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None) # Get user information from kwargs
super().__init__(*args, **kwargs)
def clean_name(self):
name = self.cleaned_data.get('name')
if UserToken.objects.filter(user=self.user, name=name).exists():
error_message = mark_safe(f'A token with this name already exists: <b>{name}</b>.')
raise forms.ValidationError(error_message)
return name
class CreateTokenView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
model = UserToken
form_class = UserTokenForm
template_name = 'apitokens/apitoken_create.html'
success_message = (
"Your new token has been generated. Copy it now as it will not be shown again."
)
def get_success_url(self):
return reverse('apitokens:list')
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
return kwargs
def form_valid(self, form):
form.instance.user = self.request.user
token_key = UserToken.generate_token_key()
form.instance.token_hash = UserToken.generate_hash(token_key)
form.instance.token_prefix = UserToken.generate_token_prefix(token_key)
messages.info(self.request, token_key)
return super().form_valid(form)
class DeleteTokenView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
model = UserToken
template_name = 'apitokens/apitoken_confirm_delete.html'
success_url = reverse_lazy('apitokens:list')
success_message = "Token deleted successfully"
def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)

@ -1 +1 @@
Subproject commit 6a52e5abcc118133b8cb51137b34bf856da716c4 Subproject commit 35a222528d621a198d25707e8399f61f0dc08fcc

View File

@ -60,6 +60,7 @@ INSTALLED_APPS = [
'rangefilter', 'rangefilter',
'reviewers', 'reviewers',
'stats', 'stats',
'apitokens',
'taggit', 'taggit',
'drf_spectacular', 'drf_spectacular',
'drf_spectacular_sidecar', 'drf_spectacular_sidecar',
@ -273,6 +274,8 @@ ACTSTREAM_SETTINGS = {
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_AUTHENTICATION_CLASSES': ('apitokens.authentication.UserTokenAuthentication',),
'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',),
} }
SPECTACULAR_SETTINGS = { SPECTACULAR_SETTINGS = {

View File

@ -40,6 +40,7 @@ urlpatterns = [
path('', include('teams.urls')), path('', include('teams.urls')),
path('', include('reviewers.urls')), path('', include('reviewers.urls')),
path('', include('notifications.urls')), path('', include('notifications.urls')),
path('', include('apitokens.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'),

View File

@ -33,6 +33,11 @@
function submitFormFileInputClear() { function submitFormFileInputClear() {
const submitFormFileInput = document.querySelector('.js-submit-form-file-input'); const submitFormFileInput = document.querySelector('.js-submit-form-file-input');
if (!submitFormFileInput) {
// Stop function execution if submitFormFileInput is not present
return;
}
submitFormFileInput.addEventListener('change', function(e) { submitFormFileInput.addEventListener('change', function(e) {
e.target.classList.remove('is-invalid'); e.target.classList.remove('is-invalid');
}); });
@ -51,7 +56,7 @@
}); });
} }
// Create finction commentForm // Create function commentForm
function commentForm() { function commentForm() {
const commentForm = document.querySelector('.js-comment-form'); const commentForm = document.querySelector('.js-comment-form');
if (!commentForm) { if (!commentForm) {
@ -131,6 +136,24 @@
init(); init();
} }
// Create function navGlobalLinkSearch
function navGlobalLinkSearch() {
const navGlobalLinkSearch = document.querySelector('.js-nav-global-link-search');
const navGlobalLinkSearchToggle = document.querySelector('.js-nav-global-link-search-toggle');
// Toggle navbar search on small screens
navGlobalLinkSearchToggle.addEventListener('click', function() {
this.classList.toggle('is-active');
if (this.classList.contains('is-active')) {
// Show navGlobalLinkSearch
navGlobalLinkSearch.classList.add('is-active');
} else {
navGlobalLinkSearch.classList.remove('is-active');
}
});
}
// Create function init // Create function init
function init() { function init() {
agreeWithTerms(); agreeWithTerms();
@ -138,6 +161,7 @@
btnBack(); btnBack();
commentForm(); commentForm();
copyInstallUrl(); copyInstallUrl();
navGlobalLinkSearch();
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {

View File

@ -12,6 +12,7 @@ function galleriaSetLargePreview(item) {
const galleriaContentType = item.dataset.galleriaContentType; const galleriaContentType = item.dataset.galleriaContentType;
const galleriaVideoUrl = item.dataset.galleriaVideoUrl; const galleriaVideoUrl = item.dataset.galleriaVideoUrl;
previewLarge.href = item.href;
previewLarge.classList = item.classList; previewLarge.classList = item.classList;
previewLarge.firstElementChild.src = item.href; previewLarge.firstElementChild.src = item.href;
previewLarge.firstElementChild.alt = galleryItem.alt; previewLarge.firstElementChild.alt = galleryItem.alt;
@ -74,9 +75,10 @@ function galleriaScrollNavigation() {
}); });
} }
/* Create the overlay that will host the image and navigation controls. */ /* Create the overlay that will host the media and navigation controls. */
function galleriaCreateOverlay() { function galleriaCreateOverlay() {
let overlay = document.createElement("div"); let overlay = document.createElement("div");
overlay.id = 'galleria';
overlay.classList.add("galleria"); overlay.classList.add("galleria");
document.body.classList.add('is-galleria-active'); document.body.classList.add('is-galleria-active');
@ -84,7 +86,9 @@ function galleriaCreateOverlay() {
} }
/* Close and delete the overlay. */ /* Close and delete the overlay. */
function galleriaCloseOverlay(overlay) { function galleriaCloseOverlay() {
let overlay = document.getElementById('galleria');
if (overlay.parentNode === document.body) { if (overlay.parentNode === document.body) {
document.body.removeChild(overlay); document.body.removeChild(overlay);
document.body.classList.remove('is-galleria-active'); document.body.classList.remove('is-galleria-active');
@ -94,7 +98,7 @@ function galleriaCloseOverlay(overlay) {
/* Create the backdrop behind the overlay. */ /* Create the backdrop behind the overlay. */
function galleriaCreateUnderlay() { function galleriaCreateUnderlay() {
let underlay = document.createElement("div"); let underlay = document.createElement("div");
underlay.classList.add("underlay"); underlay.classList.add("underlay", "zoom-out");
return underlay; return underlay;
} }
@ -107,7 +111,7 @@ function galleriaCreateLoadingPlaceholder() {
} }
/* Create Large Image element. */ /* Create expanded image element inside overlay. */
function galleriaCreateMediaImage(galleriaItem, overlay, loadingPlaceholder) { function galleriaCreateMediaImage(galleriaItem, overlay, loadingPlaceholder) {
let galleriaNewItem = new Image(); let galleriaNewItem = new Image();
galleriaNewItem.id = 'galleria-active-item'; galleriaNewItem.id = 'galleria-active-item';
@ -121,6 +125,12 @@ function galleriaCreateMediaImage(galleriaItem, overlay, loadingPlaceholder) {
galleriaNewItem.src = galleriaItem.href; galleriaNewItem.src = galleriaItem.href;
galleriaNewItem.alt = galleriaItem.firstElementChild.alt; galleriaNewItem.alt = galleriaItem.firstElementChild.alt;
/* Click on image to close the overlay. */
galleriaNewItem.classList.add('zoom-out');
galleriaNewItem.addEventListener("click", function () {
galleriaCloseOverlay();
});
galleriaCreateCaption(galleriaNewItem.alt, overlay); galleriaCreateCaption(galleriaNewItem.alt, overlay);
} }
@ -146,7 +156,7 @@ function galleriaCreateMediaVideo(galleriaItem, overlay, loadingPlaceholder) {
galleriaCreateCaption(galleriaItem.firstElementChild.alt, overlay); galleriaCreateCaption(galleriaItem.firstElementChild.alt, overlay);
} }
/* Create expanded media element inside overlay. */
function galleriaCreateMedia(galleriaItem, galleriaContentType, overlay) { function galleriaCreateMedia(galleriaItem, galleriaContentType, overlay) {
const activeItem = overlay.querySelector('#galleria-active-item'); const activeItem = overlay.querySelector('#galleria-active-item');
const loadingPlaceholder = galleriaCreateLoadingPlaceholder(); const loadingPlaceholder = galleriaCreateLoadingPlaceholder();
@ -228,7 +238,7 @@ function galleriaCreateNavigationDiv(siblings, currentIndex, overlay) {
navigationDiv.appendChild(closeButton); navigationDiv.appendChild(closeButton);
closeButton.addEventListener("click", function () { closeButton.addEventListener("click", function () {
galleriaCloseOverlay(overlay); galleriaCloseOverlay();
}); });
if (siblings.length > 1) { if (siblings.length > 1) {
@ -298,7 +308,7 @@ function galleriaCreate() {
// Keyboard event listeners // Keyboard event listeners
document.addEventListener("keydown", function (event) { document.addEventListener("keydown", function (event) {
if (overlay && event.key === "Escape") { if (overlay && event.key === "Escape") {
galleriaCloseOverlay(overlay); galleriaCloseOverlay();
} else if (overlay && event.key === "ArrowRight") { } else if (overlay && event.key === "ArrowRight") {
currentIndex = galleriaNavigateNext(siblings, currentIndex, overlay); currentIndex = galleriaNavigateNext(siblings, currentIndex, overlay);
} else if (overlay && event.key === "ArrowLeft") { } else if (overlay && event.key === "ArrowLeft") {

View File

@ -35,28 +35,38 @@ a.badge-tag
font-size: var(--fs-xs) font-size: var(--fs-xs)
.badge-status .badge-status
&-approved &-approved,
&-resolved
@extend .badge-success @extend .badge-success
&-awaiting-review &-awaiting-review
@extend .badge-info @extend .badge-info
&-incomplete,
&-awaiting-changes, &-awaiting-changes,
&-draft,
&-untriaged,
@extend .badge-warning @extend .badge-warning
&-disabled-by-staff, &-disabled-by-staff,
&-disabled-by-author &-disabled-by-author
@extend .badge-secondary @extend .badge-secondary
&-confirmed,
&-deleted
@extend .badge-danger
.badge-outline .badge-outline
background-color: transparent background-color: transparent
&.badge-status &.badge-status
&-approved &-approved,
&-resolved
color: var(--color-success) color: var(--color-success)
&-awaiting-review &-awaiting-review
color: var(--color-info) color: var(--color-info)
&-incomplete,
&-awaiting-changes, &-awaiting-changes,
&-draft,
&-untriaged,
color: var(--color-warning) color: var(--color-warning)
&-disabled-by-staff, &-disabled-by-staff,
&-disabled-by-author &-disabled-by-author
color: var(--color-secondary) color: var(--color-secondary)
&-confirmed,
&-deleted
color: var(--color-danger)

View File

@ -3,8 +3,8 @@
.hero.extension-detail .hero.extension-detail
--hero-max-height: 0 --hero-max-height: 0
--hero-min-height: 24.0rem --hero-min-height: 28.0rem
--fs-hero-title: clamp(4.8rem, 4vw + 1.6rem, 4.8rem) --fs-hero-title: var(--fs-h1)
--fs-lg: 1.8rem --fs-lg: 1.8rem
--border-width: .2rem --border-width: .2rem
--hero-bg-color: hsl(213, 10%, 14%) --hero-bg-color: hsl(213, 10%, 14%)
@ -16,6 +16,10 @@
overflow: initial overflow: initial
text-shadow: none text-shadow: none
+media-md
--fs-hero-title: clamp(4.8rem, 4vw + 1.6rem, 4.8rem)
--hero-min-height: 24.0rem
h1 h1
margin-left: calc(var(--spacer-3)) margin-left: calc(var(--spacer-3))
@ -23,13 +27,17 @@
margin: auto 0 margin: auto 0
.hero-subtitle .hero-subtitle
margin-left: calc(var(--spacer-4) + var(--spacer-1) + var(--fs-hero-title))
max-width: none max-width: none
+media-sm
margin-left: calc(var(--spacer-4) + var(--spacer-1) + var(--fs-hero-title))
.badge .badge
+margin(2, left)
pointer-events: none pointer-events: none
+media-sm
+margin(2, left)
.hero-overlay .hero-overlay
background-color: transparent background-color: transparent
background-image: linear-gradient(0deg, hsl(213, 10%, 12%), hsla(213, 10%, 14%, 0)) // --color-bg theme dark background-image: linear-gradient(0deg, hsl(213, 10%, 12%), hsla(213, 10%, 14%, 0)) // --color-bg theme dark
@ -223,8 +231,12 @@
border-radius: var(--border-radius-lg) border-radius: var(--border-radius-lg)
border: var(--border-width) solid var(--border-color) border: var(--border-width) solid var(--border-color)
display: flex display: flex
flex-direction: column
+padding(2, y) +padding(2, y)
+media-md
flex-direction: row
.previews-list-item-thumbnail .previews-list-item-thumbnail
margin: 0 margin: 0
+margin(2, y) +margin(2, y)
@ -244,6 +256,7 @@
.details .details
+padding(3, x) +padding(3, x)
flex: 1 flex: 1
width: 100%
label label
font-size: var(--fs-sm) font-size: var(--fs-sm)
@ -366,9 +379,6 @@
.badge .badge
text-decoration: none !important text-decoration: none !important
.ext-review-list-name
display: flex
.extension-icon .extension-icon
+margin(2, right) +margin(2, right)
@ -404,6 +414,7 @@
&.active &.active
background-color: var(--color-accent-bg) background-color: var(--color-accent-bg)
+fw-normal
&:last-child &:last-child
+margin(0, bottom) +margin(0, bottom)
@ -412,23 +423,32 @@
@extend .dropdown-divider @extend .dropdown-divider
+margin(0, top) +margin(0, top)
+margin(1, bottom)
.dropdown-item a
&a &.dropdown-item
+padding(3, x) +padding(3, x)
.extension-icon a
display: inline-block &.dropdown-item-disabled
vertical-align: bottom opacity: .5
width: var(--fs-lg) pointer-events: none
.extension-icon
img img
border-radius: calc(var(--border-radius) / 2) border-radius: calc(var(--border-radius) / 2)
max-width: 100% width: var(--spacer-4)
&.icon-lg &.icon-lg
transform: translateY(-.2rem)
width: var(--fs-hero-title) width: var(--fs-hero-title)
img
width: var(--fs-hero-title)
+media-md
transform: translateY(calc(var(--spacer-1) * -1))
.icon-preview, .featured-image-preview .icon-preview, .featured-image-preview
height: 9rem height: 9rem
background-size: contain background-size: contain

View File

@ -15,11 +15,14 @@
@extend .i-mic @extend .i-mic
/* Aliases for review activity types. */ /* Aliases for review activity types. */
.i-activity-approved .i-activity-approved,
.i-status-approved
@extend .i-check @extend .i-check
.i-activity-awaiting-review .i-activity-awaiting-review,
.i-status-awaiting-review
@extend .i-eye @extend .i-eye
.i-activity-awaiting-changes .i-activity-awaiting-changes,
@extend .i-edit .i-status-awaiting-changes
@extend .i-clock

View File

@ -102,6 +102,12 @@
/* Lightbox component. */ /* Lightbox component. */
.galleria .galleria
--galleria-btn-width: 100px
--galleria-media-max-width: 100%
+media-lg
--galleria-media-max-width: calc(100% - calc(var(--galleria-btn-width) * 1.5))
align-items: center align-items: center
display: flex display: flex
inset: 0 0 0 0 inset: 0 0 0 0
@ -110,19 +116,21 @@
position: fixed position: fixed
z-index: var(--z-index-galleria) z-index: var(--z-index-galleria)
img img, video
max-height: 100% max-height: 100%
max-width: 100% max-width: var(--galleria-media-max-width)
object-fit: contain object-fit: contain
/* Previous/Next buttons.*/ /* Previous/Next buttons.*/
.btn .btn
background: transparent background: transparent
border: none border: none
color: white
cursor: pointer cursor: pointer
font-size: 5.6rem font-size: 5.6rem
height: 100vh height: 100vh
max-width: 200px max-height: 300px
max-width: var(--galleria-btn-width)
opacity: .6 opacity: .6
outline: 0 outline: 0
position: absolute position: absolute
@ -137,12 +145,7 @@
color: white color: white
opacity: 1 opacity: 1
svg
opacity: 0
transition: all var(--transition-speed) var(--transition-ease-bezier)
&.btn-close &.btn-close
fill: white
font-size: 3.2rem font-size: 3.2rem
height: 20vh height: 20vh
max-height: 80px max-height: 80px
@ -166,12 +169,14 @@
.underlay .underlay
background-color: rgba(0,0,0,0.9) background-color: rgba(0,0,0,0.9)
cursor: zoom-out
inset: 0 0 0 0 inset: 0 0 0 0
overflow: hidden overflow: hidden
position: fixed position: fixed
z-index: -1 z-index: -1
.zoom-out
cursor: zoom-out
.indicator .indicator
background-color: rgba(black, .5) background-color: rgba(black, .5)
bottom: var(--spacer) bottom: var(--spacer)

View File

@ -16,7 +16,13 @@
align-items: center align-items: center
border-bottom: thin solid rgba(white, .1) border-bottom: thin solid rgba(white, .1)
display: flex display: flex
flex-grow: 1
margin-top: auto margin-top: auto
overflow-x: auto
overflow-y: hidden
a
white-space: nowrap
.dropdown-menu .dropdown-menu
font-size: initial font-size: initial
@ -55,3 +61,9 @@
&::after &::after
opacity: 1 opacity: 1
.hero-tabs-admin
justify-content: end
+media-sm
border-bottom: thin solid rgba(white, .1)

View File

@ -1,10 +1,51 @@
.dropdown-toggle .dropdown-menu-filter
height: calc(var(--spacer) * 2) .dropdown-item
align-items: center
.dropdown-filter-sort
@extend .box
align-items: center
border-radius: var(--spacer-2)
display: flex
+padding(2)
.dropdown-item,
.dropdown-toggle
white-space: nowrap
+media-md
.dropdown-menu-filter
gap: var(--spacer-1)
width: 56.0rem
li
background-color: var(--color-bg-secondary)
border-radius: var(--border-radius)
+margin(0, bottom)
&.is-visible
display: grid
grid-template-columns: repeat(3, 1fr)
.dropdown-menu-filter-sort .dropdown-menu-filter-sort
max-height: calc(var(--spacer) * 28) max-height: calc(var(--spacer) * 24.25)
overflow: auto overflow: auto
.dropdown-item
line-height: var(--lh-base)
justify-content: space-between
&.is-active
background-color: var(--color-accent-bg)
color: var(--color-accent)
.navbar-search .navbar-search
input input
color: var(--bwa-color-text)
min-width: calc(var(--spacer) * 4) min-width: calc(var(--spacer) * 4)
&:active,
&:focus,
&:hover
color: var(--bwa-color-text)

View File

@ -1,12 +1,42 @@
// TODO: refactor style partial
.nav-global .nav-global-nav-links .nav-global .nav-global-nav-links
+padding(2, right) +padding(2, right)
.nav-global .nav-global-link-search-toggle
display: none
// Media query comes from partial navigation_global, where it is explicitly set
@media (max-width: 767px) @media (max-width: 767px)
.nav-global .nav-global-nav-links li a:hover, .nav-global .nav-global-nav-links li a:hover,
.nav-global .nav-global-nav-links li a.nav-global-link-active .nav-global .nav-global-nav-links li a.nav-global-link-active
background-color: var(--bwa-color-accent-bg) !important background-color: var(--bwa-color-accent-bg) !important
color: var(--bwa-color-accent) !important color: var(--bwa-color-accent) !important
.nav-global .nav-global-link-search-toggle
display: flex
&.is-active
background-color: var(--bwa-btn-color-bg-hover)
color: var(--bwa-color-text-primary)
.nav-global-link-search
display: none !important
&.is-active
background-color: var(--bwa-color-bg-tertiary)
display: inline-flex !important
left: 0
position: absolute
top: calc(var(--spacer) * 4)
width: 100%
search
+padding(3, x)
width: 100%
.navbar-search
max-width: none
+media-xl +media-xl
.nav-global .nav-global-container .nav-global .nav-global-container
max-width: 1320px max-width: 1320px

View File

@ -52,6 +52,9 @@
pre pre
+margin(3, bottom) +margin(3, bottom)
p:last-child
margin-bottom: 0
.text-accent .text-accent
color: var(--color-accent) color: var(--color-accent)

View File

@ -107,10 +107,10 @@
</ul> </ul>
<ul class="nav-global-links-right"> <ul class="nav-global-links-right">
<li class="d-lg-inline-flex d-none"> <li class="js-nav-global-link-search nav-global-link-search">
<search> <search>
<form action="{% url "extensions:search" %}" class="navbar-search" method="GET"> <form action="{% url 'extensions:search' %}" class="navbar-search" method="GET">
<input aria-label="Search" aria-describedby="nav-search-button" class="form-control" type="text" placeholder="Search..." {% if request.GET.q %} value="{{ request.GET.q }}" {% else %} {% endif %}> <input name="q" aria-label="Search" aria-describedby="nav-search-button" class="form-control" type="text" placeholder="Search..." {% if request.GET.q %} value="{{ request.GET.q }}" {% else %} {% endif %}>
<button id="nav-search-button" type="submit"> <button id="nav-search-button" type="submit">
<i class="i-search"></i> <i class="i-search"></i>
</button> </button>
@ -118,6 +118,10 @@
</search> </search>
</li> </li>
<li>
<button class="js-nav-global-link-search-toggle nav-global-link-search-toggle"><i class="i-search"></i></button>
</li>
{% block nav-upload %} {% block nav-upload %}
<li class="d-lg-inline-flex d-none"> <li class="d-lg-inline-flex d-none">
<a class="nav-global-btn nav-global-btn-primary" href="{% url 'extensions:submit' %}"><i class="i-upload"></i><span>Upload Extension</span></a> <a class="nav-global-btn nav-global-btn-primary" href="{% url 'extensions:submit' %}"><i class="i-upload"></i><span>Upload Extension</span></a>
@ -125,7 +129,7 @@
{% endblock nav-upload %} {% endblock nav-upload %}
<li> <li>
<button class="js-toggle-theme-btn px-2"><i class="js-toggle-theme-btn-icon i-adjust"></i></button> <button class="js-toggle-theme-btn"><i class="js-toggle-theme-btn-icon i-adjust"></i></button>
</li> </li>
{% if user.is_authenticated %} {% if user.is_authenticated %}
@ -140,9 +144,9 @@
</a> </a>
</li> </li>
<li class="dropdown"> <li class="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 pe-3 px-2"> <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>
<i class="i-chevron-down"></i> <i class="d-none d-md-inline 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">
{% if user.is_staff %} {% if user.is_staff %}
@ -209,10 +213,12 @@
</ul> </ul>
</li> </li>
{% elif page_id != 'login' and page_id != 'register' %} {% elif page_id != 'login' and page_id != 'register' %}
<a href="{% url 'oauth:login' %}" class="btn btn-link"> <li>
<a class="nav-global-btn" href="{% url 'oauth:login' %}">
<i class="i-log-in"></i> <i class="i-log-in"></i>
<span>{% trans "Sign in" %}</span> <span>{% trans "Sign in" %}</span>
</a> </a>
</li>
{% endif %} {% endif %}
<li> <li>
@ -258,6 +264,16 @@
</div> </div>
{% block footer %} {% block footer %}
<div class="footer-pages pt-2">
<div class="container">
<ul>
<li><a href="/about">About</a></li>
<li><a href="https://www.blender.org/privacy-policy">Privacy Policy</a></li>
<li><a href="/terms-of-service">Terms of Service</a></li>
</ul>
</div>
</div>
{% include "_footer.html" %} {% include "_footer.html" %}
{% endblock footer %} {% endblock footer %}

View File

@ -14,7 +14,7 @@
{% if not field.is_hidden %} {% if not field.is_hidden %}
<label for="{{ field.id_for_label }}" class="form-check-label"> <label for="{{ field.id_for_label }}" class="form-check-label">
{{ label|safe }} {{ label|safe }}
{% if field.field.required %}<span class="form-required-indicator">*</span>{% endif %} {% if field.field.required or required %}<span class="form-required-indicator">*</span>{% endif %}
</label> </label>
{% endif %} {% endif %}
@ -24,7 +24,7 @@
{% if not field.is_hidden %} {% if not field.is_hidden %}
<label for="{{ field.id_for_label }}"> <label for="{{ field.id_for_label }}">
{{ label|safe }} {{ label|safe }}
{% if field.field.required %}<span class="form-required-indicator">*</span>{% endif %} {% if field.field.required or required %}<span class="form-required-indicator">*</span>{% endif %}
</label> </label>
{% endif %} {% endif %}

View File

@ -3,9 +3,9 @@
{% load common %} {% load common %}
{% with default_title="Blender Extensions" %} {% with default_title="Blender Extensions" %}
{% with default_author="Blender Institute" %} {% with default_author="Blender Foundation" %}
{% with default_description="Blender Extensions is a web based service developed by Blender Institute that allows people to share open source add-ons for Blender." %} {% with default_description="Blender Extensions is a web based service developed by Blender Foundation that allows people to share open source add-ons for Blender." %}
{% if not image_url %} {% if not image_url %}
{% absolute_url default_image_path as image_url %} {% absolute_url default_image_path as image_url %}

View File

@ -1,26 +1,59 @@
{% load common %} {% load common %}
{% get_proper_elided_page_range page_obj as page_range %} {% get_proper_elided_page_range page_obj as page_range %}
<nav> <nav>
<ul class="pagination">
{% if page_obj.has_other_pages %} {% if page_obj.has_other_pages %}
<ul class="pagination pb-2">
{% if page_obj.number != 1 %}
<li class="page-item page-first">
<a href="?{% query_transform page=1 %}">
First
</a>
</li>
{% endif %}
{% if page_obj.has_previous %}
<li class="page-item page-prev">
<a href="?{% query_transform page=page_obj.previous_page_number %}">
<i class="i-chevron-left"></i> Previous
</a>
</li>
{% endif %}
{% for page_number in page_range %} {% for page_number in page_range %}
{% if page_number == '…' %} {% if page_number == '…' %}
<li class="page-item disabled"> <li class="page-item disabled">
<span class="page-link px-0">...</span> <span class="page-link px-0">...</span>
</li> </li>
{% elif page_obj.number == page_number %} {% elif page_obj.number == page_number %}
<li class="page-item active" aria-current="page"> <li class="page-item page-current active" aria-current="page">
<a>{{ page_obj.number }}</a> <a>{{ page_obj.number }}</a>
</li> </li>
{% else %} {% else %}
<li class="page-item"> <li class="page-item">
<a href="?page={{ page_number }}">{{ page_number }}</a> <a href="?{% query_transform page=page_number %}">{{ page_number }}</a>
</li> </li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %}
<li class="page-item"> {% if page_obj.has_next %}
{{ page_obj.paginator.count }} {{ label }}{{ page_obj.paginator.count | pluralize }} <li class="page-item page-next">
<a href="?{% query_transform page=page_obj.next_page_number %}">
Next <i class="i-chevron-right"></i>
</a>
</li> </li>
{% endif %}
{% if page_obj.number != page_obj.paginator.num_pages %}
<li class="page-item page-last">
<a href="?{% query_transform page=page_obj.paginator.num_pages %}">
Last
</a>
</li>
{% endif %}
</ul> </ul>
{% endif %}
<div class="mb-2 mb-lg-0 page-item text-center">
{{ page_obj.paginator.count }} {{ label }}{{ page_obj.paginator.count | pluralize }}
</div>
</nav> </nav>

View File

@ -1,52 +1,6 @@
{% with status=object.get_status_display %} {% with label=label|default:object.get_status_display slug=slug|default:object.get_status_display|slugify %}
{% if 'incomplete' in status.lower %} <div class="badge badge-status-{{ slug }} {{ classes }}">
<div class="badge badge-warning {{ class }}" title="Requires re-uploading or editing"> {% if icon %}<i class="i-status-{{ slug }}"></i>{% endif %}
<i class="i-alert-triangle"></i> <span>{{ label }}</span>
<span>{{ status }}</span>
</div> </div>
{% elif 'disabled' in status.lower %}
<div class="badge badge-danger {{ class }}">
<i class="i-eye"></i>
<span>{{ status }}</span>
</div>
{% elif 'deleted' in status.lower %}
<div class="badge badge-danger {{ class }}">
<i class="i-eye"></i>
<span>{{ status }}</span>
</div>
{% elif 'awaiting' in status.lower %}
<div class="badge badge-info {{ class }}" title="Awaiting a review by a human being and not yet publicly visible">
<i class="i-eye"></i>
<span>{{ status }}</span>
</div>
{% elif 'approved' in status.lower %}
<div class="badge badge-success {{ class }}">
<i class="i-check"></i>
<span>{{ status }}</span>
</div>
{% elif 'confirmed' in status.lower %}
<div class="badge badge-danger {{ class }}">
<span>{{ status }}</span>
</div>
{% elif 'untriaged' in status.lower %}
<div class="badge badge-warning {{ class }}">
<span>{{ status }}</span>
</div>
{% elif 'resolved' in status.lower %}
<div class="badge badge-success {{ class }}">
<span>{{ status }}</span>
</div>
{% else %}
<div class="badge badge-secondary {{ class }}">
<span>{{ status }}</span>
</div>
{% endif %}
{% endwith %} {% endwith %}

View File

@ -7,7 +7,7 @@
</li> </li>
<li class="page-item page-prev"> <li class="page-item page-prev">
<a href="?page={{ pager.previous_page_number }}" rel="prev"><i class="i-chevron-left"></i> {% trans "Prev" %}</a> <a href="?page={{ pager.previous_page_number }}" rel="prev"><i class="i-chevron-left"></i><span class="d-md-inline d-none"> {% trans "Prev" %}</span></a>
</li> </li>
{% endif %} {% endif %}
@ -19,7 +19,7 @@
{% if pager.has_next %} {% if pager.has_next %}
<li class="page-item page-next"> <li class="page-item page-next">
<a href="?page={{ pager.next_page_number }}" rel="next">{% trans "Next" %} <i class="i-chevron-right"></i></a> <a href="?page={{ pager.next_page_number }}" rel="next"><span class="d-md-inline d-none">{% trans "Next" %} </span><i class="i-chevron-right"></i></a>
</li> </li>
<li class="page-item page-last"> <li class="page-item page-last">

View File

@ -29,6 +29,17 @@ def absolute_url(context, path: str) -> str:
return utils.absolutify(path, request=request) return utils.absolutify(path, request=request)
# Allows for example to go to another page of search
# results while keeping the search query.
# Credit: https://stackoverflow.com/questions/46026268/
@register.simple_tag(takes_context=True)
def query_transform(context, **kwargs):
query = context['request'].GET.copy()
for k, v in kwargs.items():
query[k] = v
return query.urlencode()
class PaginationRenderer: class PaginationRenderer:
def __init__(self, pager): def __init__(self, pager):
self.pager = pager self.pager = pager

View File

@ -91,8 +91,8 @@ class VersionFactory(DjangoModelFactory):
if not extracted: if not extracted:
return return
tags = Platform.objects.filter(slug__in=extracted) platforms = Platform.objects.filter(slug__in=extracted)
self.platforms.add(*tags) self.platforms.add(*platforms)
@factory.post_generation @factory.post_generation
def tags(self, create, extracted, **kwargs): def tags(self, create, extracted, **kwargs):

View File

@ -1,9 +1,13 @@
import itertools import itertools
from typing import Tuple
import django.urls as urls import django.urls as urls
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.regex_helper import normalize from django.utils.regex_helper import normalize
from apitokens.models import UserToken
try: # Django 2.0 try: # Django 2.0
url_resolver_types = (urls.URLResolver,) url_resolver_types = (urls.URLResolver,)
DJANGO_2 = True DJANGO_2 = True
@ -109,3 +113,11 @@ class CheckFilePropertiesMixin:
self.assertEqual(file.original_name, kwargs.get('original_name')) self.assertEqual(file.original_name, kwargs.get('original_name'))
if 'size_bytes' in kwargs: if 'size_bytes' in kwargs:
self.assertEqual(file.size_bytes, kwargs.get('size_bytes')) self.assertEqual(file.size_bytes, kwargs.get('size_bytes'))
def create_user_token(*args, **kwargs) -> Tuple['UserToken', str]:
token_key = UserToken.generate_token_key()
kwargs['token_hash'] = UserToken.generate_hash(token_key)
kwargs['token_prefix'] = UserToken.generate_token_prefix(token_key)
token = UserToken.objects.create(*args, **kwargs)
return token, token_key

View File

@ -10,7 +10,7 @@ EXTENSION_TYPE_CHOICES = Choices(
('BPY', 1, _('Add-on')), ('BPY', 1, _('Add-on')),
('THEME', 2, _('Theme')), ('THEME', 2, _('Theme')),
) )
STATUS_INCOMPLETE = 1 STATUS_DRAFT = 1
STATUS_AWAITING_REVIEW = 2 STATUS_AWAITING_REVIEW = 2
STATUS_APPROVED = 3 STATUS_APPROVED = 3
STATUS_DISABLED = 4 STATUS_DISABLED = 4
@ -18,7 +18,7 @@ STATUS_DISABLED_BY_AUTHOR = 5
# Extension statuses # Extension statuses
EXTENSION_STATUS_CHOICES = Choices( EXTENSION_STATUS_CHOICES = Choices(
('INCOMPLETE', STATUS_INCOMPLETE, _('Incomplete')), ('DRAFT', STATUS_DRAFT, _('Draft')),
('AWAITING_REVIEW', STATUS_AWAITING_REVIEW, _('Awaiting Review')), ('AWAITING_REVIEW', STATUS_AWAITING_REVIEW, _('Awaiting Review')),
('APPROVED', STATUS_APPROVED, _('Approved')), ('APPROVED', STATUS_APPROVED, _('Approved')),
('DISABLED', STATUS_DISABLED, _('Disabled by staff')), ('DISABLED', STATUS_DISABLED, _('Disabled by staff')),

View File

@ -83,6 +83,7 @@ class ExtensionAdmin(admin.ModelAdmin):
'website', 'website',
'icon', 'icon',
'featured_image', 'featured_image',
'latest_version',
) )
autocomplete_fields = ('team',) autocomplete_fields = ('team',)
@ -104,6 +105,7 @@ class ExtensionAdmin(admin.ModelAdmin):
'description', 'description',
('icon', 'featured_image'), ('icon', 'featured_image'),
'status', 'status',
'latest_version',
), ),
}, },
), ),

View File

@ -163,6 +163,18 @@ class ExtensionUpdateForm(forms.ModelForm):
self.add_preview_formset.error_messages['too_few_forms'] = self.msg_need_previews self.add_preview_formset.error_messages['too_few_forms'] = self.msg_need_previews
user_teams = self.request.user.teams.all()
if self.request.user in self.instance.authors.all() and len(user_teams) > 0:
team_slug = None
if self.instance.team:
team_slug = self.instance.team.slug
choices = [(None, 'None'), *[(team.slug, team.name) for team in user_teams]]
self.fields['team'] = forms.ChoiceField(
choices=choices,
required=False,
initial=team_slug,
)
def is_valid(self, *args, **kwargs) -> bool: def is_valid(self, *args, **kwargs) -> bool:
"""Validate all nested forms and form(set)s first.""" """Validate all nested forms and form(set)s first."""
if 'submit_draft' in self.data: if 'submit_draft' in self.data:
@ -198,6 +210,27 @@ class ExtensionUpdateForm(forms.ModelForm):
return all(is_valid_flags) return all(is_valid_flags)
def clean_team(self):
# don't modify instance if the field value wasn't sent
# empty value reset the team
if 'team' in self.data:
# TODO permissions check
# shouldn't happen normally: the form doesn't render the select
if self.request.user not in self.instance.authors.all():
self.add_error('team', _('Not allowed to set the team'))
return
team_slug = self.cleaned_data['team']
if team_slug:
team = self.request.user.teams.filter(slug=team_slug).first()
if not team:
self.add_error('team', _('User does not belong to the team'))
return
else:
self.instance.team = team
else:
self.instance.team = None
def clean(self): def clean(self):
"""Perform additional validation and status changes.""" """Perform additional validation and status changes."""
super().clean() super().clean()
@ -206,7 +239,7 @@ class ExtensionUpdateForm(forms.ModelForm):
if self.instance.status != self.instance.STATUSES.AWAITING_REVIEW: if self.instance.status != self.instance.STATUSES.AWAITING_REVIEW:
self.add_error(None, self.msg_cannot_convert_to_draft) self.add_error(None, self.msg_cannot_convert_to_draft)
else: else:
self.instance.status = self.instance.STATUSES.INCOMPLETE self.instance.status = self.instance.STATUSES.DRAFT
self.instance.converted_to_draft = True self.instance.converted_to_draft = True
# Send the extension and version to the review, if possible # Send the extension and version to the review, if possible

View File

@ -29,7 +29,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=255, unique=True)), ('name', models.CharField(max_length=255, unique=True)),
('description', models.TextField(help_text='\n<p><a href="https://commonmark.org/help/" rel="nofollow" target="_blank">Markdown</a>\nis supported.</p>\n')), ('description', models.TextField(help_text='\n<p><a href="https://commonmark.org/help/" rel="nofollow" target="_blank">Markdown</a>\nis supported.</p>\n')),
('tagline', models.CharField(help_text='A very short description', max_length=128)), ('tagline', models.CharField(help_text='A very short description', max_length=128)),
('status', models.PositiveSmallIntegerField(choices=[(1, 'Incomplete'), (2, 'Awaiting Review'), (3, 'Approved'), (4, 'Disabled by staff'), (5, 'Disabled by author')], default=1)), ('status', models.PositiveSmallIntegerField(choices=[(1, 'Draft'), (2, 'Awaiting Review'), (3, 'Approved'), (4, 'Disabled by staff'), (5, 'Disabled by author')], default=1)),
('doc_url', models.URLField(blank=True, help_text='URL of the documentation', null=True)), ('doc_url', models.URLField(blank=True, help_text='URL of the documentation', null=True)),
('tracker_url', models.URLField(blank=True, help_text='URL of the issue tracker', null=True)), ('tracker_url', models.URLField(blank=True, help_text='URL of the issue tracker', null=True)),
('homepage_url', models.URLField(blank=True, help_text='URL of the homepage', null=True)), ('homepage_url', models.URLField(blank=True, help_text='URL of the homepage', null=True)),

View File

@ -0,0 +1,50 @@
# Generated by Django 4.2.11 on 2024-05-27 12:42
from django.db import migrations, models
import django.db.models.deletion
from constants.base import (
EXTENSION_STATUS_CHOICES,
FILE_STATUS_CHOICES,
)
def valid_file_statuses(self):
if self.status == EXTENSION_STATUS_CHOICES.APPROVED:
return [FILE_STATUS_CHOICES.APPROVED]
return [FILE_STATUS_CHOICES.AWAITING_REVIEW, FILE_STATUS_CHOICES.APPROVED]
def populate_latest_version(apps, schema_editor):
Extension = apps.get_model('extensions', 'Extension')
to_update = []
for extension in Extension.objects.prefetch_related(
'versions',
'versions__file',
).all():
versions = extension.versions.all().order_by('-date_created')
latest_version = None
for version in versions:
if version.file.status not in valid_file_statuses(extension):
continue
latest_version = version
break
extension.latest_version = latest_version
to_update.append(extension)
Extension.objects.bulk_update(to_update, ['latest_version'])
class Migration(migrations.Migration):
dependencies = [
('extensions', '0030_platform_version_platforms'),
]
operations = [
migrations.AddField(
model_name='extension',
name='latest_version',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='latest_version_of', to='extensions.version'),
),
migrations.RunPython(populate_latest_version, reverse_code=migrations.RunPython.noop),
]

View File

@ -16,8 +16,11 @@ from constants.base import (
EXTENSION_STATUS_CHOICES, EXTENSION_STATUS_CHOICES,
EXTENSION_TYPE_CHOICES, EXTENSION_TYPE_CHOICES,
EXTENSION_TYPE_SLUGS, EXTENSION_TYPE_SLUGS,
EXTENSION_TYPE_SLUGS_SINGULAR,
FILE_STATUS_CHOICES, FILE_STATUS_CHOICES,
) )
from files.models import File
from reviewers.models import ApprovalActivity
import common.help_texts import common.help_texts
import extensions.fields import extensions.fields
@ -128,12 +131,19 @@ class ExtensionManager(models.Manager):
def unlisted(self): def unlisted(self):
return self.exclude(status=self.model.STATUSES.APPROVED) return self.exclude(status=self.model.STATUSES.APPROVED)
def authored_by(self, user_id: int): def _authored_by_filter(self, user):
return self.filter(maintainer__user_id=user_id) filter = Q(maintainer__user_id=user.pk)
user_teams = user.teams.all()
if user_teams:
filter = filter | Q(team__in=[t.pk for t in user_teams])
return filter
def listed_or_authored_by(self, user_id: int): def authored_by(self, user):
return self.filter(self._authored_by_filter(user)).distinct()
def listed_or_authored_by(self, user):
return self.filter( return self.filter(
Q(status=self.model.STATUSES.APPROVED) | Q(maintainer__user_id=user_id) Q(status=self.model.STATUSES.APPROVED) | self._authored_by_filter(user)
).distinct() ).distinct()
@ -160,6 +170,13 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
help_text='Whether the extension should be listed. It is kept in sync via signals.', help_text='Whether the extension should be listed. It is kept in sync via signals.',
default=False, default=False,
) )
latest_version = models.ForeignKey(
'Version',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='latest_version_of',
)
featured_image = models.OneToOneField( featured_image = models.OneToOneField(
'files.File', 'files.File',
@ -183,7 +200,7 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
) )
previews = FilterableManyToManyField('files.File', through='Preview', related_name='extensions') previews = FilterableManyToManyField('files.File', through='Preview', related_name='extensions')
status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.INCOMPLETE) status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.DRAFT)
support = models.URLField( support = models.URLField(
help_text='URL for reporting issues or contact details for support.', null=True, blank=True help_text='URL for reporting issues or contact details for support.', null=True, blank=True
) )
@ -212,14 +229,14 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
def type_slug(self) -> str: def type_slug(self) -> str:
return EXTENSION_TYPE_SLUGS[self.type] return EXTENSION_TYPE_SLUGS[self.type]
@property
def type_slug_singular(self) -> str:
return EXTENSION_TYPE_SLUGS_SINGULAR[self.type]
@property @property
def status_slug(self) -> str: def status_slug(self) -> str:
return utils.slugify(EXTENSION_STATUS_CHOICES[self.status - 1][1]) return utils.slugify(EXTENSION_STATUS_CHOICES[self.status - 1][1])
def save(self, *args, **kwargs):
self.clean()
return super().save(*args, **kwargs)
def update_metadata_from_version(self, version): def update_metadata_from_version(self, version):
update_fields = set() update_fields = set()
metadata = version.file.metadata metadata = version.file.metadata
@ -325,34 +342,6 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
return [FILE_STATUS_CHOICES.APPROVED] return [FILE_STATUS_CHOICES.APPROVED]
return [FILE_STATUS_CHOICES.AWAITING_REVIEW, FILE_STATUS_CHOICES.APPROVED] return [FILE_STATUS_CHOICES.AWAITING_REVIEW, FILE_STATUS_CHOICES.APPROVED]
@property
def latest_version(self):
"""Retrieve the latest version."""
versions = [
v for v in self.versions.all() if v.file and v.file.status in self.valid_file_statuses
]
if not versions:
return None
versions = sorted(versions, key=lambda v: v.date_created, reverse=True)
return versions[0]
@property
def current_version(self):
"""Return the latest public listed version of an extension.
If the add-on is not public, it can return a listed version awaiting
review (since non-public add-ons should not have public versions).
If the add-on has not been created yet or is deleted, it returns None.
"""
if not self.id:
return None
try:
return self.version
except ObjectDoesNotExist:
pass
return None
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 self.is_disabled or self.status in ( if self.is_disabled or self.status in (
@ -379,16 +368,20 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
def should_redirect_to_submit_flow(self): def should_redirect_to_submit_flow(self):
return ( return (
self.status == self.STATUSES.INCOMPLETE self.status == self.STATUSES.DRAFT
and not self.has_complete_metadata() and not self.has_complete_metadata()
and self.latest_version is not None and self.latest_version is not None
) )
def has_maintainer(self, user) -> bool: def has_maintainer(self, user) -> bool:
"""Return True if given user is listed as a maintainer.""" """Return True if given user is listed as a maintainer or is a member of the team."""
if user is None or user.is_anonymous: if user is None or user.is_anonymous:
return False return False
return user in self.authors.all() if user in self.authors.all():
return True
if self.team and user in self.team.users.all():
return True
return False
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.
@ -414,6 +407,40 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
lookup_field = 'slug' lookup_field = 'slug'
return lookup_field return lookup_field
@transaction.atomic
def update_latest_version(self, skip_version=None):
versions = self.versions.select_related('file').order_by('-date_created')
latest_version = None
for version in versions:
if skip_version and version == skip_version:
continue
if version.file.status not in self.valid_file_statuses:
continue
latest_version = version
break
self.latest_version = latest_version
self.save(update_fields={'latest_version'})
if latest_version:
self.update_metadata_from_version(latest_version)
def update_is_listed(self):
should_be_listed = (
self.status == self.STATUSES.APPROVED
and self.versions.filter(file__status=File.STATUSES.APPROVED).count() > 0
)
# this method is called from post_save signal, this early return should prevent a loop
if self.is_listed == should_be_listed:
return
self.is_listed = should_be_listed
update_fields = {'is_listed'}
if self.status == self.STATUSES.APPROVED and not should_be_listed:
self.status = self.STATUSES.DRAFT
update_fields.add('status')
self.save(update_fields=update_fields)
class VersionPermission(CreatedModifiedMixin, models.Model): class VersionPermission(CreatedModifiedMixin, models.Model):
name = models.CharField(max_length=128, null=False, blank=False, unique=True) name = models.CharField(max_length=128, null=False, blank=False, unique=True)
@ -606,7 +633,7 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
@property @property
def is_listed(self): def is_listed(self):
# To be public, version file must have a public status. # To be public, version file must have a public status.
return self.file is not None and self.file.status == self.file.STATUSES.APPROVED return self.file.status == self.file.STATUSES.APPROVED
@property @property
def cannot_be_deleted_reasons(self) -> List[str]: def cannot_be_deleted_reasons(self) -> List[str]:
@ -631,12 +658,14 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
@property @property
def download_url(self) -> str: def download_url(self) -> str:
filename = f'{self.extension.type_slug_singular}-{self.extension.slug}-v{self.version}.zip'
return reverse( return reverse(
'extensions:version-download', 'extensions:version-download',
kwargs={ kwargs={
'type_slug': self.extension.type_slug, 'type_slug': self.extension.type_slug,
'slug': self.extension.slug, 'slug': self.extension.slug,
'version': self.version, 'version': self.version,
'filename': filename,
}, },
) )
@ -661,6 +690,41 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
}, },
) )
@transaction.atomic
def save(self, *args, **kwargs):
is_new = self.pk is None
update_fields = kwargs.get('update_fields', None)
was_changed, old_state = self.pre_save_record(update_fields=update_fields)
self.record_status_change(was_changed, old_state, **kwargs)
super().save(*args, **kwargs)
if not is_new:
return
# auto approving our file if extension is already listed (i.e. have been approved)
if self.extension.is_listed:
args = {'f_id': self.file.pk, 'pk': self.pk, 's': self.file.source.name}
log.info('Auto-approving file pk=%(f_id)s of Version pk=%(pk)s source=%(s)s', args)
self.file.status = File.STATUSES.APPROVED
self.file.save(update_fields={'status', 'date_modified'})
ApprovalActivity(
type=ApprovalActivity.ActivityType.UPLOADED_NEW_VERSION,
user=self.file.user,
extension=self.extension,
message=f'uploaded new version: {self.version}',
).save()
self.extension.update_latest_version()
@transaction.atomic
def delete(self, *args, **kwargs):
if self == self.extension.latest_version:
# make sure self is not a candidate for latest_version, since it's being deleted
self.extension.update_latest_version(skip_version=self)
super().delete(*args, **kwargs)
class Maintainer(CreatedModifiedMixin, models.Model): class Maintainer(CreatedModifiedMixin, models.Model):
extension = models.ForeignKey(Extension, on_delete=models.CASCADE) extension = models.ForeignKey(Extension, on_delete=models.CASCADE)

View File

@ -4,11 +4,11 @@ import logging
from actstream.actions import follow, unfollow from actstream.actions import follow, unfollow
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.db.models.signals import m2m_changed, pre_save, post_save, pre_delete, post_delete from django.db import transaction
from django.db.models.signals import m2m_changed, pre_save, post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from constants.activity import Flag from constants.activity import Flag
from reviewers.models import ApprovalActivity
import extensions.models import extensions.models
import files.models import files.models
@ -46,42 +46,24 @@ def _log_deletion(
@receiver(pre_save, sender=extensions.models.Extension) @receiver(pre_save, sender=extensions.models.Extension)
@receiver(pre_save, sender=extensions.models.Version)
def _record_changes( def _record_changes(
sender: object, sender: object,
instance: Union[extensions.models.Extension, extensions.models.Version], instance: extensions.models.Extension,
update_fields: object, update_fields: object,
**kwargs: object, **kwargs: object,
) -> None: ) -> None:
was_changed, old_state = instance.pre_save_record(update_fields=update_fields) was_changed, old_state = instance.pre_save_record(update_fields=update_fields)
if hasattr(instance, 'name'):
instance.sanitize('name', was_changed, old_state, **kwargs) instance.sanitize('name', was_changed, old_state, **kwargs)
if hasattr(instance, 'description'):
instance.sanitize('description', was_changed, old_state, **kwargs) instance.sanitize('description', was_changed, old_state, **kwargs)
instance.record_status_change(was_changed, old_state, **kwargs) instance.record_status_change(was_changed, old_state, **kwargs)
@receiver(post_save, sender=extensions.models.Extension) # TODO? move this out into version.approve that would take care of updating file.status and
def _update_search_index(sender, instance, **kw): # recomputing extension's is_listed and latest_version fields
pass # TODO: update search index
def extension_should_be_listed(extension):
return (
extension.latest_version is not None
and extension.latest_version.is_listed
and extension.status == extension.STATUSES.APPROVED
)
@receiver(post_save, sender=extensions.models.Extension)
@receiver(post_save, sender=extensions.models.Version)
@receiver(post_save, sender=files.models.File) @receiver(post_save, sender=files.models.File)
def _set_is_listed( def _update_version(
sender: object, sender: object,
instance: Union[extensions.models.Extension, extensions.models.Version, files.models.File], instance: files.models.File,
raw: bool, raw: bool,
*args: object, *args: object,
**kwargs: object, **kwargs: object,
@ -89,26 +71,27 @@ def _set_is_listed(
if raw: if raw:
return return
if isinstance(instance, extensions.models.Extension): if hasattr(instance, 'version'):
extension = instance extension = instance.version.extension
else: with transaction.atomic():
# Since signals is called very early on, we can't assume file.extension will be available. # it's important to update is_listed before computing latest_version
extension = instance.extension # because latest_version for listed and unlisted extensions are defined differently
if not extension: extension.update_is_listed()
extension.update_latest_version()
@receiver(post_save, sender=extensions.models.Extension)
def _set_is_listed(
sender: object,
instance: extensions.models.Extension,
raw: bool,
*args: object,
**kwargs: object,
) -> None:
if raw:
return return
old_is_listed = extension.is_listed instance.update_is_listed()
new_is_listed = extension_should_be_listed(extension)
if old_is_listed == new_is_listed:
return
if extension.status == extensions.models.Extension.STATUSES.APPROVED and not new_is_listed:
extension.status = extensions.models.Extension.STATUSES.INCOMPLETE
logger.info('Extension pk=%s becomes listed', extension.pk)
extension.is_listed = new_is_listed
extension.save()
@receiver(post_save, sender=extensions.models.Extension) @receiver(post_save, sender=extensions.models.Extension)
@ -148,10 +131,9 @@ def _update_authors_follow(instance, action, model, reverse, pk_set, **kwargs):
@receiver(post_save, sender=extensions.models.Preview) @receiver(post_save, sender=extensions.models.Preview)
@receiver(post_save, sender=extensions.models.Version)
def _auto_approve_subsequent_uploads( def _auto_approve_subsequent_uploads(
sender: object, sender: object,
instance: Union[extensions.models.Preview, extensions.models.Version], instance: extensions.models.Preview,
created: bool, created: bool,
raw: bool, raw: bool,
**kwargs: object, **kwargs: object,
@ -163,7 +145,7 @@ def _auto_approve_subsequent_uploads(
if not instance.file_id: if not instance.file_id:
return return
# N.B.: currently, subsequent version and preview uploads get approved automatically, # N.B.: currently, subsequent preview uploads get approved automatically,
# if extension is currently listed (meaning, it was approved by a human already). # if extension is currently listed (meaning, it was approved by a human already).
extension = instance.extension extension = instance.extension
file = instance.file file = instance.file
@ -172,45 +154,3 @@ def _auto_approve_subsequent_uploads(
args = {'f_id': file.pk, 'pk': instance.pk, 'sender': sender, 's': file.source.name} args = {'f_id': file.pk, 'pk': instance.pk, 'sender': sender, 's': file.source.name}
logger.info('Auto-approving file pk=%(f_id)s of %(sender)s pk=%(pk)s source=%(s)s', args) logger.info('Auto-approving file pk=%(f_id)s of %(sender)s pk=%(pk)s source=%(s)s', args)
file.save(update_fields={'status', 'date_modified'}) file.save(update_fields={'status', 'date_modified'})
@receiver(post_save, sender=extensions.models.Version)
def _create_approval_activity_for_new_version_if_listed(
sender: object,
instance: extensions.models.Version,
created: bool,
raw: bool,
**kwargs: object,
):
if raw:
return
if not created:
return
extension = instance.extension
if not extension.is_listed or not instance.file:
return
ApprovalActivity(
type=ApprovalActivity.ActivityType.UPLOADED_NEW_VERSION,
user=instance.file.user,
extension=instance.extension,
message=f'uploaded new version: {instance.version}',
).save()
@receiver(post_delete, sender=extensions.models.Version)
@receiver(post_save, sender=extensions.models.Version)
def _update_extension_metadata_from_latest_version(
sender: object,
instance: extensions.models.Version,
**kwargs: object,
):
# this code will also be triggered when an extension is deleted
# and it deletes all related versions
extension = instance.extension
latest_version = extension.latest_version
# should check in case we are deleting the latest version, then no need to update anything
if not latest_version:
return
extension.update_metadata_from_version(latest_version)

View File

@ -38,7 +38,7 @@ function appendImageUploadForm() {
<div class="details"> <div class="details">
<div> <div>
<label for="${formsetPrefix}-${i}-caption">Image or Video</label> <label for="${formsetPrefix}-${i}-caption">Image or Video</label>
<input class="js-input-img-caption form-control" id="${formsetPrefix}-${i}-caption" type="text" maxlength="255" name="${formsetPrefix}-${i}-caption" placeholder="Description"> <input class="js-input-img-caption form-control mb-2" id="${formsetPrefix}-${i}-caption" type="text" maxlength="255" name="${formsetPrefix}-${i}-caption" placeholder="Description">
</div> </div>
<div class="details-buttons"> <div class="details-buttons">
<input accept="image/jpg,image/jpeg,image/png,image/webp,video/mp4" class="form-control form-control-sm 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 form-control-sm js-input-img" id="id_${formsetPrefix}-${i}-source" type="file" name="${formsetPrefix}-${i}-source">

View File

@ -18,7 +18,10 @@
</div> </div>
{% endblock hero_breadcrumbs %} {% endblock hero_breadcrumbs %}
<h1>{% include "extensions/components/icon.html" with classes="icon-lg" %} {{ extension.name }}</h1> <h1 class="d-flex">
{% include "extensions/components/icon.html" with classes="icon-lg me-2" %}
{{ extension.name }}
</h1>
<div class="hero-subtitle"> <div class="hero-subtitle">
{% if latest.tagline %} {% if latest.tagline %}
@ -39,16 +42,15 @@
{% endif %} {% endif %}
{% if not extension.is_approved %} {% if not extension.is_approved %}
<span class="badge badge-outline badge-status-{{ extension.get_status_display|slugify }}"> {% include "common/components/status.html" with object=extension classes="badge-outline" %}
{{ extension.get_status_display }}
</span>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
{% block hero_tabs %} {% block hero_tabs %}
<nav class="hero-tabs"> <nav class="d-flex flex-column-reverse flex-md-row">
<div class="hero-tabs">
<a href="{{ extension.get_absolute_url }}" class="{% if extension.get_absolute_url == request.get_full_path %}is-active{% endif %}"> <a href="{{ extension.get_absolute_url }}" class="{% if extension.get_absolute_url == request.get_full_path %}is-active{% endif %}">
{% trans "About" %} {% trans "About" %}
</a> </a>
@ -70,14 +72,14 @@
<a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}"> <a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}">
{% trans "Version History" %} {% trans "Version History" %}
</a> </a>
</div>
<span class="ms-auto"></span> <div class="btn-row hero-tabs-admin mb-3 mb-md-0">
<div class="btn-row">
{% if is_maintainer %} {% if is_maintainer %}
<div>
<a href="{{ extension.get_manage_url }}" class="btn"> <a href="{{ extension.get_manage_url }}" class="btn">
<i class="i-edit"></i> {% trans 'Edit' %} <i class="i-edit"></i> {% trans 'Edit' %}
</a> </a>
</div>
{% endif %} {% endif %}
{% if request.user.is_staff %} {% if request.user.is_staff %}
@ -87,16 +89,7 @@
<i class="i-chevron-down"></i> <i class="i-chevron-down"></i>
</button> </button>
<ul id="extension-admin-menu" class="dropdown-menu dropdown-menu-right js-dropdown-menu"> <ul id="extension-admin-menu" class="dropdown-menu dropdown-menu-right js-dropdown-menu">
<li> {% include "extensions/components/dropdown_admin.html" %}
<a href="{% url 'admin:extensions_extension_change' extension.pk %}" class="dropdown-item is-admin">
<i class="i-edit"></i> {% trans 'Edit' %}
</a>
</li>
<li>
<a href="{% url 'admin:ratings_rating_changelist' %}?extension_id={{ extension.pk }}" class="dropdown-item is-admin">
<i class="i-star"></i> {% trans 'Reviews' %}
</a>
</li>
</ul> </ul>
</div> </div>
{% endif %} {% endif %}

View File

@ -0,0 +1,46 @@
{% load i18n %}
<li>
<a href="{% url 'admin:extensions_extension_change' extension.pk %}" class="dropdown-item is-admin">
<i class="i-edit"></i> {% trans 'Extension' %}
</a>
</li>
{% if extension.latest_version %}
<li>
<a href="{% url 'admin:extensions_version_change' extension.latest_version.pk %}" class="dropdown-item is-admin">
<i class="i-puzzle"></i> {% trans 'Version' %}
</a>
</li>
{% endif %}
<li>
<a href="{% url 'admin:ratings_rating_changelist' %}?extension_id={{ extension.pk }}" class="dropdown-item is-admin">
<i class="i-star"></i> {% trans 'Ratings' %}
</a>
</li>
{% if not '/approval-queue/' in request.get_full_path %}
<li>
<a href="{{ extension.get_review_url }}" class="dropdown-item is-admin">
<i class="i-eye"></i> {% trans 'View in Approval Queue' %}
</a>
</li>
{% endif %}
{% if extension.authors.all.0 %}
<li class="dropdown-divider"></li>
<li>
<a href="{% url 'admin:users_user_change' extension.authors.all.0.pk %}" class="dropdown-item is-admin">
<i class="i-user"></i> {% trans 'Maintainer' %}{{ extension.authors.all.count|pluralize }}
{% if extension.authors.all.count > 1 %}({{ extension.authors.all.count }}){% endif %}
</a>
</li>
{% endif %}
{% if extension.team %}
<li>
<a href="{% url 'admin:teams_team_change' extension.team.pk %}" class="dropdown-item is-admin">
<i class="i-users"></i> {% trans 'Team' %}
</a>
</li>
{% endif %}

View File

@ -0,0 +1,16 @@
{% if user in extension.authors.all and user.teams.count > 0 %}
<div class="row mb-3">
<div class="col">
{# django won't allow submitting an empty field for a required field, so using a hack with an explicit required=True #}
{% include "common/components/field.html" with field=extension_form.team label="Assign Team" required=True %}
</div>
</div>
{% endif %}
<div>
{% include "common/components/field.html" with field=extension_form.description label="Description" placeholder="Describe the extension..." %}
</div>
<div>
{% include "common/components/field.html" with field=extension_form.support placeholder="https://example.com" %}
</div>

View File

@ -1,5 +1,5 @@
{% extends "common/base.html" %} {% extends "common/base.html" %}
{% load i18n common pipeline %} {% load common filters i18n pipeline %}
{% block page_title %} {% block page_title %}
{% with extension=extension_form.instance %} {% with extension=extension_form.instance %}
@ -38,16 +38,7 @@
</section> </section>
<section class="card p-3 mb-3"> <section class="card p-3 mb-3">
{% for field in extension_form %} {% include "extensions/components/extension_form.html" with extension_form=extension_form %}
{% if field != 'tags' %}
{# TODO: fix handling of tags #}
<div class="row">
<div class="col">
{% include "common/components/field.html" with placeholder="Enter the text here..." %}
</div>
</div>
{% endif %}
{% endfor %}
</section> </section>
<section class="mt-4"> <section class="mt-4">
@ -60,7 +51,8 @@
<section class="mt-4"> <section class="mt-4">
<h2>{% trans 'Media' %}</h2> <h2>{% trans 'Media' %}</h2>
<div class="row flex"> <div class="row flex">
<div class="col-md-6"> {# TODO: @web-assets check media brakpoints utilities 'md' #}
<div class="col-md-6 mb-3 mb-md-0">
<div class="box p-3"> <div class="box p-3">
{% trans "Featured Image" as featured_image_label %} {% trans "Featured Image" as featured_image_label %}
{% trans "A JPEG, PNG or WebP image, at least 1920 x 1080 and with aspect ratio of 16:9." as featured_image_help_text %} {% trans "A JPEG, PNG or WebP image, at least 1920 x 1080 and with aspect ratio of 16:9." as featured_image_help_text %}

View File

@ -13,51 +13,52 @@
<h2 class="me-auto">{{ type }}</h2> <h2 class="me-auto">{{ type }}</h2>
{% else %} {% else %}
<h2 class="align-items-center d-flex mb-0"> <h2 class="align-items-center d-flex mb-0">
<span class="d-md-block d-none">
<span class="me-3">{% blocktranslate %}Extensions with the tag{% endblocktranslate %}</span> <span class="me-3">{% blocktranslate %}Extensions with the tag{% endblocktranslate %}</span>
{% include "extensions/components/badge_tag.html" %} {% include "extensions/components/badge_tag.html" %}
</span>
<span class="d-md-none">{% blocktranslate %}Extensions{% endblocktranslate %}</span>
</h2> </h2>
{% endif %} {% endif %}
<div class="d-flex">
{% if tags %} {% if tags %}
<div class="d-flex flex-column flex-md-row"> <div class="dropdown dropdown-filter-sort me-2">
<div class="box dropdown me-md-3 p-2 rounded-2">
<button class="align-items-center d-flex dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="js-dropdown-menu-filter"> <button class="align-items-center d-flex dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="js-dropdown-menu-filter">
{% if tag %} {% if tag %}
{{ tag.name }} {{ tag.name }}
{# TODO: @back-end add tags count dynamic #} {# TODO: @back-end add tags count dynamic #}
<div class="align-items-center bg-secondary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4"> <div class="align-items-center bg-tertiary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
1 1
</div> </div>
{% else %} {% else %}
All All
{# TODO: @back-end add tags count dynamic #} {# TODO: @back-end add tags count dynamic #}
<div class="align-items-center bg-secondary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4"> <div class="align-items-center bg-tertiary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
1 1
</div> </div>
{% endif %} {% endif %}
<i class="i-chevron-down"></i> <i class="i-chevron-down"></i>
</button> </button>
<ul class="dropdown-menu dropdown-menu-filter-sort dropdown-menu-right js-dropdown-menu" id="js-dropdown-menu-filter"> <ul class="dropdown-menu dropdown-menu-filter dropdown-menu-filter-sort dropdown-menu-right js-dropdown-menu" id="js-dropdown-menu-filter">
<li> <li>
{% if tag %}
{# If tag is active, show button 'All'. #} {# If tag is active, show button 'All'. #}
{# TODO @back-end: Find a proper way to get the plural tag type to build the URL. #} {# TODO @back-end: Find a proper way to get the plural tag type to build the URL. #}
<a class="dropdown-item justify-content-between" href="/{{ tag.get_type_display|slugify }}s/"> <a class="dropdown-item {% if not tag.name %}is-active{% endif %}" href="/{{ tag.get_type_display|slugify }}s/">
All All
<div class="align-items-center bg-secondary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4"> <div class="align-items-center bg-tertiary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
1 1
</div> </div>
</a> </a>
{% endif %}
</li> </li>
{% for list_tag in tags %} {% for list_tag in tags %}
<li> <li>
<a class="dropdown-item justify-content-between" href="{% url "extensions:by-tag" tag_slug=list_tag.slug %}" title="{{ list_tag.name }}"> <a class="dropdown-item {% if tag.name == list_tag.name %}is-active{% endif %}" href="{% url "extensions:by-tag" tag_slug=list_tag.slug %}" title="{{ list_tag.name }}">
{{ list_tag.name }} {{ list_tag.name }}
<div class="align-items-center bg-secondary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4"> <div class="align-items-center bg-tertiary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
1 1
</div> </div>
</a> </a>
@ -65,7 +66,8 @@
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
<div class="box dropdown p-2 rounded-2"> {% endif %}
<div class="dropdown dropdown-filter-sort">
<button class="dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="js-dropdown-menu-sort"> <button class="dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="js-dropdown-menu-sort">
Sort by <i class="i-chevron-down"></i> Sort by <i class="i-chevron-down"></i>
</button> </button>
@ -108,7 +110,6 @@
</ul> </ul>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
{% else %} {% else %}

View File

@ -20,7 +20,7 @@
</div> </div>
</div> </div>
<div class="details"> <div class="details">
<div class="js-input-img-caption-helper"> <div class="js-input-img-caption-helper mb-2">
{% include "common/components/field.html" with field=inlineform.caption label='Image or Video' placeholder="Description" %} {% include "common/components/field.html" with field=inlineform.caption label='Image or Video' placeholder="Description" %}
</div> </div>
<div class="details-buttons js-input-img-helper"> <div class="details-buttons js-input-img-helper">

View File

@ -10,7 +10,7 @@
<span>{% trans 'Edit' %}</span> <span>{% trans 'Edit' %}</span>
</a> </a>
<div class="align-items-center d-flex"> <div class="align-items-center d-flex">
{% include "common/components/status.html" with object=extension class="badge-tag" %} {% include "common/components/status.html" with object=extension classes="badge-tag" %}
</div> </div>
</li> </li>
</ul> </ul>

View File

@ -1,6 +1,5 @@
{% extends "common/base.html" %} {% extends "common/base.html" %}
{% load filters %} {% load common filters i18n pipeline %}
{% load i18n common pipeline %}
{% block page_title %}{{ extension.name }}{% endblock page_title %} {% block page_title %}{{ extension.name }}{% endblock page_title %}
{% block content %} {% block content %}
@ -29,13 +28,7 @@
{{ form.errors }} {{ form.errors }}
<section class="card p-3"> <section class="card p-3">
<div> {% include "extensions/components/extension_form.html" with extension_form=form %}
{% include "common/components/field.html" with field=form.description label="Description" placeholder="Describe the extension..." %}
</div>
<div>
{% include "common/components/field.html" with field=form.support placeholder="https://example.com" %}
</div>
</section> </section>
<section class="mt-4"> <section class="mt-4">

View File

@ -0,0 +1,118 @@
from pathlib import Path
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase, APIClient
from common.tests.factories.users import UserFactory
from common.tests.factories.extensions import create_approved_version
from common.tests.utils import create_user_token
from extensions.models import Version
TEST_FILES_DIR = Path(__file__).resolve().parent / 'files'
class VersionUploadAPITest(APITestCase):
def setUp(self):
self.user = UserFactory()
self.token, self.token_key = create_user_token(user=self.user)
self.client = APIClient()
self.version = create_approved_version(
extension__extension_id="amaranth",
version="1.0.7",
file__user=self.user,
)
self.extension = self.version.extension
self.file_path = TEST_FILES_DIR / "amaranth-1.0.8.zip"
@staticmethod
def _get_upload_url(extension_id):
upload_url = reverse('extensions:upload-extension-version', args=(extension_id,))
return upload_url
def test_version_upload_unauthenticated(self):
with open(self.file_path, 'rb') as version_file:
response = self.client.post(
self._get_upload_url(self.extension.extension_id),
{
'version_file': version_file,
'release_notes': 'These are the release notes',
},
format='multipart',
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_version_upload_extension_not_maintained_by_user(self):
other_user = UserFactory()
other_extension = create_approved_version(
extension__extension_id='other_extension', file__user=other_user
).extension
with open(self.file_path, 'rb') as version_file:
response = self.client.post(
self._get_upload_url(other_extension.extension_id),
{
'version_file': version_file,
'release_notes': 'These are the release notes',
},
format='multipart',
HTTP_AUTHORIZATION=f'Bearer {self.token_key}',
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(
response.data['message'],
f'Extension "{other_extension.extension_id}" not maintained by user "{self.user.full_name}"',
)
def test_version_upload_extension_does_not_exist(self):
extension_name = 'extension_do_not_exist'
with open(self.file_path, 'rb') as version_file:
response = self.client.post(
self._get_upload_url(extension_name),
{
'version_file': version_file,
'release_notes': 'These are the release notes',
},
format='multipart',
HTTP_AUTHORIZATION=f'Bearer {self.token_key}',
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(response.data['message'], f'Extension "{extension_name}" not found')
def test_version_upload_success(self):
self.assertEqual(Version.objects.filter(extension=self.extension).count(), 1)
with open(self.file_path, 'rb') as version_file:
response = self.client.post(
self._get_upload_url(self.extension.extension_id),
{
'version_file': version_file,
'release_notes': 'These are the release notes',
},
format='multipart',
HTTP_AUTHORIZATION=f'Bearer {self.token_key}',
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Version.objects.filter(extension=self.extension).count(), 2)
def test_date_last_access(self):
self.assertIsNone(self.token.date_last_access)
with open(self.file_path, 'rb') as version_file:
response = self.client.post(
self._get_upload_url(self.extension.extension_id),
{
'version_file': version_file,
'release_notes': 'These are the release notes',
},
format='multipart',
HTTP_AUTHORIZATION=f'Bearer {self.token_key}',
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.token.refresh_from_db()
self.assertIsNotNone(self.token.date_last_access)

View File

@ -1,10 +1,47 @@
from django.test import TestCase from django.test import TestCase
from common.tests.factories.extensions import create_version from common.tests.factories.extensions import create_version
from files.models import File
class ApproveExtensionTest(TestCase): class ApproveExtensionTest(TestCase):
fixtures = ['licenses'] fixtures = ['licenses']
def test_approve_extension(self): # TODO def test_approve_extension(self):
create_version().extension first_version = create_version()
extension = first_version.extension
self.assertFalse(extension.is_listed)
extension.approve()
self.assertTrue(extension.is_listed)
# auto approve of new versions
new_version = create_version(extension=extension)
extension.refresh_from_db()
self.assertEqual(new_version, extension.latest_version)
self.assertTrue(new_version.is_listed)
self.assertTrue(extension.is_listed)
# TODO stop supporting direct file status updates, introduce methods for Version object
# that would replace the signals logic
# latest_version of approved extension must be listed
# check that we rollback latest_version when file is not approved
new_version.file.status = File.STATUSES.AWAITING_REVIEW
new_version.file.save()
self.assertFalse(new_version.is_listed)
extension.refresh_from_db()
self.assertEqual(first_version, extension.latest_version)
self.assertTrue(extension.is_listed)
# break the first_version, check that nothing is listed anymore
first_version.file.status = File.STATUSES.AWAITING_REVIEW
first_version.file.save()
self.assertFalse(first_version.is_listed)
extension.refresh_from_db()
self.assertFalse(extension.is_listed)
# this looks weird, but that's the current definition of latest_version, it's different
# for listed and unlisted extensions:
# now the extension is not listed, its latest_version doesn't have to be the latest
# listed version
self.assertEqual(new_version, extension.latest_version)

View File

@ -34,7 +34,7 @@ class DeleteTest(TestCase):
extension = version.extension extension = version.extension
version_file = version.file version_file = version.file
self.assertEqual(version_file.get_status_display(), 'Awaiting Review') self.assertEqual(version_file.get_status_display(), 'Awaiting Review')
self.assertEqual(extension.get_status_display(), 'Incomplete') self.assertEqual(extension.get_status_display(), 'Draft')
self.assertFalse(extension.is_listed) self.assertFalse(extension.is_listed)
self.assertEqual(extension.cannot_be_deleted_reasons, []) self.assertEqual(extension.cannot_be_deleted_reasons, [])
preview_file = extension.previews.first() preview_file = extension.previews.first()
@ -111,6 +111,7 @@ class DeleteTest(TestCase):
'featured_image', 'featured_image',
'icon', 'icon',
'is_listed', 'is_listed',
'latest_version',
'name', 'name',
'pk', 'pk',
'slug', 'slug',
@ -151,7 +152,7 @@ class DeleteTest(TestCase):
self.assertFalse(version.is_listed) self.assertFalse(version.is_listed)
extension = version.extension extension = version.extension
self.assertFalse(extension.is_listed) self.assertFalse(extension.is_listed)
self.assertEqual(extension.get_status_display(), 'Incomplete') self.assertEqual(extension.get_status_display(), 'Draft')
self.assertEqual(version.cannot_be_deleted_reasons, ['version_has_ratings']) self.assertEqual(version.cannot_be_deleted_reasons, ['version_has_ratings'])
self.assertEqual( self.assertEqual(

View File

@ -698,6 +698,7 @@ class VersionPermissionsTest(CreateFileTest):
_file.status = File.STATUSES.APPROVED _file.status = File.STATUSES.APPROVED
_file.save() _file.save()
extension.refresh_from_db()
self.assertNotEqual(version_original, extension.latest_version) self.assertNotEqual(version_original, extension.latest_version)
self.assertEqual(Extension.objects.count(), 1) self.assertEqual(Extension.objects.count(), 1)
self.assertEqual(Version.objects.count(), 2) self.assertEqual(Version.objects.count(), 2)

View File

@ -20,7 +20,7 @@ class ExtensionTest(TestCase):
extension__description='Extension description', extension__description='Extension description',
extension__website='https://example.com/', extension__website='https://example.com/',
extension__name='Extension name', extension__name='Extension name',
extension__status=Extension.STATUSES.INCOMPLETE, extension__status=Extension.STATUSES.DRAFT,
extension__support='https://example.com/', extension__support='https://example.com/',
file__metadata={ file__metadata={
'name': 'Extension name', 'name': 'Extension name',
@ -94,7 +94,7 @@ class VersionTest(TestCase):
extension__description='Extension description', extension__description='Extension description',
extension__website='https://example.com/', extension__website='https://example.com/',
extension__name='Extension name', extension__name='Extension name',
extension__status=Extension.STATUSES.INCOMPLETE, extension__status=Extension.STATUSES.DRAFT,
extension__support='https://example.com/', extension__support='https://example.com/',
) )
self.assertEqual(entries_for(self.version).count(), 0) self.assertEqual(entries_for(self.version).count(), 0)
@ -142,7 +142,7 @@ class UpdateMetadataTest(TestCase):
self.first_version = create_version( self.first_version = create_version(
extension__description='Extension description', extension__description='Extension description',
extension__name='name', extension__name='name',
extension__status=Extension.STATUSES.INCOMPLETE, extension__status=Extension.STATUSES.DRAFT,
extension__support='https://example.com/', extension__support='https://example.com/',
extension__website='https://example.com/', extension__website='https://example.com/',
file__metadata={ file__metadata={
@ -188,7 +188,7 @@ class UpdateMetadataTest(TestCase):
extension__description='Extension description', extension__description='Extension description',
extension__extension_id='lalalala', extension__extension_id='lalalala',
extension__name='name', extension__name='name',
extension__status=Extension.STATUSES.INCOMPLETE, extension__status=Extension.STATUSES.DRAFT,
extension__support='https://example.com/', extension__support='https://example.com/',
extension__website='https://example.com/', extension__website='https://example.com/',
file__metadata={ file__metadata={

View File

@ -571,7 +571,7 @@ class DraftsWarningTest(TestCase):
def test_page_contains_warning(self): def test_page_contains_warning(self):
version = create_version(extension__extension_id='draft_warning') version = create_version(extension__extension_id='draft_warning')
extension = version.extension extension = version.extension
self.assertEqual(extension.status, Extension.STATUSES.INCOMPLETE) self.assertEqual(extension.status, Extension.STATUSES.DRAFT)
self.client.force_login(extension.authors.all()[0]) self.client.force_login(extension.authors.all()[0])
response = self.client.get(reverse_lazy('extensions:submit')) response = self.client.get(reverse_lazy('extensions:submit'))
self.assertContains(response, extension.get_draft_url()) self.assertContains(response, extension.get_draft_url())

View File

@ -5,10 +5,13 @@ from django.test import TestCase
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, ImageFactory from common.tests.factories.files import FileFactory, ImageFactory
from common.tests.factories.teams import TeamFactory
from common.tests.factories.users import UserFactory
from common.tests.utils import _get_all_form_errors, CheckFilePropertiesMixin from common.tests.utils import _get_all_form_errors, CheckFilePropertiesMixin
from extensions.models import Extension from extensions.models import Extension
from files.models import File from files.models import File
from reviewers.models import ApprovalActivity from reviewers.models import ApprovalActivity
from teams.models import TeamsUsers
TEST_FILES_DIR = Path(__file__).resolve().parent / 'files' TEST_FILES_DIR = Path(__file__).resolve().parent / 'files'
POST_DATA = { POST_DATA = {
@ -218,7 +221,6 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase):
}, },
_get_all_form_errors(response), _get_all_form_errors(response),
) )
self.assertFalse("TODO: It should also list previews as required")
def test_post_upload_validation_error_duplicate_images(self): def test_post_upload_validation_error_duplicate_images(self):
extension = create_approved_version().extension extension = create_approved_version().extension
@ -492,10 +494,135 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase):
) )
self.assertEqual(response2.status_code, 302) self.assertEqual(response2.status_code, 302)
extension.refresh_from_db() extension.refresh_from_db()
self.assertEqual(extension.status, extension.STATUSES.INCOMPLETE) self.assertEqual(extension.status, extension.STATUSES.DRAFT)
self.assertEqual( self.assertEqual(
extension.review_activity.last().type, ApprovalActivity.ActivityType.AWAITING_CHANGES extension.review_activity.last().type, ApprovalActivity.ActivityType.AWAITING_CHANGES
) )
response3 = self.client.get(url) response3 = self.client.get(url)
self.assertEqual(response3.status_code, 302) self.assertEqual(response3.status_code, 302)
self.assertEqual(response3['Location'], extension.get_draft_url()) self.assertEqual(response3['Location'], extension.get_draft_url())
def test_team_field_in_draft_form(self):
version = create_version(
extension__status=Extension.STATUSES.DRAFT,
)
extension = version.extension
author = extension.authors.first()
self.client.force_login(author)
team = TeamFactory(slug='test-team')
TeamsUsers(team=team, user=author).save()
url = extension.get_draft_url()
response = self.client.get(url)
# a simple check that we have an input with the team option available
self.assertContains(response, 'value="test-team"')
# post the form to save the team field
response = self.client.post(
url,
{
**POST_DATA,
'team': 'test-team',
'save_draft': '',
},
)
self.assertEqual(response.status_code, 302, _get_all_form_errors(response))
extension.refresh_from_db()
self.assertEqual(extension.team.slug, 'test-team')
# can't assign an invalid team slug
response = self.client.post(
url,
{
**POST_DATA,
'team': '-',
'save_draft': '',
},
)
self.assertEqual(response.status_code, 200, _get_all_form_errors(response))
# add another team member, they shouldn't see the field
user = UserFactory()
team2 = TeamFactory(slug='test-team2')
TeamsUsers(team=team, user=user).save()
TeamsUsers(team=team2, user=user).save()
self.client.force_login(user)
response = self.client.get(url)
self.assertNotContains(response, 'value="test-team"')
response = self.client.post(
url,
{
**POST_DATA,
'team': 'test-team2',
'save_draft': '',
},
)
# the field is ignored: no error expected and the team wasn't updated
self.assertEqual(response.status_code, 302, _get_all_form_errors(response))
extension.refresh_from_db()
self.assertEqual(extension.team.slug, 'test-team')
def test_team_field_in_update_form(self):
"""This test is a copy-paste of the one above, only status, url and form data differ."""
version = create_version(
extension__status=Extension.STATUSES.APPROVED,
)
extension = version.extension
author = extension.authors.first()
self.client.force_login(author)
team = TeamFactory(slug='test-team')
TeamsUsers(team=team, user=author).save()
url = extension.get_manage_url()
response = self.client.get(url)
# a simple check that we have an input with the team option available
self.assertContains(response, 'value="test-team"')
# post the form to save the team field
response = self.client.post(
url,
{
**POST_DATA,
'team': 'test-team',
'save': '',
},
)
self.assertEqual(response.status_code, 302, _get_all_form_errors(response))
extension.refresh_from_db()
self.assertEqual(extension.team.slug, 'test-team')
# can't assign an invalid team slug
response = self.client.post(
url,
{
**POST_DATA,
'team': '-',
'save': '',
},
)
self.assertEqual(response.status_code, 200, _get_all_form_errors(response))
# add another team member, they shouldn't see the field
user = UserFactory()
team2 = TeamFactory(slug='test-team2')
TeamsUsers(team=team, user=user).save()
TeamsUsers(team=team2, user=user).save()
self.client.force_login(user)
response = self.client.get(url)
self.assertNotContains(response, 'value="test-team"')
response = self.client.post(
url,
{
**POST_DATA,
'team': 'test-team2',
'save': '',
},
)
# the field is ignored: no error expected and the team wasn't updated
self.assertEqual(response.status_code, 302, _get_all_form_errors(response))
extension.refresh_from_db()
self.assertEqual(extension.team.slug, 'test-team')

View File

@ -4,10 +4,11 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from common.tests.factories.extensions import create_version, create_approved_version from common.tests.factories.extensions import create_version, create_approved_version
from common.tests.factories.teams import TeamFactory
from common.tests.factories.users import UserFactory from common.tests.factories.users import UserFactory
from extensions.models import Extension, Version from extensions.models import Extension, Version
from files.models import File from files.models import File
from teams.models import Team from teams.models import Team, TeamsUsers
def _create_extension(): def _create_extension():
@ -18,7 +19,7 @@ def _create_extension():
extension__description='**Description in bold**', extension__description='**Description in bold**',
extension__support='https://example.com/issues/', extension__support='https://example.com/issues/',
extension__website='https://example.com/', extension__website='https://example.com/',
extension__status=Extension.STATUSES.INCOMPLETE, extension__status=Extension.STATUSES.DRAFT,
extension__average_score=2.5, extension__average_score=2.5,
file__metadata={ file__metadata={
'name': 'Test Add-on', 'name': 'Test Add-on',
@ -76,10 +77,12 @@ class PublicViewsTest(_BaseTestCase):
self.assertIn('license', v) self.assertIn('license', v)
self.assertIn('website', v) self.assertIn('website', v)
self.assertIn('schema_version', v) self.assertIn('schema_version', v)
# Blender expects urls in HTML anchors to end with .zip to handle drag&drop
self.assertEqual(v['archive_url'][-4:], '.zip')
return response return response
def test_home_page_view_api(self): def test_api(self):
url = '/' url = '/api/v1/extensions/'
self._test_format_json(url, HTTP_ACCEPT='application/json') self._test_format_json(url, HTTP_ACCEPT='application/json')
def test_home_page_view_html(self): def test_home_page_view_html(self):
@ -190,7 +193,7 @@ class ExtensionDetailViewTest(_BaseTestCase):
self._check_detail_page(response, extension) self._check_detail_page(response, extension)
def test_can_view_unlisted_extension_if_maintaner(self): def test_can_view_unlisted_extension_if_maintainer(self):
extension = _create_extension() extension = _create_extension()
self.client.force_login(extension.authors.first()) self.client.force_login(extension.authors.first())
@ -198,6 +201,20 @@ class ExtensionDetailViewTest(_BaseTestCase):
self._check_detail_page(response, extension) self._check_detail_page(response, extension)
def test_can_view_unlisted_extension_if_team_member(self):
extension = _create_extension()
team = TeamFactory(slug='test-team')
user = UserFactory()
TeamsUsers(team=team, user=user).save()
extension.team = team
extension.save()
self.client.force_login(user)
response = self.client.get(extension.get_manage_url())
self._check_detail_page(response, extension)
def test_can_view_publicly_listed_extension_anonymously(self): def test_can_view_publicly_listed_extension_anonymously(self):
extension = _create_extension() extension = _create_extension()
extension.approve() extension.approve()
@ -245,7 +262,7 @@ class ExtensionManageViewTest(_BaseTestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
def test_can_view_manage_extension_page_if_maintaner(self): def test_can_view_manage_extension_page_if_maintainer(self):
extension = _create_extension() extension = _create_extension()
extension.approve() extension.approve()
@ -254,6 +271,20 @@ class ExtensionManageViewTest(_BaseTestCase):
self._check_manage_page(response, extension) self._check_manage_page(response, extension)
def test_can_view_manage_extension_page_if_team_member(self):
extension = _create_extension()
extension.approve()
team = TeamFactory(slug='test-team')
user = UserFactory()
TeamsUsers(team=team, user=user).save()
extension.team = team
extension.save()
self.client.force_login(user)
response = self.client.get(extension.get_manage_url())
self._check_manage_page(response, extension)
class ListedExtensionsTest(_BaseTestCase): class ListedExtensionsTest(_BaseTestCase):
def setUp(self): def setUp(self):
@ -267,7 +298,7 @@ class ListedExtensionsTest(_BaseTestCase):
self.assertEqual(self._listed_extensions_count(), 1) self.assertEqual(self._listed_extensions_count(), 1)
def _listed_extensions_count(self): def _listed_extensions_count(self):
response = self.client.get('/?format=json', HTTP_ACCEPT='application/json') response = self.client.get('/api/v1/extensions/', HTTP_ACCEPT='application/json')
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')
@ -354,3 +385,17 @@ class UpdateVersionViewTest(_BaseTestCase):
self.assertEqual(response2.status_code, 302) self.assertEqual(response2.status_code, 302)
version.refresh_from_db() version.refresh_from_db()
self.assertEqual(version.blender_version_max, '4.2.0') self.assertEqual(version.blender_version_max, '4.2.0')
class MyExtensionsTest(_BaseTestCase):
def test_team_members_see_extensions_in_my_extensions(self):
extension = _create_extension()
team = TeamFactory(slug='test-team')
user = UserFactory()
TeamsUsers(team=team, user=user).save()
extension.team = team
extension.save()
self.client.force_login(user)
response = self.client.get(reverse('extensions:manage-list'))
self.assertContains(response, extension.name)

View File

@ -16,6 +16,11 @@ urlpatterns = [
), ),
# API # API
path('api/v1/extensions/', api.ExtensionsAPIView.as_view(), name='api'), path('api/v1/extensions/', api.ExtensionsAPIView.as_view(), name='api'),
path(
'api/v1/extensions/<str:extension_id>/versions/new/',
api.UploadExtensionVersionView.as_view(),
name='upload-extension-version',
),
# Public pages # Public pages
path('', public.HomeView.as_view(), name='home'), path('', public.HomeView.as_view(), name='home'),
path('search/', public.SearchView.as_view(), name='search'), path('search/', public.SearchView.as_view(), name='search'),
@ -74,7 +79,7 @@ urlpatterns = [
name='version-update', name='version-update',
), ),
path( path(
'<slug:slug>/<version>/download/', '<slug:slug>/<version>/download/<filename>',
public.extension_version_download, public.extension_version_download,
name='version-download', name='version-download',
), ),

View File

@ -1,14 +1,19 @@
import logging import logging
from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import serializers from rest_framework import serializers, status
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from drf_spectacular.utils import OpenApiParameter, extend_schema from drf_spectacular.utils import OpenApiParameter, extend_schema
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction
from common.compare import is_in_version_range, version from common.compare import is_in_version_range, version
from extensions.models import Extension, Platform from extensions.models import Extension, Platform, Version
from extensions.utils import clean_json_dictionary_from_optional_fields from extensions.utils import clean_json_dictionary_from_optional_fields
from extensions.views.manage import NewVersionView
from files.forms import FileFormSkipAgreed
from constants.base import ( from constants.base import (
@ -104,6 +109,7 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
class ExtensionsAPIView(APIView): class ExtensionsAPIView(APIView):
permission_classes = [AllowAny]
serializer_class = ListedExtensionsSerializer serializer_class = ListedExtensionsSerializer
@extend_schema( @extend_schema(
@ -149,3 +155,76 @@ class ExtensionsAPIView(APIView):
'version': 'v1', 'version': 'v1',
} }
) )
class ExtensionVersionSerializer(serializers.Serializer):
version_file = serializers.FileField()
release_notes = serializers.CharField(max_length=1024, required=False)
class UploadExtensionVersionView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
request=ExtensionVersionSerializer,
responses={201: 'Extension version uploaded successfully!'},
)
def post(self, request, extension_id, *args, **kwargs):
serializer = ExtensionVersionSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
user = request.user
version_file = serializer.validated_data['version_file']
release_notes = serializer.validated_data.get('release_notes', '')
extension = Extension.objects.filter(extension_id=extension_id).first()
if not extension:
return Response(
{
'message': f'Extension "{extension_id}" not found',
},
status=status.HTTP_404_NOT_FOUND,
)
if not extension.has_maintainer(user):
return Response(
{
'message': f'Extension "{extension_id}" not maintained by user "{user}"',
},
status=status.HTTP_403_FORBIDDEN,
)
# Create a NewVersionView instance to handle file creation
new_version_view = NewVersionView(request=request, extension=extension)
# Pass the version_file to the form
form = new_version_view.get_form(FileFormSkipAgreed)
form.fields['source'].initial = version_file
if not form.is_valid():
return Response({'message': form.errors}, status=status.HTTP_400_BAD_REQUEST)
with transaction.atomic():
# Create the file instance
file_instance = form.save(commit=False)
file_instance.user = user
file_instance.save()
# Create the version from the file
version = Version.objects.update_or_create(
extension=extension,
file=file_instance,
release_notes=release_notes,
**file_instance.parsed_version_fields,
)[0]
return Response(
{
'message': 'Extension version uploaded successfully!',
'extension_id': extension_id,
'version_file': version_file.name,
'release_notes': version.release_notes,
},
status=status.HTTP_201_CREATED,
)

View File

@ -99,7 +99,16 @@ class ManageListView(LoginRequiredMixin, ListView):
template_name = 'extensions/manage/list.html' template_name = 'extensions/manage/list.html'
def get_queryset(self): def get_queryset(self):
return Extension.objects.authored_by(user_id=self.request.user.pk) return Extension.objects.authored_by(self.request.user).prefetch_related(
'authors',
'preview_set',
'preview_set__file',
'ratings',
'team',
'versions',
'versions__file',
'versions__tags',
)
class UpdateExtensionView( class UpdateExtensionView(
@ -121,7 +130,7 @@ class UpdateExtensionView(
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
extension = self.extension extension = self.extension
if extension.status == extension.STATUSES.INCOMPLETE: if extension.status == extension.STATUSES.DRAFT:
return redirect('extensions:draft', slug=extension.slug, type_slug=extension.type_slug) return redirect('extensions:draft', slug=extension.slug, type_slug=extension.type_slug)
else: else:
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
@ -330,12 +339,12 @@ class DraftExtensionView(
@property @property
def success_message(self) -> str: def success_message(self) -> str:
if self.extension.status == Extension.STATUSES.INCOMPLETE: if self.extension.status == Extension.STATUSES.DRAFT:
return "Updated successfully" return "Updated successfully"
return "Submitted to the Approval Queue" return "Submitted to the Approval Queue"
def test_func(self) -> bool: def test_func(self) -> bool:
return self.extension.status == Extension.STATUSES.INCOMPLETE return self.extension.status == Extension.STATUSES.DRAFT
def get_form_kwargs(self): def get_form_kwargs(self):
form_kwargs = super().get_form_kwargs() form_kwargs = super().get_form_kwargs()

View File

@ -23,7 +23,7 @@ class ExtensionQuerysetMixin:
if self.request.user.is_staff: if self.request.user.is_staff:
return Extension.objects.all() return Extension.objects.all()
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
return Extension.objects.listed_or_authored_by(user_id=self.request.user.pk) return Extension.objects.listed_or_authored_by(self.request.user)
return Extension.objects.listed return Extension.objects.listed
@ -32,7 +32,7 @@ class MaintainedExtensionMixin:
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
self.extension = get_object_or_404( self.extension = get_object_or_404(
Extension.objects.authored_by(user_id=self.request.user.pk), Extension.objects.authored_by(self.request.user),
slug=self.kwargs['slug'], slug=self.kwargs['slug'],
) )
return super().dispatch(*args, **kwargs) return super().dispatch(*args, **kwargs)

View File

@ -16,8 +16,6 @@ from constants.base import (
from stats.models import ExtensionDownload, VersionDownload from stats.models import ExtensionDownload, VersionDownload
import teams.models import teams.models
from .api import ExtensionsAPIView
User = get_user_model() User = get_user_model()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -32,14 +30,6 @@ class HomeView(ListedExtensionsView):
paginate_by = 16 paginate_by = 16
template_name = 'extensions/home.html' template_name = 'extensions/home.html'
def dispatch(self, request, *args, **kwargs):
"""Return the API view if requesting a JSON."""
if request.headers.get('Accept') == 'application/json':
api_view = ExtensionsAPIView.as_view()
return api_view(request, *args, **kwargs)
else:
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
q = ( q = (
@ -47,12 +37,12 @@ class HomeView(ListedExtensionsView):
.get_queryset() .get_queryset()
.prefetch_related( .prefetch_related(
'authors', 'authors',
'latest_version__file',
'latest_version__tags',
'preview_set', 'preview_set',
'preview_set__file', 'preview_set__file',
'ratings', 'ratings',
'versions', 'team',
'versions__file',
'versions__tags',
) )
) )
context['addons'] = q.filter(type=EXTENSION_TYPE_CHOICES.BPY)[:8] context['addons'] = q.filter(type=EXTENSION_TYPE_CHOICES.BPY)[:8]
@ -60,12 +50,17 @@ class HomeView(ListedExtensionsView):
return context return context
def extension_version_download(request, type_slug, slug, version): def extension_version_download(request, type_slug, slug, version, filename):
"""Download an extension version and count downloads.""" """Download an extension version and count downloads.
The `filename` parameter is used to pass a file name ending with `.zip`.
This is a convention Blender uses to initiate an extension installation on an HTML anchor
drag&drop.
"""
extension_version = get_object_or_404(Version, extension__slug=slug, version=version) extension_version = get_object_or_404(Version, extension__slug=slug, version=version)
ExtensionDownload.create_from_request(request, object_id=extension_version.extension_id) ExtensionDownload.create_from_request(request, object_id=extension_version.extension_id)
VersionDownload.create_from_request(request, object_id=extension_version.pk) VersionDownload.create_from_request(request, object_id=extension_version.pk)
return redirect(extension_version.downloadable_signed_url) return redirect(extension_version.downloadable_signed_url + f'?filename={filename}')
class SearchView(ListedExtensionsView): class SearchView(ListedExtensionsView):
@ -81,7 +76,9 @@ class SearchView(ListedExtensionsView):
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
if self.kwargs.get('tag_slug'): if self.kwargs.get('tag_slug'):
queryset = queryset.filter(versions__tags__slug=self.kwargs['tag_slug']).distinct() queryset = queryset.filter(
latest_version__tags__slug=self.kwargs['tag_slug']
).distinct()
if self.kwargs.get('team_slug'): if self.kwargs.get('team_slug'):
queryset = queryset.filter(team__slug=self.kwargs['team_slug']) queryset = queryset.filter(team__slug=self.kwargs['team_slug'])
if self.kwargs.get('user_id'): if self.kwargs.get('user_id'):
@ -99,17 +96,17 @@ class SearchView(ListedExtensionsView):
Q(slug__icontains=token) Q(slug__icontains=token)
| Q(name__icontains=token) | Q(name__icontains=token)
| Q(description__icontains=token) | Q(description__icontains=token)
| Q(versions__tags__name__icontains=token) | Q(latest_version__tags__name__icontains=token)
) )
queryset = queryset.filter(search_query).distinct() queryset = queryset.filter(search_query).distinct()
return queryset.prefetch_related( return queryset.prefetch_related(
'authors', 'authors',
'latest_version__file',
'latest_version__tags',
'preview_set', 'preview_set',
'preview_set__file', 'preview_set__file',
'ratings', 'ratings',
'versions', 'team',
'versions__file',
'versions__tags',
) )
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):

View File

@ -18,8 +18,8 @@ class UploadFileView(LoginRequiredMixin, CreateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
drafts = Extension.objects.authored_by(user_id=self.request.user.pk).filter( drafts = Extension.objects.authored_by(self.request.user).filter(
status=Extension.STATUSES.INCOMPLETE status=Extension.STATUSES.DRAFT
) )
context['drafts'] = drafts context['drafts'] = drafts
return context return context
@ -41,7 +41,7 @@ class UploadFileView(LoginRequiredMixin, CreateView):
if parsed_extension_fields: if parsed_extension_fields:
# Try to look up extension by the same author and file info # Try to look up extension by the same author and file info
extension = ( extension = (
Extension.objects.authored_by(user_id=self.request.user.pk) Extension.objects.authored_by(self.request.user)
.filter(type=self.file.type, **parsed_extension_fields) .filter(type=self.file.type, **parsed_extension_fields)
.first() .first()
) )

View File

@ -167,6 +167,16 @@ class FileForm(forms.ModelForm):
return self.cleaned_data return self.cleaned_data
class FileFormSkipAgreed(FileForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['agreed_with_terms'].required = False
def clean(self):
self.cleaned_data['agreed_with_terms'] = True
super().clean()
class BaseMediaFileForm(forms.ModelForm): class BaseMediaFileForm(forms.ModelForm):
class Meta: class Meta:
model = files.models.File model = files.models.File

View File

@ -29,7 +29,7 @@
</td> </td>
<td class="notifications-item-nav"> <td class="notifications-item-nav">
<div class="dropdown"> <div class="dropdown">
<button class="btn btn-link dropdown-toggle js-dropdown-toggle active" data-toggle-menu-id="js-notifications-item-nav-{{ notification.id }}"> <button class="btn btn-link dropdown-toggle js-dropdown-toggle px-0" data-toggle-menu-id="js-notifications-item-nav-{{ notification.id }}">
<i class="i-more-vertical"></i> <i class="i-more-vertical"></i>
</button> </button>
<ul class="dropdown-menu dropdown-menu-right js-dropdown-menu" id="js-notifications-item-nav-{{ notification.id }}"> <ul class="dropdown-menu dropdown-menu-right js-dropdown-menu" id="js-notifications-item-nav-{{ notification.id }}">

View File

@ -4,7 +4,20 @@
roles: [common] roles: [common]
vars: vars:
playbook_type: deploy playbook_type: deploy
lock_file_path: /tmp/deploy-{{project_slug}}.lock
tasks: tasks:
- import_tasks: tasks/pull.yaml - import_tasks: tasks/pull.yaml
- import_tasks: tasks/deploy.yaml - import_tasks: tasks/deploy.yaml
pre_tasks:
- stat: path={{lock_file_path}}
register: lock_file
- fail: msg="Another deploy was already started by {{lock_file.stat.pw_name}} {{((ansible_date_time.epoch|float - lock_file.stat.mtime) / 60)|int}}min ago.\nAdd '-e override_lock=true' to override if the deploy was abandoned."
when: lock_file.stat.exists|bool and override_lock is undefined
- copy: dest={{lock_file_path}} content="{{ansible_user_id}} locked at {{now(fmt='%Y-%m-%d %H:%M:%S')}}"
post_tasks:
- file: path={{lock_file_path}} state=absent

View File

@ -33,6 +33,10 @@ server {
location /media/ { location /media/ {
alias {{ dir.media | regex_replace('\\/*$', '/') }}; alias {{ dir.media | regex_replace('\\/*$', '/') }};
if ($arg_filename) {
add_header Content-Disposition "attachment; filename=$arg_filename";
}
} }
location /static/ { location /static/ {
alias {{ dir.static | regex_replace('\\/*$', '/') }}; alias {{ dir.static | regex_replace('\\/*$', '/') }};

View File

@ -1,7 +1,7 @@
{% load i18n extensions %} {% load i18n extensions %}
{% has_maintainer extension as is_maintainer %} {% has_maintainer extension as is_maintainer %}
<div class="card p-3 mb-3 ratings-summary"> <div class="card p-3 mb-3 mt-2 mt-lg-0 ratings-summary">
{% if extension.text_ratings_count %} {% if extension.text_ratings_count %}
<div class="summary-container"> <div class="summary-container">
<div class="summary-value"> <div class="summary-value">

View File

@ -19,7 +19,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="row"> <div class="row">
<div class="col-8"> <div class="col-md-8">
<section> <section>
{% if my_rating and not my_rating.is_listed %} {% if my_rating and not my_rating.is_listed %}
{% include "ratings/components/rating.html" with rating=my_rating classes="mb-2" %} {% include "ratings/components/rating.html" with rating=my_rating classes="mb-2" %}
@ -39,7 +39,7 @@
</section> </section>
</div> </div>
<div class="col-4"> <div class="col-md-4">
{% include "ratings/components/summary.html" %} {% include "ratings/components/summary.html" %}
</div> </div>
</div> </div>

View File

@ -0,0 +1,16 @@
# Generated by Django 4.2.11 on 2024-05-24 16:15
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('reviewers', '0009_alter_approvalactivity_type'),
]
operations = [
migrations.DeleteModel(
name='ReviewerSubscription',
),
]

View File

@ -1,22 +1,14 @@
import logging
from django.conf import settings
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.template import loader
from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import common.help_texts import common.help_texts
from extensions.models import Extension
from common.model_mixins import CreatedModifiedMixin, RecordDeletionMixin from common.model_mixins import CreatedModifiedMixin, RecordDeletionMixin
from utils import absolutify, send_mail
from constants.base import EXTENSION_TYPE_CHOICES from constants.base import EXTENSION_TYPE_CHOICES
from constants.reviewers import CANNED_RESPONSE_CATEGORY_CHOICES from constants.reviewers import CANNED_RESPONSE_CATEGORY_CHOICES
User = get_user_model() User = get_user_model()
logger = logging.getLogger('users')
class CannedResponse(CreatedModifiedMixin, models.Model): class CannedResponse(CreatedModifiedMixin, models.Model):
@ -35,45 +27,6 @@ class CannedResponse(CreatedModifiedMixin, models.Model):
return str(self.name) return str(self.name)
class ReviewerSubscription(CreatedModifiedMixin, models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
extension = models.ForeignKey(Extension, on_delete=models.CASCADE)
def send_notification(self, version):
logger.info(
'Sending extension update notice to %s for %s' % (self.user.email, self.extension.pk)
)
listing_url = absolutify(
reverse('extensions.detail', args=[self.extension.pk], add_prefix=False)
)
context = {
'name': self.extension.name,
'url': listing_url,
'number': version.version,
'review': absolutify(
reverse(
'reviewers.review',
kwargs={
'extension_id': self.extension.pk,
},
add_prefix=False,
)
),
'SITE_URL': settings.SITE_URL,
}
# Not being localised because we don't know the reviewer's locale.
subject = 'Blender Extensions: %s Updated' % self.extension.name
template = loader.get_template('reviewers/emails/notify_update.ltxt')
send_mail(
subject,
template.render(context),
recipient_list=[self.user.email],
from_email=settings.EXTENSIONS_EMAIL,
use_deny_list=False,
)
class ApprovalActivity(CreatedModifiedMixin, RecordDeletionMixin, models.Model): class ApprovalActivity(CreatedModifiedMixin, RecordDeletionMixin, models.Model):
class ActivityType(models.TextChoices): class ActivityType(models.TextChoices):
COMMENT = "COM", _("Comment") COMMENT = "COM", _("Comment")
@ -84,7 +37,7 @@ class ApprovalActivity(CreatedModifiedMixin, RecordDeletionMixin, models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True) user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
extension = models.ForeignKey( extension = models.ForeignKey(
Extension, 'extensions.Extension',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='review_activity', related_name='review_activity',
) )

View File

@ -2,14 +2,22 @@
<tr> <tr>
<td class="ext-review-list-type">{{ extension.get_type_display }}</td> <td class="ext-review-list-type">{{ extension.get_type_display }}</td>
<td class="ext-review-list-name"> <td class="ext-review-list-name">
<div class="d-flex">
<a href="{{ extension.get_review_url }}"> <a href="{{ extension.get_review_url }}">
{% include "extensions/components/icon.html" %} {% include "extensions/components/icon.html" %}
</a> </a>
<a href="{{ extension.get_review_url }}"> <a href="{{ extension.get_review_url }}" class="w-100">
{{ extension.name }} {{ extension.name }}
</a> </a>
</div>
</td>
<td>
{% include "extensions/components/authors.html" %}
{% if extension.team %}
<a class="text-secondary" href="{{ extension.team.get_absolute_url }}">({{ extension.team.name }})</a>
{% endif %}
</td> </td>
<td>{% include "extensions/components/authors.html" %}</td>
<td title="{{ extension.date_created }}">{{ extension.date_created|naturaltime_compact }}</td> <td title="{{ extension.date_created }}">{{ extension.date_created|naturaltime_compact }}</td>
<td class="ext-review-list-activity" colspan="2"> <td class="ext-review-list-activity" colspan="2">
<a href="{{ extension.get_review_url }}#activity-{{ stats.last_activity.id }}"> <a href="{{ extension.get_review_url }}#activity-{{ stats.last_activity.id }}">
@ -25,10 +33,7 @@
<td> <td>
<a href="{{ extension.get_review_url }}" class="text-decoration-none"> <a href="{{ extension.get_review_url }}" class="text-decoration-none">
{% with last_type=stats.last_type_display|default:"Awaiting Review" %} {% with last_type=stats.last_type_display|default:"Awaiting Review" %}
<div class="d-block badge badge-status-{{ last_type|slugify }}"> {% include "common/components/status.html" with label=last_type slug=last_type|slugify object=extension classes="d-block" icon=True %}
<i class="i-eye"></i>
<span>{{ last_type }}</span>
</div>
{% endwith %} {% endwith %}
</a> </a>
</td> </td>

View File

@ -13,7 +13,8 @@
{% endblock hero_breadcrumbs %} {% endblock hero_breadcrumbs %}
{% block hero_tabs %} {% block hero_tabs %}
<div class="hero-tabs"> <div class="d-flex flex-column-reverse flex-md-row">
<div class="hero-tabs">
<a href="#about"> <a href="#about">
{% trans "About" %} {% trans "About" %}
</a> </a>
@ -23,14 +24,14 @@
<a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}"> <a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}">
{% trans "Version History" %} {% trans "Version History" %}
</a> </a>
</div>
<span class="ms-auto"></span> <div class="btn-row hero-tabs-admin mb-3 mb-md-0">
<div class="btn-row">
{% if is_maintainer %} {% if is_maintainer %}
<div>
<a href="{{ extension.get_manage_url }}" class="btn"> <a href="{{ extension.get_manage_url }}" class="btn">
<i class="i-edit"></i> {% trans 'Edit' %} <i class="i-edit"></i> {% trans 'Edit' %}
</a> </a>
</div>
{% endif %} {% endif %}
{% if request.user.is_staff %} {% if request.user.is_staff %}
@ -40,26 +41,7 @@
<i class="i-chevron-down"></i> <i class="i-chevron-down"></i>
</button> </button>
<ul id="extension-admin-menu" class="dropdown-menu dropdown-menu-right js-dropdown-menu"> <ul id="extension-admin-menu" class="dropdown-menu dropdown-menu-right js-dropdown-menu">
<li> {% include "extensions/components/dropdown_admin.html" %}
<a href="{% url 'admin:extensions_extension_change' extension.pk %}" class="dropdown-item is-admin">
{% trans 'Extension' %}
</a>
</li>
{% if extension.latest_version %}
<li>
<a href="{% url 'admin:extensions_version_change' extension.latest_version.pk %}" class="dropdown-item is-admin">
{% trans 'Version' %}
</a>
</li>
{% endif %}
{% if extension.authors.all.0 %}
<li class="dropdown-divider"></li>
<li>
<a href="{% url 'admin:users_user_change' extension.authors.all.0.pk %}" class="dropdown-item is-admin">
{% trans 'User' %}
</a>
</li>
{% endif %}
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
@ -103,10 +85,16 @@
<li id="activity-{{ activity.id }}"> <li id="activity-{{ activity.id }}">
<article class="activity-item comment-card"> <article class="activity-item comment-card">
<i class="activity-icon {% if activity.type in status_change_types %}i-activity-{{ activity.get_type_display|slugify }}{% else %}i-comment{% endif %}"></i> <i class="activity-icon {% if activity.type in status_change_types %}i-activity-{{ activity.get_type_display|slugify }}{% else %}i-comment{% endif %}"></i>
<aside> <aside class="d-flex flex-column text-secondary">
<a href="{% url "extensions:by-author" user_id=activity.user.pk %}"> <a href="{% url "extensions:by-author" user_id=activity.user.pk %}">
{% include "users/components/profile_display.html" with user=activity.user classes="" %} {% include "users/components/profile_display.html" with user=activity.user classes="" %}
</a> </a>
{% if is_maintainer %}
<span title="Extension Maintainer"><i class="i-mic"></i></span>
{% elif activity.user.is_moderator %}
<span title="Moderator"><i class="i-shield"></i></span>
{% endif %}
</aside> </aside>
<div> <div>
<header> <header>
@ -123,7 +111,7 @@
{% endif %} {% endif %}
</li> </li>
<li class="ms-auto"> <li class="ms-auto">
<a href="#activity-{{ activity.id }}" title="{{ activity.date_created }}"> <a href="#activity-{{ activity.id }}" title="{{ activity.date_created|date:'l jS, F Y - H:i' }}">
{{ activity.date_created|naturaltime_compact }} {{ activity.date_created|naturaltime_compact }}
</a> </a>
</li> </li>

View File

@ -22,12 +22,13 @@
<section class="ext-review-list"> <section class="ext-review-list">
{% if object_list %} {% if object_list %}
<div class="overflow-x-auto">
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th>{% trans "Type" %}</th> <th>{% trans "Type" %}</th>
<th>{% trans "Name" %}</th> <th>{% trans "Name" %}</th>
<th>{% trans "Author" %}</th> <th>{% trans "Maintainer" %}</th>
<th>{% trans "Submitted" %}</th> <th>{% trans "Submitted" %}</th>
<th colspan="2">{% trans "Activity" %}</th> <th colspan="2">{% trans "Activity" %}</th>
<th></th> <th></th>
@ -42,6 +43,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% else %} {% else %}
<p>{% trans "No extensions to review." %}</p> <p>{% trans "No extensions to review." %}</p>
{% endif %} {% endif %}

View File

@ -1,7 +1,7 @@
import logging import logging
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, transaction
from django.urls import reverse from django.urls import reverse
from common.model_mixins import CreatedModifiedMixin from common.model_mixins import CreatedModifiedMixin
@ -49,3 +49,39 @@ class TeamsUsers(CreatedModifiedMixin, models.Model):
@property @property
def is_manager(self) -> bool: def is_manager(self) -> bool:
return self.role == TEAM_ROLE_MANAGER return self.role == TEAM_ROLE_MANAGER
@transaction.atomic
def delete(self):
# This runs when a user is leaving a team.
# If the user had authored an extension, other team members shouldn't have access to it,
# unless the extension has another maintainer who is still on that team.
for extension in self.user.extensions.filter(team=self.team).all():
# assuming small datasets, not optimizing db access
authors = extension.authors.all()
has_other_authors_from_the_team = False
for author in authors:
if author.pk == self.user.pk:
continue
if self.team in author.teams.all():
has_other_authors_from_the_team = True
break
if not has_other_authors_from_the_team:
extension.team = None
extension.save(update_fields={'team'})
return super().delete()
@property
def may_leave(self) -> bool:
nr_of_managers = TeamsUsers.objects.filter(role=TEAM_ROLE_MANAGER, team=self.team).count()
user_is_manager = (
TeamsUsers.objects.filter(
role=TEAM_ROLE_MANAGER,
team=self.team,
user=self.user,
).first()
is not None
)
if user_is_manager and nr_of_managers < 2:
return False
return True

View File

@ -0,0 +1,49 @@
{% 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 team_name=object.name %}Leave team {{ team_name }}?{% endblocktranslate %}
</h2>
{% if may_leave %}
<p>
{% blocktranslate %}
If you wish to join the team again in the future, you will need to ask the team manager to add you back.
{% endblocktranslate %}
{% if will_lose_access_to %}
<br>
{% blocktranslate %}
You will lose access to all team extensions that were not uploaded by you:
{% endblocktranslate %}
<ul>
{% for extension in will_lose_access_to %}
<li><a href="{{ extension.get_absolute_url }}">{{ extension }}</a></li>
{% endfor %}
</ul>
{% endif %}
</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-log-out"></i>
<span>{% trans 'Confirm Leave' %}</span>
</button>
</form>
</div>
{% else %}
<p>
{% trans 'You cannot leave this team because you are the only manager.' %}
</p>
{% endif %}
</div>
</div>
</div>
{% endblock content %}

View File

@ -4,6 +4,7 @@
<h1 class="mb-3">Teams</h1> <h1 class="mb-3">Teams</h1>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% if team_memberships %}
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>
@ -13,10 +14,14 @@
<th> <th>
Role Role
</th> </th>
<th>
Users
</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for team_member in user.team_users.all %} {% for team_member in team_memberships %}
{% with team=team_member.team %} {% with team=team_member.team %}
<tr> <tr>
<td> <td>
@ -27,11 +32,38 @@
{{ team_member.get_role_display }} {{ team_member.get_role_display }}
</div> </div>
</td> </td>
<td class="text-center">
<div class="badge">
{{ team.team_users.all.count }}
</div>
</td>
<td>
<div class="dropdown">
<button class="btn btn-link dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="team-{{ team.slug }}">
<i class="i-more-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-right js-dropdown-menu" id="team-{{ team.slug }}">
<li>
<a class="dropdown-item {% if not team_member.may_leave %}dropdown-item-disabled{% endif %}" href="{% url 'teams:leave-team' slug=team.slug %}">
<i class="i-log-out"></i>Leave Team
</a>
</li>
</ul>
</div>
</td>
</tr> </tr>
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %}
<p>
You are not assigned to any teams yet.
</p>
{% endif %}
<p class="pt-3">
We can help you with team management if you <a href="https://projects.blender.org/infrastructure/extensions-website/issues/new?title=Team%20Management%20Request&body=Please%20add%20user%20X%20to%20team%20Y">submit your request</a> to the issue tracker.
</p>
</div> </div>
</div> </div>
{% endblock settings %} {% endblock settings %}

52
teams/tests/test_leave.py Normal file
View File

@ -0,0 +1,52 @@
from django.test import TestCase
from django.urls import reverse
from common.tests.factories.extensions import create_version
from common.tests.factories.teams import TeamFactory
from common.tests.factories.users import UserFactory
from constants.base import TEAM_ROLE_MANAGER, TEAM_ROLE_MEMBER
from teams.models import TeamsUsers
class TeamLeaveTest(TestCase):
def test_the_only_manager_cant_leave(self):
team = TeamFactory(slug='test-team')
user = UserFactory()
TeamsUsers(team=team, user=user, role=TEAM_ROLE_MANAGER).save()
self.assertEqual(user.teams.count(), 1)
self.client.force_login(user)
response = self.client.get(reverse('teams:leave-team', args=[team.slug]))
self.assertContains(response, 'cannot leave')
self.client.post(reverse('teams:leave-team', args=[team.slug]))
user.refresh_from_db()
self.assertEqual(user.teams.count(), 1)
# create another manager
user2 = UserFactory()
TeamsUsers(team=team, user=user2, role=TEAM_ROLE_MANAGER).save()
# try to leave again
response = self.client.get(reverse('teams:leave-team', args=[team.slug]))
self.assertNotContains(response, 'cannot leave')
self.client.post(reverse('teams:leave-team', args=[team.slug]))
user.refresh_from_db()
self.assertEqual(user.teams.count(), 0)
def test_extensions_lose_team_assignment(self):
team = TeamFactory(slug='test-team')
user = UserFactory()
TeamsUsers(team=team, user=user, role=TEAM_ROLE_MEMBER).save()
extension = create_version().extension
extension.team = team
extension.authors.add(user)
extension.save()
self.client.force_login(user)
self.client.post(reverse('teams:leave-team', args=[team.slug]))
user.refresh_from_db()
self.assertEqual(user.teams.count(), 0)
extension.refresh_from_db()
self.assertIsNone(extension.team)

View File

@ -5,4 +5,9 @@ import teams.views
app_name = 'teams' app_name = 'teams'
urlpatterns = [ urlpatterns = [
path('settings/teams/', teams.views.TeamsView.as_view(), name='list'), path('settings/teams/', teams.views.TeamsView.as_view(), name='list'),
path(
'settings/leave-team/<slug:slug>/',
teams.views.LeaveTeamView.as_view(),
name='leave-team',
),
] ]

View File

@ -1,12 +1,43 @@
"""Team pages.""" """Team pages."""
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect
from django.views.generic import ListView from django.views.generic import ListView
from django.views.generic.detail import DetailView
import teams.models from extensions.models import Extension
from teams.models import Team, TeamsUsers
class TeamsView(LoginRequiredMixin, ListView): class TeamsView(LoginRequiredMixin, ListView):
model = teams.models.Team model = Team
def get_queryset(self): def get_context_data(self, **kwargs):
return self.request.user.teams.all() context = super().get_context_data(**kwargs)
context['team_memberships'] = (
self.request.user.team_users.select_related('team').order_by('team__name').all()
)
return context
class LeaveTeamView(LoginRequiredMixin, DetailView):
model = Team
template_name = 'teams/confirm_leave.html'
def post(self, request, *args, **kwargs):
team = self.get_object()
team_user = TeamsUsers.objects.filter(team=team, user=self.request.user).first()
if team_user and team_user.may_leave:
team_user.delete()
return redirect('teams:list')
def get_context_data(self, **kwargs):
team = self.get_object()
team_user = TeamsUsers.objects.filter(team=team, user=self.request.user).first()
context = super().get_context_data(**kwargs)
context['may_leave'] = team_user.may_leave
context['will_lose_access_to'] = list(
Extension.objects.authored_by(self.request.user).exclude(
maintainer__user_id=self.request.user.pk
)
)
return context

View File

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

View File

@ -2,9 +2,9 @@
<div class="nav nav-pills flex-column" role="tablist" aria-orientation="vertical"> <div class="nav nav-pills flex-column" role="tablist" aria-orientation="vertical">
{% include "common/components/nav_link.html" with name="users:my-profile" title="Profile" classes="i-home py-2" %} {% include "common/components/nav_link.html" with name="users:my-profile" title="Profile" classes="i-home py-2" %}
{% if user.teams.count %}
{% include "common/components/nav_link.html" with name="teams:list" title="Teams" classes="i-users py-2" %} {% include "common/components/nav_link.html" with name="teams:list" title="Teams" classes="i-users py-2" %}
{% endif %}
{% include "common/components/nav_link.html" with name="apitokens:list" title="Tokens" classes="i-lock py-2" %}
<div class="nav-pills-divider"></div> <div class="nav-pills-divider"></div>