Extensions list: sort_by parameter #159
@ -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
1
apitokens/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
default_app_config = 'apitokens.apps.TokensConfig'
|
6
apitokens/apps.py
Normal file
6
apitokens/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TokensConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apitokens'
|
33
apitokens/authentication.py
Normal file
33
apitokens/authentication.py
Normal 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)
|
30
apitokens/migrations/0001_initial.py
Normal file
30
apitokens/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
0
apitokens/migrations/__init__.py
Normal file
0
apitokens/migrations/__init__.py
Normal file
36
apitokens/models.py
Normal file
36
apitokens/models.py
Normal 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]
|
30
apitokens/templates/apitokens/apitoken_confirm_delete.html
Normal file
30
apitokens/templates/apitokens/apitoken_confirm_delete.html
Normal 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 %}
|
37
apitokens/templates/apitokens/apitoken_create.html
Normal file
37
apitokens/templates/apitokens/apitoken_create.html
Normal 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 %}
|
62
apitokens/templates/apitokens/usertoken_list.html
Normal file
62
apitokens/templates/apitokens/usertoken_list.html
Normal 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 %}
|
0
apitokens/tests/__init__.py
Normal file
0
apitokens/tests/__init__.py
Normal file
74
apitokens/tests/test_user_token.py
Normal file
74
apitokens/tests/test_user_token.py
Normal 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
11
apitokens/urls.py
Normal 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
78
apitokens/views.py
Normal 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
|
@ -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 = {
|
||||
|
@ -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'),
|
||||
|
@ -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) {
|
||||
@ -128,9 +133,28 @@
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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 +162,7 @@
|
||||
btnBack();
|
||||
commentForm();
|
||||
copyInstallUrl();
|
||||
navGlobalLinkSearch();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
@ -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") {
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -1,10 +1,47 @@
|
||||
.dropdown-toggle
|
||||
height: calc(var(--spacer) * 2)
|
||||
.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)
|
||||
|
||||
+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)
|
||||
|
@ -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
|
||||
|
@ -52,6 +52,9 @@
|
||||
pre
|
||||
+margin(3, bottom)
|
||||
|
||||
p:last-child
|
||||
margin-bottom: 0
|
||||
|
||||
.text-accent
|
||||
color: var(--color-accent)
|
||||
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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')),
|
||||
|
@ -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
|
||||
|
@ -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)),
|
||||
|
@ -16,6 +16,7 @@ from constants.base import (
|
||||
EXTENSION_STATUS_CHOICES,
|
||||
EXTENSION_TYPE_CHOICES,
|
||||
EXTENSION_TYPE_SLUGS,
|
||||
EXTENSION_TYPE_SLUGS_SINGULAR,
|
||||
FILE_STATUS_CHOICES,
|
||||
)
|
||||
import common.help_texts
|
||||
@ -128,12 +129,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()
|
||||
|
||||
|
||||
@ -183,7 +191,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,6 +220,10 @@ 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])
|
||||
@ -336,23 +348,6 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
|
||||
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 +374,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.
|
||||
@ -631,12 +630,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,
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -104,7 +104,7 @@ def _set_is_listed(
|
||||
return
|
||||
|
||||
if extension.status == extensions.models.Extension.STATUSES.APPROVED and not new_is_listed:
|
||||
extension.status = extensions.models.Extension.STATUSES.INCOMPLETE
|
||||
extension.status = extensions.models.Extension.STATUSES.DRAFT
|
||||
|
||||
logger.info('Extension pk=%s becomes listed', extension.pk)
|
||||
extension.is_listed = new_is_listed
|
||||
|
@ -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">
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
@ -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>
|
@ -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 %}
|
||||
|
@ -13,100 +13,67 @@
|
||||
<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 %}
|
||||
|
||||
{% 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">
|
||||
<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">
|
||||
{% comment %}
|
||||
<div class="align-items-center bg-tertiary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
|
||||
1
|
||||
</div>
|
||||
{% endcomment %}
|
||||
{% 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">
|
||||
{% comment %}
|
||||
<div class="align-items-center bg-tertiary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
|
||||
1
|
||||
</div>
|
||||
{% endcomment %}
|
||||
{% 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">
|
||||
{% comment %}
|
||||
<div class="align-items-center bg-tertiary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
|
||||
1
|
||||
</div>
|
||||
{% endcomment %}
|
||||
</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">
|
||||
{% comment %}
|
||||
<div class="align-items-center bg-tertiary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
|
||||
1
|
||||
</div>
|
||||
{% endcomment %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="box dropdown p-2 rounded-2">
|
||||
<button class="dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="js-dropdown-menu-sort">
|
||||
Sort by <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-sort">
|
||||
<li>
|
||||
<a class="dropdown-item" href="#">
|
||||
Newest First
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#">
|
||||
Recently Updated
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#">
|
||||
Most Reviewed
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#">
|
||||
Rating
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#">
|
||||
Downloads
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#">
|
||||
Title (A-Z)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#">
|
||||
Title (Z-A)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
118
extensions/tests/test_api.py
Normal file
118
extensions/tests/test_api.py
Normal 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)
|
@ -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)
|
||||
|
@ -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()
|
||||
@ -151,7 +151,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(
|
||||
|
@ -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={
|
||||
|
@ -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())
|
||||
|
@ -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 = {
|
||||
@ -492,10 +495,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')
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
),
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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 = (
|
||||
@ -50,6 +40,7 @@ class HomeView(ListedExtensionsView):
|
||||
'preview_set',
|
||||
'preview_set__file',
|
||||
'ratings',
|
||||
'team',
|
||||
'versions',
|
||||
'versions__file',
|
||||
'versions__tags',
|
||||
@ -60,12 +51,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):
|
||||
@ -107,6 +103,7 @@ class SearchView(ListedExtensionsView):
|
||||
'preview_set',
|
||||
'preview_set__file',
|
||||
'ratings',
|
||||
'team',
|
||||
'versions',
|
||||
'versions__file',
|
||||
'versions__tags',
|
||||
|
@ -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()
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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 }}">
|
||||
|
@ -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('\\/*$', '/') }};
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
16
reviewers/migrations/0010_delete_reviewersubscription.py
Normal file
16
reviewers/migrations/0010_delete_reviewersubscription.py
Normal 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',
|
||||
),
|
||||
]
|
@ -1,22 +1,15 @@
|
||||
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 +28,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")
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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
|
||||
|
49
teams/templates/teams/confirm_leave.html
Normal file
49
teams/templates/teams/confirm_leave.html
Normal 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 %}
|
@ -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
52
teams/tests/test_leave.py
Normal 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)
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user