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>
<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>
</td>
</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',
'reviewers',
'stats',
'apitokens',
'taggit',
'drf_spectacular',
'drf_spectacular_sidecar',
@ -273,6 +274,8 @@ ACTSTREAM_SETTINGS = {
REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_AUTHENTICATION_CLASSES': ('apitokens.authentication.UserTokenAuthentication',),
'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',),
}
SPECTACULAR_SETTINGS = {

View File

@ -40,6 +40,7 @@ urlpatterns = [
path('', include('teams.urls')),
path('', include('reviewers.urls')),
path('', include('notifications.urls')),
path('', include('apitokens.urls')),
path('api/swagger/', RedirectView.as_view(url='/api/v1/swagger/')),
path('api/v1/', SpectacularAPIView.as_view(), name='schema_v1'),
path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'),

View File

@ -33,6 +33,11 @@
function submitFormFileInputClear() {
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) {
e.target.classList.remove('is-invalid');
});
@ -51,7 +56,7 @@
});
}
// Create finction commentForm
// Create function commentForm
function commentForm() {
const commentForm = document.querySelector('.js-comment-form');
if (!commentForm) {
@ -131,6 +136,24 @@
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
function init() {
agreeWithTerms();
@ -138,6 +161,7 @@
btnBack();
commentForm();
copyInstallUrl();
navGlobalLinkSearch();
}
document.addEventListener('DOMContentLoaded', function() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,51 @@
.dropdown-menu-filter
.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
height: calc(var(--spacer) * 2)
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
max-height: calc(var(--spacer) * 28)
max-height: calc(var(--spacer) * 24.25)
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
input
color: var(--bwa-color-text)
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
+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)
.nav-global .nav-global-nav-links li a:hover,
.nav-global .nav-global-nav-links li a.nav-global-link-active
background-color: var(--bwa-color-accent-bg) !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
.nav-global .nav-global-container
max-width: 1320px

View File

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

View File

@ -107,10 +107,10 @@
</ul>
<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>
<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 %}>
<form action="{% url 'extensions:search' %}" class="navbar-search" method="GET">
<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">
<i class="i-search"></i>
</button>
@ -118,6 +118,10 @@
</search>
</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 %}
<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>
@ -125,7 +129,7 @@
{% endblock nav-upload %}
<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>
{% if user.is_authenticated %}
@ -140,9 +144,9 @@
</a>
</li>
<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-chevron-down"></i>
<i class="d-none d-md-inline i-chevron-down"></i>
</button>
<ul id="nav-account-dropdown" aria-labelledby="navbarDropdown" class="dropdown-menu dropdown-menu-right js-dropdown-menu">
{% if user.is_staff %}
@ -209,10 +213,12 @@
</ul>
</li>
{% 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>
<span>{% trans "Sign in" %}</span>
</a>
</li>
{% endif %}
<li>
@ -258,6 +264,16 @@
</div>
{% 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" %}
{% endblock footer %}

View File

@ -14,7 +14,7 @@
{% if not field.is_hidden %}
<label for="{{ field.id_for_label }}" class="form-check-label">
{{ 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>
{% endif %}
@ -24,7 +24,7 @@
{% if not field.is_hidden %}
<label for="{{ field.id_for_label }}">
{{ 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>
{% endif %}

View File

@ -3,9 +3,9 @@
{% load common %}
{% 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 %}
{% absolute_url default_image_path as image_url %}

View File

@ -1,26 +1,59 @@
{% load common %}
{% get_proper_elided_page_range page_obj as page_range %}
<nav>
<ul class="pagination">
{% 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 %}
{% if page_number == '…' %}
<li class="page-item disabled">
<span class="page-link px-0">...</span>
</li>
{% 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>
</li>
{% else %}
<li class="page-item">
<a href="?page={{ page_number }}">{{ page_number }}</a>
<a href="?{% query_transform page=page_number %}">{{ page_number }}</a>
</li>
{% endif %}
{% endfor %}
{% endif %}
<li class="page-item">
{{ page_obj.paginator.count }} {{ label }}{{ page_obj.paginator.count | pluralize }}
{% if page_obj.has_next %}
<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>
{% 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>
{% endif %}
<div class="mb-2 mb-lg-0 page-item text-center">
{{ page_obj.paginator.count }} {{ label }}{{ page_obj.paginator.count | pluralize }}
</div>
</nav>

View File

@ -1,52 +1,6 @@
{% with status=object.get_status_display %}
{% if 'incomplete' in status.lower %}
<div class="badge badge-warning {{ class }}" title="Requires re-uploading or editing">
<i class="i-alert-triangle"></i>
<span>{{ status }}</span>
{% with label=label|default:object.get_status_display slug=slug|default:object.get_status_display|slugify %}
<div class="badge badge-status-{{ slug }} {{ classes }}">
{% if icon %}<i class="i-status-{{ slug }}"></i>{% endif %}
<span>{{ label }}</span>
</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 %}

View File

@ -7,7 +7,7 @@
</li>
<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>
{% endif %}
@ -19,7 +19,7 @@
{% if pager.has_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 class="page-item page-last">

View File

@ -29,6 +29,17 @@ def absolute_url(context, path: str) -> str:
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:
def __init__(self, pager):
self.pager = pager

View File

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

View File

@ -1,9 +1,13 @@
import itertools
from typing import Tuple
import django.urls as urls
from django.utils.functional import cached_property
from django.utils.regex_helper import normalize
from apitokens.models import UserToken
try: # Django 2.0
url_resolver_types = (urls.URLResolver,)
DJANGO_2 = True
@ -109,3 +113,11 @@ class CheckFilePropertiesMixin:
self.assertEqual(file.original_name, kwargs.get('original_name'))
if 'size_bytes' in kwargs:
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')),
('THEME', 2, _('Theme')),
)
STATUS_INCOMPLETE = 1
STATUS_DRAFT = 1
STATUS_AWAITING_REVIEW = 2
STATUS_APPROVED = 3
STATUS_DISABLED = 4
@ -18,7 +18,7 @@ STATUS_DISABLED_BY_AUTHOR = 5
# Extension statuses
EXTENSION_STATUS_CHOICES = Choices(
('INCOMPLETE', STATUS_INCOMPLETE, _('Incomplete')),
('DRAFT', STATUS_DRAFT, _('Draft')),
('AWAITING_REVIEW', STATUS_AWAITING_REVIEW, _('Awaiting Review')),
('APPROVED', STATUS_APPROVED, _('Approved')),
('DISABLED', STATUS_DISABLED, _('Disabled by staff')),

View File

@ -83,6 +83,7 @@ class ExtensionAdmin(admin.ModelAdmin):
'website',
'icon',
'featured_image',
'latest_version',
)
autocomplete_fields = ('team',)
@ -104,6 +105,7 @@ class ExtensionAdmin(admin.ModelAdmin):
'description',
('icon', 'featured_image'),
'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
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:
"""Validate all nested forms and form(set)s first."""
if 'submit_draft' in self.data:
@ -198,6 +210,27 @@ class ExtensionUpdateForm(forms.ModelForm):
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):
"""Perform additional validation and status changes."""
super().clean()
@ -206,7 +239,7 @@ class ExtensionUpdateForm(forms.ModelForm):
if self.instance.status != self.instance.STATUSES.AWAITING_REVIEW:
self.add_error(None, self.msg_cannot_convert_to_draft)
else:
self.instance.status = self.instance.STATUSES.INCOMPLETE
self.instance.status = self.instance.STATUSES.DRAFT
self.instance.converted_to_draft = True
# 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)),
('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)),
('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)),
('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)),

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_TYPE_CHOICES,
EXTENSION_TYPE_SLUGS,
EXTENSION_TYPE_SLUGS_SINGULAR,
FILE_STATUS_CHOICES,
)
from files.models import File
from reviewers.models import ApprovalActivity
import common.help_texts
import extensions.fields
@ -128,12 +131,19 @@ class ExtensionManager(models.Manager):
def unlisted(self):
return self.exclude(status=self.model.STATUSES.APPROVED)
def authored_by(self, user_id: int):
return self.filter(maintainer__user_id=user_id)
def _authored_by_filter(self, user):
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(
Q(status=self.model.STATUSES.APPROVED) | Q(maintainer__user_id=user_id)
Q(status=self.model.STATUSES.APPROVED) | self._authored_by_filter(user)
).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.',
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(
'files.File',
@ -183,7 +200,7 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
)
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(
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:
return EXTENSION_TYPE_SLUGS[self.type]
@property
def type_slug_singular(self) -> str:
return EXTENSION_TYPE_SLUGS_SINGULAR[self.type]
@property
def status_slug(self) -> str:
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):
update_fields = set()
metadata = version.file.metadata
@ -325,34 +342,6 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
return [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):
"""Return whether an add-on can request a review or not."""
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):
return (
self.status == self.STATUSES.INCOMPLETE
self.status == self.STATUSES.DRAFT
and not self.has_complete_metadata()
and self.latest_version is not None
)
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:
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:
"""Return True if given user can rate this extension.
@ -414,6 +407,40 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
lookup_field = 'slug'
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):
name = models.CharField(max_length=128, null=False, blank=False, unique=True)
@ -606,7 +633,7 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
@property
def is_listed(self):
# 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
def cannot_be_deleted_reasons(self) -> List[str]:
@ -631,12 +658,14 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
@property
def download_url(self) -> str:
filename = f'{self.extension.type_slug_singular}-{self.extension.slug}-v{self.version}.zip'
return reverse(
'extensions:version-download',
kwargs={
'type_slug': self.extension.type_slug,
'slug': self.extension.slug,
'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):
extension = models.ForeignKey(Extension, on_delete=models.CASCADE)

View File

@ -4,11 +4,11 @@ import logging
from actstream.actions import follow, unfollow
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.db.models.signals import m2m_changed, pre_save, post_save, pre_delete, post_delete
from django.db import transaction
from django.db.models.signals import m2m_changed, pre_save, post_save, pre_delete
from django.dispatch import receiver
from constants.activity import Flag
from reviewers.models import ApprovalActivity
import extensions.models
import files.models
@ -46,42 +46,24 @@ def _log_deletion(
@receiver(pre_save, sender=extensions.models.Extension)
@receiver(pre_save, sender=extensions.models.Version)
def _record_changes(
sender: object,
instance: Union[extensions.models.Extension, extensions.models.Version],
instance: extensions.models.Extension,
update_fields: object,
**kwargs: object,
) -> None:
was_changed, old_state = instance.pre_save_record(update_fields=update_fields)
if hasattr(instance, 'name'):
instance.sanitize('name', was_changed, old_state, **kwargs)
if hasattr(instance, 'description'):
instance.sanitize('description', was_changed, old_state, **kwargs)
instance.record_status_change(was_changed, old_state, **kwargs)
@receiver(post_save, sender=extensions.models.Extension)
def _update_search_index(sender, instance, **kw):
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)
# TODO? move this out into version.approve that would take care of updating file.status and
# recomputing extension's is_listed and latest_version fields
@receiver(post_save, sender=files.models.File)
def _set_is_listed(
def _update_version(
sender: object,
instance: Union[extensions.models.Extension, extensions.models.Version, files.models.File],
instance: files.models.File,
raw: bool,
*args: object,
**kwargs: object,
@ -89,26 +71,27 @@ def _set_is_listed(
if raw:
return
if isinstance(instance, extensions.models.Extension):
extension = instance
else:
# Since signals is called very early on, we can't assume file.extension will be available.
extension = instance.extension
if not extension:
if hasattr(instance, 'version'):
extension = instance.version.extension
with transaction.atomic():
# it's important to update is_listed before computing latest_version
# because latest_version for listed and unlisted extensions are defined differently
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
old_is_listed = extension.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()
instance.update_is_listed()
@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.Version)
def _auto_approve_subsequent_uploads(
sender: object,
instance: Union[extensions.models.Preview, extensions.models.Version],
instance: extensions.models.Preview,
created: bool,
raw: bool,
**kwargs: object,
@ -163,7 +145,7 @@ def _auto_approve_subsequent_uploads(
if not instance.file_id:
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).
extension = instance.extension
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}
logger.info('Auto-approving file pk=%(f_id)s of %(sender)s pk=%(pk)s source=%(s)s', args)
file.save(update_fields={'status', 'date_modified'})
@receiver(post_save, sender=extensions.models.Version)
def _create_approval_activity_for_new_version_if_listed(
sender: object,
instance: extensions.models.Version,
created: bool,
raw: bool,
**kwargs: object,
):
if raw:
return
if not created:
return
extension = instance.extension
if not extension.is_listed or not instance.file:
return
ApprovalActivity(
type=ApprovalActivity.ActivityType.UPLOADED_NEW_VERSION,
user=instance.file.user,
extension=instance.extension,
message=f'uploaded new version: {instance.version}',
).save()
@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>
<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 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">

View File

@ -18,7 +18,10 @@
</div>
{% 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">
{% if latest.tagline %}
@ -39,16 +42,15 @@
{% endif %}
{% if not extension.is_approved %}
<span class="badge badge-outline badge-status-{{ extension.get_status_display|slugify }}">
{{ extension.get_status_display }}
</span>
{% include "common/components/status.html" with object=extension classes="badge-outline" %}
{% endif %}
</div>
</div>
</div>
{% 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 %}">
{% trans "About" %}
</a>
@ -70,14 +72,14 @@
<a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}">
{% trans "Version History" %}
</a>
<span class="ms-auto"></span>
<div class="btn-row">
</div>
<div class="btn-row hero-tabs-admin mb-3 mb-md-0">
{% if is_maintainer %}
<div>
<a href="{{ extension.get_manage_url }}" class="btn">
<i class="i-edit"></i> {% trans 'Edit' %}
</a>
</div>
{% endif %}
{% if request.user.is_staff %}
@ -87,16 +89,7 @@
<i class="i-chevron-down"></i>
</button>
<ul id="extension-admin-menu" class="dropdown-menu dropdown-menu-right js-dropdown-menu">
<li>
<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>
{% include "extensions/components/dropdown_admin.html" %}
</ul>
</div>
{% 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" %}
{% load i18n common pipeline %}
{% load common filters i18n pipeline %}
{% block page_title %}
{% with extension=extension_form.instance %}
@ -38,16 +38,7 @@
</section>
<section class="card p-3 mb-3">
{% for field in 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 %}
{% include "extensions/components/extension_form.html" with extension_form=extension_form %}
</section>
<section class="mt-4">
@ -60,7 +51,8 @@
<section class="mt-4">
<h2>{% trans 'Media' %}</h2>
<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">
{% 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 %}

View File

@ -13,51 +13,52 @@
<h2 class="me-auto">{{ type }}</h2>
{% else %}
<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>
{% include "extensions/components/badge_tag.html" %}
</span>
<span class="d-md-none">{% blocktranslate %}Extensions{% endblocktranslate %}</span>
</h2>
{% endif %}
<div class="d-flex">
{% if tags %}
<div class="d-flex flex-column flex-md-row">
<div class="box dropdown me-md-3 p-2 rounded-2">
<div class="dropdown dropdown-filter-sort me-2">
<button class="align-items-center d-flex dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="js-dropdown-menu-filter">
{% if tag %}
{{ tag.name }}
{# 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
</div>
{% else %}
All
{# 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
</div>
{% endif %}
<i class="i-chevron-down"></i>
</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>
{% if tag %}
{# If tag is active, show button 'All'. #}
{# 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
<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
</div>
</a>
{% endif %}
</li>
{% for list_tag in tags %}
<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 }}
<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
</div>
</a>
@ -65,7 +66,8 @@
{% endfor %}
</ul>
</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">
Sort by <i class="i-chevron-down"></i>
</button>
@ -108,7 +110,6 @@
</ul>
</div>
</div>
{% endif %}
</div>
</div>
{% else %}

View File

@ -20,7 +20,7 @@
</div>
</div>
<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" %}
</div>
<div class="details-buttons js-input-img-helper">

View File

@ -10,7 +10,7 @@
<span>{% trans 'Edit' %}</span>
</a>
<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>
</li>
</ul>

View File

@ -1,6 +1,5 @@
{% extends "common/base.html" %}
{% load filters %}
{% load i18n common pipeline %}
{% load common filters i18n pipeline %}
{% block page_title %}{{ extension.name }}{% endblock page_title %}
{% block content %}
@ -29,13 +28,7 @@
{{ form.errors }}
<section class="card p-3">
<div>
{% 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>
{% include "extensions/components/extension_form.html" with extension_form=form %}
</section>
<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 common.tests.factories.extensions import create_version
from files.models import File
class ApproveExtensionTest(TestCase):
fixtures = ['licenses']
def test_approve_extension(self): # TODO
create_version().extension
def test_approve_extension(self):
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
version_file = version.file
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.assertEqual(extension.cannot_be_deleted_reasons, [])
preview_file = extension.previews.first()
@ -111,6 +111,7 @@ class DeleteTest(TestCase):
'featured_image',
'icon',
'is_listed',
'latest_version',
'name',
'pk',
'slug',
@ -151,7 +152,7 @@ class DeleteTest(TestCase):
self.assertFalse(version.is_listed)
extension = version.extension
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(

View File

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

View File

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

View File

@ -571,7 +571,7 @@ class DraftsWarningTest(TestCase):
def test_page_contains_warning(self):
version = create_version(extension__extension_id='draft_warning')
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])
response = self.client.get(reverse_lazy('extensions:submit'))
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.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 extensions.models import Extension
from files.models import File
from reviewers.models import ApprovalActivity
from teams.models import TeamsUsers
TEST_FILES_DIR = Path(__file__).resolve().parent / 'files'
POST_DATA = {
@ -218,7 +221,6 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase):
},
_get_all_form_errors(response),
)
self.assertFalse("TODO: It should also list previews as required")
def test_post_upload_validation_error_duplicate_images(self):
extension = create_approved_version().extension
@ -492,10 +494,135 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase):
)
self.assertEqual(response2.status_code, 302)
extension.refresh_from_db()
self.assertEqual(extension.status, extension.STATUSES.INCOMPLETE)
self.assertEqual(extension.status, extension.STATUSES.DRAFT)
self.assertEqual(
extension.review_activity.last().type, ApprovalActivity.ActivityType.AWAITING_CHANGES
)
response3 = self.client.get(url)
self.assertEqual(response3.status_code, 302)
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 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 extensions.models import Extension, Version
from files.models import File
from teams.models import Team
from teams.models import Team, TeamsUsers
def _create_extension():
@ -18,7 +19,7 @@ def _create_extension():
extension__description='**Description in bold**',
extension__support='https://example.com/issues/',
extension__website='https://example.com/',
extension__status=Extension.STATUSES.INCOMPLETE,
extension__status=Extension.STATUSES.DRAFT,
extension__average_score=2.5,
file__metadata={
'name': 'Test Add-on',
@ -76,10 +77,12 @@ class PublicViewsTest(_BaseTestCase):
self.assertIn('license', v)
self.assertIn('website', 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
def test_home_page_view_api(self):
url = '/'
def test_api(self):
url = '/api/v1/extensions/'
self._test_format_json(url, HTTP_ACCEPT='application/json')
def test_home_page_view_html(self):
@ -190,7 +193,7 @@ class ExtensionDetailViewTest(_BaseTestCase):
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()
self.client.force_login(extension.authors.first())
@ -198,6 +201,20 @@ class ExtensionDetailViewTest(_BaseTestCase):
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):
extension = _create_extension()
extension.approve()
@ -245,7 +262,7 @@ class ExtensionManageViewTest(_BaseTestCase):
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.approve()
@ -254,6 +271,20 @@ class ExtensionManageViewTest(_BaseTestCase):
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):
def setUp(self):
@ -267,7 +298,7 @@ class ListedExtensionsTest(_BaseTestCase):
self.assertEqual(self._listed_extensions_count(), 1)
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['Content-Type'], 'application/json')
@ -354,3 +385,17 @@ class UpdateVersionViewTest(_BaseTestCase):
self.assertEqual(response2.status_code, 302)
version.refresh_from_db()
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
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
path('', public.HomeView.as_view(), name='home'),
path('search/', public.SearchView.as_view(), name='search'),
@ -74,7 +79,7 @@ urlpatterns = [
name='version-update',
),
path(
'<slug:slug>/<version>/download/',
'<slug:slug>/<version>/download/<filename>',
public.extension_version_download,
name='version-download',
),

View File

@ -1,14 +1,19 @@
import logging
from rest_framework.permissions import AllowAny
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.permissions import IsAuthenticated
from drf_spectacular.utils import OpenApiParameter, extend_schema
from django.core.exceptions import ValidationError
from django.db import transaction
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.views.manage import NewVersionView
from files.forms import FileFormSkipAgreed
from constants.base import (
@ -104,6 +109,7 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
class ExtensionsAPIView(APIView):
permission_classes = [AllowAny]
serializer_class = ListedExtensionsSerializer
@extend_schema(
@ -149,3 +155,76 @@ class ExtensionsAPIView(APIView):
'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'
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(
@ -121,7 +130,7 @@ class UpdateExtensionView(
def get(self, request, *args, **kwargs):
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)
else:
return super().get(request, *args, **kwargs)
@ -330,12 +339,12 @@ class DraftExtensionView(
@property
def success_message(self) -> str:
if self.extension.status == Extension.STATUSES.INCOMPLETE:
if self.extension.status == Extension.STATUSES.DRAFT:
return "Updated successfully"
return "Submitted to the Approval Queue"
def test_func(self) -> bool:
return self.extension.status == Extension.STATUSES.INCOMPLETE
return self.extension.status == Extension.STATUSES.DRAFT
def get_form_kwargs(self):
form_kwargs = super().get_form_kwargs()

View File

@ -23,7 +23,7 @@ class ExtensionQuerysetMixin:
if self.request.user.is_staff:
return Extension.objects.all()
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
@ -32,7 +32,7 @@ class MaintainedExtensionMixin:
def dispatch(self, *args, **kwargs):
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'],
)
return super().dispatch(*args, **kwargs)

View File

@ -16,8 +16,6 @@ from constants.base import (
from stats.models import ExtensionDownload, VersionDownload
import teams.models
from .api import ExtensionsAPIView
User = get_user_model()
log = logging.getLogger(__name__)
@ -32,14 +30,6 @@ class HomeView(ListedExtensionsView):
paginate_by = 16
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):
context = super().get_context_data(**kwargs)
q = (
@ -47,12 +37,12 @@ class HomeView(ListedExtensionsView):
.get_queryset()
.prefetch_related(
'authors',
'latest_version__file',
'latest_version__tags',
'preview_set',
'preview_set__file',
'ratings',
'versions',
'versions__file',
'versions__tags',
'team',
)
)
context['addons'] = q.filter(type=EXTENSION_TYPE_CHOICES.BPY)[:8]
@ -60,12 +50,17 @@ class HomeView(ListedExtensionsView):
return context
def extension_version_download(request, type_slug, slug, version):
"""Download an extension version and count downloads."""
def extension_version_download(request, type_slug, slug, version, filename):
"""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)
ExtensionDownload.create_from_request(request, object_id=extension_version.extension_id)
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):
@ -81,7 +76,9 @@ class SearchView(ListedExtensionsView):
def get_queryset(self):
queryset = super().get_queryset()
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'):
queryset = queryset.filter(team__slug=self.kwargs['team_slug'])
if self.kwargs.get('user_id'):
@ -99,17 +96,17 @@ class SearchView(ListedExtensionsView):
Q(slug__icontains=token)
| Q(name__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()
return queryset.prefetch_related(
'authors',
'latest_version__file',
'latest_version__tags',
'preview_set',
'preview_set__file',
'ratings',
'versions',
'versions__file',
'versions__tags',
'team',
)
def get_context_data(self, **kwargs):

View File

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

View File

@ -167,6 +167,16 @@ class FileForm(forms.ModelForm):
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 Meta:
model = files.models.File

View File

@ -29,7 +29,7 @@
</td>
<td class="notifications-item-nav">
<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>
</button>
<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]
vars:
playbook_type: deploy
lock_file_path: /tmp/deploy-{{project_slug}}.lock
tasks:
- import_tasks: tasks/pull.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/ {
alias {{ dir.media | regex_replace('\\/*$', '/') }};
if ($arg_filename) {
add_header Content-Disposition "attachment; filename=$arg_filename";
}
}
location /static/ {
alias {{ dir.static | regex_replace('\\/*$', '/') }};

View File

@ -1,7 +1,7 @@
{% load i18n extensions %}
{% 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 %}
<div class="summary-container">
<div class="summary-value">

View File

@ -19,7 +19,7 @@
{% endif %}
</div>
<div class="row">
<div class="col-8">
<div class="col-md-8">
<section>
{% if my_rating and not my_rating.is_listed %}
{% include "ratings/components/rating.html" with rating=my_rating classes="mb-2" %}
@ -39,7 +39,7 @@
</section>
</div>
<div class="col-4">
<div class="col-md-4">
{% include "ratings/components/summary.html" %}
</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.db import models
from django.template import loader
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import common.help_texts
from extensions.models import Extension
from common.model_mixins import CreatedModifiedMixin, RecordDeletionMixin
from utils import absolutify, send_mail
from constants.base import EXTENSION_TYPE_CHOICES
from constants.reviewers import CANNED_RESPONSE_CATEGORY_CHOICES
User = get_user_model()
logger = logging.getLogger('users')
class CannedResponse(CreatedModifiedMixin, models.Model):
@ -35,45 +27,6 @@ class CannedResponse(CreatedModifiedMixin, models.Model):
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 ActivityType(models.TextChoices):
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)
extension = models.ForeignKey(
Extension,
'extensions.Extension',
on_delete=models.CASCADE,
related_name='review_activity',
)

View File

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

View File

@ -13,6 +13,7 @@
{% endblock hero_breadcrumbs %}
{% block hero_tabs %}
<div class="d-flex flex-column-reverse flex-md-row">
<div class="hero-tabs">
<a href="#about">
{% trans "About" %}
@ -23,14 +24,14 @@
<a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}">
{% trans "Version History" %}
</a>
<span class="ms-auto"></span>
<div class="btn-row">
</div>
<div class="btn-row hero-tabs-admin mb-3 mb-md-0">
{% if is_maintainer %}
<div>
<a href="{{ extension.get_manage_url }}" class="btn">
<i class="i-edit"></i> {% trans 'Edit' %}
</a>
</div>
{% endif %}
{% if request.user.is_staff %}
@ -40,26 +41,7 @@
<i class="i-chevron-down"></i>
</button>
<ul id="extension-admin-menu" class="dropdown-menu dropdown-menu-right js-dropdown-menu">
<li>
<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 %}
{% include "extensions/components/dropdown_admin.html" %}
</ul>
</div>
{% endif %}
@ -103,10 +85,16 @@
<li id="activity-{{ activity.id }}">
<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>
<aside>
<aside class="d-flex flex-column text-secondary">
<a href="{% url "extensions:by-author" user_id=activity.user.pk %}">
{% include "users/components/profile_display.html" with user=activity.user classes="" %}
</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>
<div>
<header>
@ -123,7 +111,7 @@
{% endif %}
</li>
<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 }}
</a>
</li>

View File

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

View File

@ -1,7 +1,7 @@
import logging
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 common.model_mixins import CreatedModifiedMixin
@ -49,3 +49,39 @@ class TeamsUsers(CreatedModifiedMixin, models.Model):
@property
def is_manager(self) -> bool:
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>
<div class="row">
<div class="col">
{% if team_memberships %}
<table class="table table-hover">
<thead>
<tr>
@ -13,10 +14,14 @@
<th>
Role
</th>
<th>
Users
</th>
<th></th>
</tr>
</thead>
<tbody>
{% for team_member in user.team_users.all %}
{% for team_member in team_memberships %}
{% with team=team_member.team %}
<tr>
<td>
@ -27,11 +32,38 @@
{{ team_member.get_role_display }}
</div>
</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>
{% endwith %}
{% endfor %}
</tbody>
</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>
{% 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'
urlpatterns = [
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."""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect
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):
model = teams.models.Team
model = Team
def get_queryset(self):
return self.request.user.teams.all()
def get_context_data(self, **kwargs):
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 py-4">
<div class="row">
<div class="d-none d-md-block col-md-3">
<div class="col-md-3">
<div class="is-sticky pt-4">
<nav class="box nav-drawer-nested p-3">
<div class="nav-drawer-body fw-bold">
<nav class="box p-2">
<div class="nav-drawer-body">
{% include 'users/settings/tabs.html' %}
</div>
</nav>

View File

@ -2,9 +2,9 @@
<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" %}
{% if user.teams.count %}
{% 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>