Extensions list: sort_by parameter #159
@ -47,7 +47,7 @@
|
|||||||
<td title="{{ report.date_created }}">{{ report.date_created|naturaltime_compact }}</td>
|
<td title="{{ report.date_created }}">{{ report.date_created|naturaltime_compact }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ report.get_absolute_url }}" class="text-decoration-none">
|
<a href="{{ report.get_absolute_url }}" class="text-decoration-none">
|
||||||
{% include "common/components/status.html" with object=report class="d-block" %}
|
{% include "common/components/status.html" with object=report classes="d-block" %}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
1
apitokens/__init__.py
Normal file
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',
|
'rangefilter',
|
||||||
'reviewers',
|
'reviewers',
|
||||||
'stats',
|
'stats',
|
||||||
|
'apitokens',
|
||||||
'taggit',
|
'taggit',
|
||||||
'drf_spectacular',
|
'drf_spectacular',
|
||||||
'drf_spectacular_sidecar',
|
'drf_spectacular_sidecar',
|
||||||
@ -273,6 +274,8 @@ ACTSTREAM_SETTINGS = {
|
|||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': ('apitokens.authentication.UserTokenAuthentication',),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',),
|
||||||
}
|
}
|
||||||
|
|
||||||
SPECTACULAR_SETTINGS = {
|
SPECTACULAR_SETTINGS = {
|
||||||
|
@ -40,6 +40,7 @@ urlpatterns = [
|
|||||||
path('', include('teams.urls')),
|
path('', include('teams.urls')),
|
||||||
path('', include('reviewers.urls')),
|
path('', include('reviewers.urls')),
|
||||||
path('', include('notifications.urls')),
|
path('', include('notifications.urls')),
|
||||||
|
path('', include('apitokens.urls')),
|
||||||
path('api/swagger/', RedirectView.as_view(url='/api/v1/swagger/')),
|
path('api/swagger/', RedirectView.as_view(url='/api/v1/swagger/')),
|
||||||
path('api/v1/', SpectacularAPIView.as_view(), name='schema_v1'),
|
path('api/v1/', SpectacularAPIView.as_view(), name='schema_v1'),
|
||||||
path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'),
|
path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'),
|
||||||
|
@ -33,6 +33,11 @@
|
|||||||
function submitFormFileInputClear() {
|
function submitFormFileInputClear() {
|
||||||
const submitFormFileInput = document.querySelector('.js-submit-form-file-input');
|
const submitFormFileInput = document.querySelector('.js-submit-form-file-input');
|
||||||
|
|
||||||
|
if (!submitFormFileInput) {
|
||||||
|
// Stop function execution if submitFormFileInput is not present
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
submitFormFileInput.addEventListener('change', function(e) {
|
submitFormFileInput.addEventListener('change', function(e) {
|
||||||
e.target.classList.remove('is-invalid');
|
e.target.classList.remove('is-invalid');
|
||||||
});
|
});
|
||||||
@ -51,7 +56,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create finction commentForm
|
// Create function commentForm
|
||||||
function commentForm() {
|
function commentForm() {
|
||||||
const commentForm = document.querySelector('.js-comment-form');
|
const commentForm = document.querySelector('.js-comment-form');
|
||||||
if (!commentForm) {
|
if (!commentForm) {
|
||||||
@ -131,6 +136,24 @@
|
|||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create function navGlobalLinkSearch
|
||||||
|
function navGlobalLinkSearch() {
|
||||||
|
const navGlobalLinkSearch = document.querySelector('.js-nav-global-link-search');
|
||||||
|
const navGlobalLinkSearchToggle = document.querySelector('.js-nav-global-link-search-toggle');
|
||||||
|
|
||||||
|
// Toggle navbar search on small screens
|
||||||
|
navGlobalLinkSearchToggle.addEventListener('click', function() {
|
||||||
|
this.classList.toggle('is-active');
|
||||||
|
|
||||||
|
if (this.classList.contains('is-active')) {
|
||||||
|
// Show navGlobalLinkSearch
|
||||||
|
navGlobalLinkSearch.classList.add('is-active');
|
||||||
|
} else {
|
||||||
|
navGlobalLinkSearch.classList.remove('is-active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create function init
|
// Create function init
|
||||||
function init() {
|
function init() {
|
||||||
agreeWithTerms();
|
agreeWithTerms();
|
||||||
@ -138,6 +161,7 @@
|
|||||||
btnBack();
|
btnBack();
|
||||||
commentForm();
|
commentForm();
|
||||||
copyInstallUrl();
|
copyInstallUrl();
|
||||||
|
navGlobalLinkSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
@ -12,6 +12,7 @@ function galleriaSetLargePreview(item) {
|
|||||||
const galleriaContentType = item.dataset.galleriaContentType;
|
const galleriaContentType = item.dataset.galleriaContentType;
|
||||||
const galleriaVideoUrl = item.dataset.galleriaVideoUrl;
|
const galleriaVideoUrl = item.dataset.galleriaVideoUrl;
|
||||||
|
|
||||||
|
previewLarge.href = item.href;
|
||||||
previewLarge.classList = item.classList;
|
previewLarge.classList = item.classList;
|
||||||
previewLarge.firstElementChild.src = item.href;
|
previewLarge.firstElementChild.src = item.href;
|
||||||
previewLarge.firstElementChild.alt = galleryItem.alt;
|
previewLarge.firstElementChild.alt = galleryItem.alt;
|
||||||
@ -74,9 +75,10 @@ function galleriaScrollNavigation() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Create the overlay that will host the image and navigation controls. */
|
/* Create the overlay that will host the media and navigation controls. */
|
||||||
function galleriaCreateOverlay() {
|
function galleriaCreateOverlay() {
|
||||||
let overlay = document.createElement("div");
|
let overlay = document.createElement("div");
|
||||||
|
overlay.id = 'galleria';
|
||||||
overlay.classList.add("galleria");
|
overlay.classList.add("galleria");
|
||||||
document.body.classList.add('is-galleria-active');
|
document.body.classList.add('is-galleria-active');
|
||||||
|
|
||||||
@ -84,7 +86,9 @@ function galleriaCreateOverlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Close and delete the overlay. */
|
/* Close and delete the overlay. */
|
||||||
function galleriaCloseOverlay(overlay) {
|
function galleriaCloseOverlay() {
|
||||||
|
let overlay = document.getElementById('galleria');
|
||||||
|
|
||||||
if (overlay.parentNode === document.body) {
|
if (overlay.parentNode === document.body) {
|
||||||
document.body.removeChild(overlay);
|
document.body.removeChild(overlay);
|
||||||
document.body.classList.remove('is-galleria-active');
|
document.body.classList.remove('is-galleria-active');
|
||||||
@ -94,7 +98,7 @@ function galleriaCloseOverlay(overlay) {
|
|||||||
/* Create the backdrop behind the overlay. */
|
/* Create the backdrop behind the overlay. */
|
||||||
function galleriaCreateUnderlay() {
|
function galleriaCreateUnderlay() {
|
||||||
let underlay = document.createElement("div");
|
let underlay = document.createElement("div");
|
||||||
underlay.classList.add("underlay");
|
underlay.classList.add("underlay", "zoom-out");
|
||||||
|
|
||||||
return underlay;
|
return underlay;
|
||||||
}
|
}
|
||||||
@ -107,7 +111,7 @@ function galleriaCreateLoadingPlaceholder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Create Large Image element. */
|
/* Create expanded image element inside overlay. */
|
||||||
function galleriaCreateMediaImage(galleriaItem, overlay, loadingPlaceholder) {
|
function galleriaCreateMediaImage(galleriaItem, overlay, loadingPlaceholder) {
|
||||||
let galleriaNewItem = new Image();
|
let galleriaNewItem = new Image();
|
||||||
galleriaNewItem.id = 'galleria-active-item';
|
galleriaNewItem.id = 'galleria-active-item';
|
||||||
@ -121,6 +125,12 @@ function galleriaCreateMediaImage(galleriaItem, overlay, loadingPlaceholder) {
|
|||||||
galleriaNewItem.src = galleriaItem.href;
|
galleriaNewItem.src = galleriaItem.href;
|
||||||
galleriaNewItem.alt = galleriaItem.firstElementChild.alt;
|
galleriaNewItem.alt = galleriaItem.firstElementChild.alt;
|
||||||
|
|
||||||
|
/* Click on image to close the overlay. */
|
||||||
|
galleriaNewItem.classList.add('zoom-out');
|
||||||
|
galleriaNewItem.addEventListener("click", function () {
|
||||||
|
galleriaCloseOverlay();
|
||||||
|
});
|
||||||
|
|
||||||
galleriaCreateCaption(galleriaNewItem.alt, overlay);
|
galleriaCreateCaption(galleriaNewItem.alt, overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,7 +156,7 @@ function galleriaCreateMediaVideo(galleriaItem, overlay, loadingPlaceholder) {
|
|||||||
galleriaCreateCaption(galleriaItem.firstElementChild.alt, overlay);
|
galleriaCreateCaption(galleriaItem.firstElementChild.alt, overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Create expanded media element inside overlay. */
|
||||||
function galleriaCreateMedia(galleriaItem, galleriaContentType, overlay) {
|
function galleriaCreateMedia(galleriaItem, galleriaContentType, overlay) {
|
||||||
const activeItem = overlay.querySelector('#galleria-active-item');
|
const activeItem = overlay.querySelector('#galleria-active-item');
|
||||||
const loadingPlaceholder = galleriaCreateLoadingPlaceholder();
|
const loadingPlaceholder = galleriaCreateLoadingPlaceholder();
|
||||||
@ -228,7 +238,7 @@ function galleriaCreateNavigationDiv(siblings, currentIndex, overlay) {
|
|||||||
navigationDiv.appendChild(closeButton);
|
navigationDiv.appendChild(closeButton);
|
||||||
|
|
||||||
closeButton.addEventListener("click", function () {
|
closeButton.addEventListener("click", function () {
|
||||||
galleriaCloseOverlay(overlay);
|
galleriaCloseOverlay();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (siblings.length > 1) {
|
if (siblings.length > 1) {
|
||||||
@ -298,7 +308,7 @@ function galleriaCreate() {
|
|||||||
// Keyboard event listeners
|
// Keyboard event listeners
|
||||||
document.addEventListener("keydown", function (event) {
|
document.addEventListener("keydown", function (event) {
|
||||||
if (overlay && event.key === "Escape") {
|
if (overlay && event.key === "Escape") {
|
||||||
galleriaCloseOverlay(overlay);
|
galleriaCloseOverlay();
|
||||||
} else if (overlay && event.key === "ArrowRight") {
|
} else if (overlay && event.key === "ArrowRight") {
|
||||||
currentIndex = galleriaNavigateNext(siblings, currentIndex, overlay);
|
currentIndex = galleriaNavigateNext(siblings, currentIndex, overlay);
|
||||||
} else if (overlay && event.key === "ArrowLeft") {
|
} else if (overlay && event.key === "ArrowLeft") {
|
||||||
|
@ -35,28 +35,38 @@ a.badge-tag
|
|||||||
font-size: var(--fs-xs)
|
font-size: var(--fs-xs)
|
||||||
|
|
||||||
.badge-status
|
.badge-status
|
||||||
&-approved
|
&-approved,
|
||||||
|
&-resolved
|
||||||
@extend .badge-success
|
@extend .badge-success
|
||||||
&-awaiting-review
|
&-awaiting-review
|
||||||
@extend .badge-info
|
@extend .badge-info
|
||||||
&-incomplete,
|
|
||||||
&-awaiting-changes,
|
&-awaiting-changes,
|
||||||
|
&-draft,
|
||||||
|
&-untriaged,
|
||||||
@extend .badge-warning
|
@extend .badge-warning
|
||||||
&-disabled-by-staff,
|
&-disabled-by-staff,
|
||||||
&-disabled-by-author
|
&-disabled-by-author
|
||||||
@extend .badge-secondary
|
@extend .badge-secondary
|
||||||
|
&-confirmed,
|
||||||
|
&-deleted
|
||||||
|
@extend .badge-danger
|
||||||
|
|
||||||
.badge-outline
|
.badge-outline
|
||||||
background-color: transparent
|
background-color: transparent
|
||||||
|
|
||||||
&.badge-status
|
&.badge-status
|
||||||
&-approved
|
&-approved,
|
||||||
|
&-resolved
|
||||||
color: var(--color-success)
|
color: var(--color-success)
|
||||||
&-awaiting-review
|
&-awaiting-review
|
||||||
color: var(--color-info)
|
color: var(--color-info)
|
||||||
&-incomplete,
|
|
||||||
&-awaiting-changes,
|
&-awaiting-changes,
|
||||||
|
&-draft,
|
||||||
|
&-untriaged,
|
||||||
color: var(--color-warning)
|
color: var(--color-warning)
|
||||||
&-disabled-by-staff,
|
&-disabled-by-staff,
|
||||||
&-disabled-by-author
|
&-disabled-by-author
|
||||||
color: var(--color-secondary)
|
color: var(--color-secondary)
|
||||||
|
&-confirmed,
|
||||||
|
&-deleted
|
||||||
|
color: var(--color-danger)
|
||||||
|
@ -3,8 +3,8 @@
|
|||||||
|
|
||||||
.hero.extension-detail
|
.hero.extension-detail
|
||||||
--hero-max-height: 0
|
--hero-max-height: 0
|
||||||
--hero-min-height: 24.0rem
|
--hero-min-height: 28.0rem
|
||||||
--fs-hero-title: clamp(4.8rem, 4vw + 1.6rem, 4.8rem)
|
--fs-hero-title: var(--fs-h1)
|
||||||
--fs-lg: 1.8rem
|
--fs-lg: 1.8rem
|
||||||
--border-width: .2rem
|
--border-width: .2rem
|
||||||
--hero-bg-color: hsl(213, 10%, 14%)
|
--hero-bg-color: hsl(213, 10%, 14%)
|
||||||
@ -16,6 +16,10 @@
|
|||||||
overflow: initial
|
overflow: initial
|
||||||
text-shadow: none
|
text-shadow: none
|
||||||
|
|
||||||
|
+media-md
|
||||||
|
--fs-hero-title: clamp(4.8rem, 4vw + 1.6rem, 4.8rem)
|
||||||
|
--hero-min-height: 24.0rem
|
||||||
|
|
||||||
h1
|
h1
|
||||||
margin-left: calc(var(--spacer-3))
|
margin-left: calc(var(--spacer-3))
|
||||||
|
|
||||||
@ -23,13 +27,17 @@
|
|||||||
margin: auto 0
|
margin: auto 0
|
||||||
|
|
||||||
.hero-subtitle
|
.hero-subtitle
|
||||||
margin-left: calc(var(--spacer-4) + var(--spacer-1) + var(--fs-hero-title))
|
|
||||||
max-width: none
|
max-width: none
|
||||||
|
|
||||||
|
+media-sm
|
||||||
|
margin-left: calc(var(--spacer-4) + var(--spacer-1) + var(--fs-hero-title))
|
||||||
|
|
||||||
.badge
|
.badge
|
||||||
+margin(2, left)
|
|
||||||
pointer-events: none
|
pointer-events: none
|
||||||
|
|
||||||
|
+media-sm
|
||||||
|
+margin(2, left)
|
||||||
|
|
||||||
.hero-overlay
|
.hero-overlay
|
||||||
background-color: transparent
|
background-color: transparent
|
||||||
background-image: linear-gradient(0deg, hsl(213, 10%, 12%), hsla(213, 10%, 14%, 0)) // --color-bg theme dark
|
background-image: linear-gradient(0deg, hsl(213, 10%, 12%), hsla(213, 10%, 14%, 0)) // --color-bg theme dark
|
||||||
@ -223,8 +231,12 @@
|
|||||||
border-radius: var(--border-radius-lg)
|
border-radius: var(--border-radius-lg)
|
||||||
border: var(--border-width) solid var(--border-color)
|
border: var(--border-width) solid var(--border-color)
|
||||||
display: flex
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
+padding(2, y)
|
+padding(2, y)
|
||||||
|
|
||||||
|
+media-md
|
||||||
|
flex-direction: row
|
||||||
|
|
||||||
.previews-list-item-thumbnail
|
.previews-list-item-thumbnail
|
||||||
margin: 0
|
margin: 0
|
||||||
+margin(2, y)
|
+margin(2, y)
|
||||||
@ -244,6 +256,7 @@
|
|||||||
.details
|
.details
|
||||||
+padding(3, x)
|
+padding(3, x)
|
||||||
flex: 1
|
flex: 1
|
||||||
|
width: 100%
|
||||||
|
|
||||||
label
|
label
|
||||||
font-size: var(--fs-sm)
|
font-size: var(--fs-sm)
|
||||||
@ -366,9 +379,6 @@
|
|||||||
.badge
|
.badge
|
||||||
text-decoration: none !important
|
text-decoration: none !important
|
||||||
|
|
||||||
.ext-review-list-name
|
|
||||||
display: flex
|
|
||||||
|
|
||||||
.extension-icon
|
.extension-icon
|
||||||
+margin(2, right)
|
+margin(2, right)
|
||||||
|
|
||||||
@ -404,6 +414,7 @@
|
|||||||
|
|
||||||
&.active
|
&.active
|
||||||
background-color: var(--color-accent-bg)
|
background-color: var(--color-accent-bg)
|
||||||
|
+fw-normal
|
||||||
|
|
||||||
&:last-child
|
&:last-child
|
||||||
+margin(0, bottom)
|
+margin(0, bottom)
|
||||||
@ -412,23 +423,32 @@
|
|||||||
@extend .dropdown-divider
|
@extend .dropdown-divider
|
||||||
|
|
||||||
+margin(0, top)
|
+margin(0, top)
|
||||||
|
+margin(1, bottom)
|
||||||
|
|
||||||
.dropdown-item
|
a
|
||||||
&a
|
&.dropdown-item
|
||||||
+padding(3, x)
|
+padding(3, x)
|
||||||
|
|
||||||
.extension-icon
|
a
|
||||||
display: inline-block
|
&.dropdown-item-disabled
|
||||||
vertical-align: bottom
|
opacity: .5
|
||||||
width: var(--fs-lg)
|
pointer-events: none
|
||||||
|
|
||||||
|
.extension-icon
|
||||||
img
|
img
|
||||||
border-radius: calc(var(--border-radius) / 2)
|
border-radius: calc(var(--border-radius) / 2)
|
||||||
max-width: 100%
|
width: var(--spacer-4)
|
||||||
|
|
||||||
&.icon-lg
|
&.icon-lg
|
||||||
|
transform: translateY(-.2rem)
|
||||||
width: var(--fs-hero-title)
|
width: var(--fs-hero-title)
|
||||||
|
|
||||||
|
img
|
||||||
|
width: var(--fs-hero-title)
|
||||||
|
|
||||||
|
+media-md
|
||||||
|
transform: translateY(calc(var(--spacer-1) * -1))
|
||||||
|
|
||||||
.icon-preview, .featured-image-preview
|
.icon-preview, .featured-image-preview
|
||||||
height: 9rem
|
height: 9rem
|
||||||
background-size: contain
|
background-size: contain
|
||||||
|
@ -15,11 +15,14 @@
|
|||||||
@extend .i-mic
|
@extend .i-mic
|
||||||
|
|
||||||
/* Aliases for review activity types. */
|
/* Aliases for review activity types. */
|
||||||
.i-activity-approved
|
.i-activity-approved,
|
||||||
|
.i-status-approved
|
||||||
@extend .i-check
|
@extend .i-check
|
||||||
|
|
||||||
.i-activity-awaiting-review
|
.i-activity-awaiting-review,
|
||||||
|
.i-status-awaiting-review
|
||||||
@extend .i-eye
|
@extend .i-eye
|
||||||
|
|
||||||
.i-activity-awaiting-changes
|
.i-activity-awaiting-changes,
|
||||||
@extend .i-edit
|
.i-status-awaiting-changes
|
||||||
|
@extend .i-clock
|
||||||
|
@ -102,6 +102,12 @@
|
|||||||
|
|
||||||
/* Lightbox component. */
|
/* Lightbox component. */
|
||||||
.galleria
|
.galleria
|
||||||
|
--galleria-btn-width: 100px
|
||||||
|
--galleria-media-max-width: 100%
|
||||||
|
|
||||||
|
+media-lg
|
||||||
|
--galleria-media-max-width: calc(100% - calc(var(--galleria-btn-width) * 1.5))
|
||||||
|
|
||||||
align-items: center
|
align-items: center
|
||||||
display: flex
|
display: flex
|
||||||
inset: 0 0 0 0
|
inset: 0 0 0 0
|
||||||
@ -110,19 +116,21 @@
|
|||||||
position: fixed
|
position: fixed
|
||||||
z-index: var(--z-index-galleria)
|
z-index: var(--z-index-galleria)
|
||||||
|
|
||||||
img
|
img, video
|
||||||
max-height: 100%
|
max-height: 100%
|
||||||
max-width: 100%
|
max-width: var(--galleria-media-max-width)
|
||||||
object-fit: contain
|
object-fit: contain
|
||||||
|
|
||||||
/* Previous/Next buttons.*/
|
/* Previous/Next buttons.*/
|
||||||
.btn
|
.btn
|
||||||
background: transparent
|
background: transparent
|
||||||
border: none
|
border: none
|
||||||
|
color: white
|
||||||
cursor: pointer
|
cursor: pointer
|
||||||
font-size: 5.6rem
|
font-size: 5.6rem
|
||||||
height: 100vh
|
height: 100vh
|
||||||
max-width: 200px
|
max-height: 300px
|
||||||
|
max-width: var(--galleria-btn-width)
|
||||||
opacity: .6
|
opacity: .6
|
||||||
outline: 0
|
outline: 0
|
||||||
position: absolute
|
position: absolute
|
||||||
@ -137,12 +145,7 @@
|
|||||||
color: white
|
color: white
|
||||||
opacity: 1
|
opacity: 1
|
||||||
|
|
||||||
svg
|
|
||||||
opacity: 0
|
|
||||||
transition: all var(--transition-speed) var(--transition-ease-bezier)
|
|
||||||
|
|
||||||
&.btn-close
|
&.btn-close
|
||||||
fill: white
|
|
||||||
font-size: 3.2rem
|
font-size: 3.2rem
|
||||||
height: 20vh
|
height: 20vh
|
||||||
max-height: 80px
|
max-height: 80px
|
||||||
@ -166,12 +169,14 @@
|
|||||||
|
|
||||||
.underlay
|
.underlay
|
||||||
background-color: rgba(0,0,0,0.9)
|
background-color: rgba(0,0,0,0.9)
|
||||||
cursor: zoom-out
|
|
||||||
inset: 0 0 0 0
|
inset: 0 0 0 0
|
||||||
overflow: hidden
|
overflow: hidden
|
||||||
position: fixed
|
position: fixed
|
||||||
z-index: -1
|
z-index: -1
|
||||||
|
|
||||||
|
.zoom-out
|
||||||
|
cursor: zoom-out
|
||||||
|
|
||||||
.indicator
|
.indicator
|
||||||
background-color: rgba(black, .5)
|
background-color: rgba(black, .5)
|
||||||
bottom: var(--spacer)
|
bottom: var(--spacer)
|
||||||
|
@ -16,7 +16,13 @@
|
|||||||
align-items: center
|
align-items: center
|
||||||
border-bottom: thin solid rgba(white, .1)
|
border-bottom: thin solid rgba(white, .1)
|
||||||
display: flex
|
display: flex
|
||||||
|
flex-grow: 1
|
||||||
margin-top: auto
|
margin-top: auto
|
||||||
|
overflow-x: auto
|
||||||
|
overflow-y: hidden
|
||||||
|
|
||||||
|
a
|
||||||
|
white-space: nowrap
|
||||||
|
|
||||||
.dropdown-menu
|
.dropdown-menu
|
||||||
font-size: initial
|
font-size: initial
|
||||||
@ -55,3 +61,9 @@
|
|||||||
|
|
||||||
&::after
|
&::after
|
||||||
opacity: 1
|
opacity: 1
|
||||||
|
|
||||||
|
.hero-tabs-admin
|
||||||
|
justify-content: end
|
||||||
|
|
||||||
|
+media-sm
|
||||||
|
border-bottom: thin solid rgba(white, .1)
|
||||||
|
@ -1,10 +1,51 @@
|
|||||||
.dropdown-toggle
|
.dropdown-menu-filter
|
||||||
height: calc(var(--spacer) * 2)
|
.dropdown-item
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
.dropdown-filter-sort
|
||||||
|
@extend .box
|
||||||
|
|
||||||
|
align-items: center
|
||||||
|
border-radius: var(--spacer-2)
|
||||||
|
display: flex
|
||||||
|
+padding(2)
|
||||||
|
|
||||||
|
.dropdown-item,
|
||||||
|
.dropdown-toggle
|
||||||
|
white-space: nowrap
|
||||||
|
|
||||||
|
+media-md
|
||||||
|
.dropdown-menu-filter
|
||||||
|
gap: var(--spacer-1)
|
||||||
|
width: 56.0rem
|
||||||
|
|
||||||
|
li
|
||||||
|
background-color: var(--color-bg-secondary)
|
||||||
|
border-radius: var(--border-radius)
|
||||||
|
+margin(0, bottom)
|
||||||
|
|
||||||
|
&.is-visible
|
||||||
|
display: grid
|
||||||
|
grid-template-columns: repeat(3, 1fr)
|
||||||
|
|
||||||
.dropdown-menu-filter-sort
|
.dropdown-menu-filter-sort
|
||||||
max-height: calc(var(--spacer) * 28)
|
max-height: calc(var(--spacer) * 24.25)
|
||||||
overflow: auto
|
overflow: auto
|
||||||
|
|
||||||
|
.dropdown-item
|
||||||
|
line-height: var(--lh-base)
|
||||||
|
justify-content: space-between
|
||||||
|
|
||||||
|
&.is-active
|
||||||
|
background-color: var(--color-accent-bg)
|
||||||
|
color: var(--color-accent)
|
||||||
|
|
||||||
.navbar-search
|
.navbar-search
|
||||||
input
|
input
|
||||||
|
color: var(--bwa-color-text)
|
||||||
min-width: calc(var(--spacer) * 4)
|
min-width: calc(var(--spacer) * 4)
|
||||||
|
|
||||||
|
&:active,
|
||||||
|
&:focus,
|
||||||
|
&:hover
|
||||||
|
color: var(--bwa-color-text)
|
||||||
|
@ -1,12 +1,42 @@
|
|||||||
|
// TODO: refactor style partial
|
||||||
.nav-global .nav-global-nav-links
|
.nav-global .nav-global-nav-links
|
||||||
+padding(2, right)
|
+padding(2, right)
|
||||||
|
|
||||||
|
.nav-global .nav-global-link-search-toggle
|
||||||
|
display: none
|
||||||
|
|
||||||
|
// Media query comes from partial navigation_global, where it is explicitly set
|
||||||
@media (max-width: 767px)
|
@media (max-width: 767px)
|
||||||
.nav-global .nav-global-nav-links li a:hover,
|
.nav-global .nav-global-nav-links li a:hover,
|
||||||
.nav-global .nav-global-nav-links li a.nav-global-link-active
|
.nav-global .nav-global-nav-links li a.nav-global-link-active
|
||||||
background-color: var(--bwa-color-accent-bg) !important
|
background-color: var(--bwa-color-accent-bg) !important
|
||||||
color: var(--bwa-color-accent) !important
|
color: var(--bwa-color-accent) !important
|
||||||
|
|
||||||
|
.nav-global .nav-global-link-search-toggle
|
||||||
|
display: flex
|
||||||
|
|
||||||
|
&.is-active
|
||||||
|
background-color: var(--bwa-btn-color-bg-hover)
|
||||||
|
color: var(--bwa-color-text-primary)
|
||||||
|
|
||||||
|
.nav-global-link-search
|
||||||
|
display: none !important
|
||||||
|
|
||||||
|
&.is-active
|
||||||
|
background-color: var(--bwa-color-bg-tertiary)
|
||||||
|
display: inline-flex !important
|
||||||
|
left: 0
|
||||||
|
position: absolute
|
||||||
|
top: calc(var(--spacer) * 4)
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
search
|
||||||
|
+padding(3, x)
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
.navbar-search
|
||||||
|
max-width: none
|
||||||
|
|
||||||
+media-xl
|
+media-xl
|
||||||
.nav-global .nav-global-container
|
.nav-global .nav-global-container
|
||||||
max-width: 1320px
|
max-width: 1320px
|
||||||
|
@ -52,6 +52,9 @@
|
|||||||
pre
|
pre
|
||||||
+margin(3, bottom)
|
+margin(3, bottom)
|
||||||
|
|
||||||
|
p:last-child
|
||||||
|
margin-bottom: 0
|
||||||
|
|
||||||
.text-accent
|
.text-accent
|
||||||
color: var(--color-accent)
|
color: var(--color-accent)
|
||||||
|
|
||||||
|
@ -107,10 +107,10 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<ul class="nav-global-links-right">
|
<ul class="nav-global-links-right">
|
||||||
<li class="d-lg-inline-flex d-none">
|
<li class="js-nav-global-link-search nav-global-link-search">
|
||||||
<search>
|
<search>
|
||||||
<form action="{% url "extensions:search" %}" class="navbar-search" method="GET">
|
<form action="{% url 'extensions:search' %}" class="navbar-search" method="GET">
|
||||||
<input aria-label="Search" aria-describedby="nav-search-button" class="form-control" type="text" placeholder="Search..." {% if request.GET.q %} value="{{ request.GET.q }}" {% else %} {% endif %}>
|
<input name="q" aria-label="Search" aria-describedby="nav-search-button" class="form-control" type="text" placeholder="Search..." {% if request.GET.q %} value="{{ request.GET.q }}" {% else %} {% endif %}>
|
||||||
<button id="nav-search-button" type="submit">
|
<button id="nav-search-button" type="submit">
|
||||||
<i class="i-search"></i>
|
<i class="i-search"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -118,6 +118,10 @@
|
|||||||
</search>
|
</search>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<button class="js-nav-global-link-search-toggle nav-global-link-search-toggle"><i class="i-search"></i></button>
|
||||||
|
</li>
|
||||||
|
|
||||||
{% block nav-upload %}
|
{% block nav-upload %}
|
||||||
<li class="d-lg-inline-flex d-none">
|
<li class="d-lg-inline-flex d-none">
|
||||||
<a class="nav-global-btn nav-global-btn-primary" href="{% url 'extensions:submit' %}"><i class="i-upload"></i><span>Upload Extension</span></a>
|
<a class="nav-global-btn nav-global-btn-primary" href="{% url 'extensions:submit' %}"><i class="i-upload"></i><span>Upload Extension</span></a>
|
||||||
@ -125,7 +129,7 @@
|
|||||||
{% endblock nav-upload %}
|
{% endblock nav-upload %}
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<button class="js-toggle-theme-btn px-2"><i class="js-toggle-theme-btn-icon i-adjust"></i></button>
|
<button class="js-toggle-theme-btn"><i class="js-toggle-theme-btn-icon i-adjust"></i></button>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
@ -140,9 +144,9 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="dropdown">
|
<li class="dropdown">
|
||||||
<button id="navbarDropdown" aria-expanded="false" aria-haspopup="true" data-toggle-menu-id="nav-account-dropdown" role="button" class="nav-link dropdown-toggle js-dropdown-toggle pe-3 px-2">
|
<button id="navbarDropdown" aria-expanded="false" aria-haspopup="true" data-toggle-menu-id="nav-account-dropdown" role="button" class="nav-link dropdown-toggle js-dropdown-toggle">
|
||||||
<i class="i-user"></i>
|
<i class="i-user"></i>
|
||||||
<i class="i-chevron-down"></i>
|
<i class="d-none d-md-inline i-chevron-down"></i>
|
||||||
</button>
|
</button>
|
||||||
<ul id="nav-account-dropdown" aria-labelledby="navbarDropdown" class="dropdown-menu dropdown-menu-right js-dropdown-menu">
|
<ul id="nav-account-dropdown" aria-labelledby="navbarDropdown" class="dropdown-menu dropdown-menu-right js-dropdown-menu">
|
||||||
{% if user.is_staff %}
|
{% if user.is_staff %}
|
||||||
@ -209,10 +213,12 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{% elif page_id != 'login' and page_id != 'register' %}
|
{% elif page_id != 'login' and page_id != 'register' %}
|
||||||
<a href="{% url 'oauth:login' %}" class="btn btn-link">
|
<li>
|
||||||
<i class="i-log-in"></i>
|
<a class="nav-global-btn" href="{% url 'oauth:login' %}">
|
||||||
<span>{% trans "Sign in" %}</span>
|
<i class="i-log-in"></i>
|
||||||
</a>
|
<span>{% trans "Sign in" %}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
@ -258,6 +264,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block footer %}
|
{% block footer %}
|
||||||
|
<div class="footer-pages pt-2">
|
||||||
|
<div class="container">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/about">About</a></li>
|
||||||
|
<li><a href="https://www.blender.org/privacy-policy">Privacy Policy</a></li>
|
||||||
|
<li><a href="/terms-of-service">Terms of Service</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% include "_footer.html" %}
|
{% include "_footer.html" %}
|
||||||
{% endblock footer %}
|
{% endblock footer %}
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
{% if not field.is_hidden %}
|
{% if not field.is_hidden %}
|
||||||
<label for="{{ field.id_for_label }}" class="form-check-label">
|
<label for="{{ field.id_for_label }}" class="form-check-label">
|
||||||
{{ label|safe }}
|
{{ label|safe }}
|
||||||
{% if field.field.required %}<span class="form-required-indicator">*</span>{% endif %}
|
{% if field.field.required or required %}<span class="form-required-indicator">*</span>{% endif %}
|
||||||
</label>
|
</label>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -24,7 +24,7 @@
|
|||||||
{% if not field.is_hidden %}
|
{% if not field.is_hidden %}
|
||||||
<label for="{{ field.id_for_label }}">
|
<label for="{{ field.id_for_label }}">
|
||||||
{{ label|safe }}
|
{{ label|safe }}
|
||||||
{% if field.field.required %}<span class="form-required-indicator">*</span>{% endif %}
|
{% if field.field.required or required %}<span class="form-required-indicator">*</span>{% endif %}
|
||||||
</label>
|
</label>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -3,9 +3,9 @@
|
|||||||
{% load common %}
|
{% load common %}
|
||||||
|
|
||||||
{% with default_title="Blender Extensions" %}
|
{% with default_title="Blender Extensions" %}
|
||||||
{% with default_author="Blender Institute" %}
|
{% with default_author="Blender Foundation" %}
|
||||||
|
|
||||||
{% with default_description="Blender Extensions is a web based service developed by Blender Institute that allows people to share open source add-ons for Blender." %}
|
{% with default_description="Blender Extensions is a web based service developed by Blender Foundation that allows people to share open source add-ons for Blender." %}
|
||||||
|
|
||||||
{% if not image_url %}
|
{% if not image_url %}
|
||||||
{% absolute_url default_image_path as image_url %}
|
{% absolute_url default_image_path as image_url %}
|
||||||
|
@ -1,26 +1,59 @@
|
|||||||
{% load common %}
|
{% load common %}
|
||||||
{% get_proper_elided_page_range page_obj as page_range %}
|
{% get_proper_elided_page_range page_obj as page_range %}
|
||||||
<nav>
|
<nav>
|
||||||
<ul class="pagination">
|
{% if page_obj.has_other_pages %}
|
||||||
{% if page_obj.has_other_pages %}
|
<ul class="pagination pb-2">
|
||||||
|
{% if page_obj.number != 1 %}
|
||||||
|
<li class="page-item page-first">
|
||||||
|
<a href="?{% query_transform page=1 %}">
|
||||||
|
First
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item page-prev">
|
||||||
|
<a href="?{% query_transform page=page_obj.previous_page_number %}">
|
||||||
|
<i class="i-chevron-left"></i> Previous
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% for page_number in page_range %}
|
{% for page_number in page_range %}
|
||||||
{% if page_number == '…' %}
|
{% if page_number == '…' %}
|
||||||
<li class="page-item disabled">
|
<li class="page-item disabled">
|
||||||
<span class="page-link px-0">...</span>
|
<span class="page-link px-0">...</span>
|
||||||
</li>
|
</li>
|
||||||
{% elif page_obj.number == page_number %}
|
{% elif page_obj.number == page_number %}
|
||||||
<li class="page-item active" aria-current="page">
|
<li class="page-item page-current active" aria-current="page">
|
||||||
<a>{{ page_obj.number }}</a>
|
<a>{{ page_obj.number }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a href="?page={{ page_number }}">{{ page_number }}</a>
|
<a href="?{% query_transform page=page_number %}">{{ page_number }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
|
||||||
<li class="page-item">
|
{% if page_obj.has_next %}
|
||||||
{{ page_obj.paginator.count }} {{ label }}{{ page_obj.paginator.count | pluralize }}
|
<li class="page-item page-next">
|
||||||
</li>
|
<a href="?{% query_transform page=page_obj.next_page_number %}">
|
||||||
</ul>
|
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>
|
</nav>
|
||||||
|
@ -1,52 +1,6 @@
|
|||||||
{% with status=object.get_status_display %}
|
{% with label=label|default:object.get_status_display slug=slug|default:object.get_status_display|slugify %}
|
||||||
{% if 'incomplete' in status.lower %}
|
<div class="badge badge-status-{{ slug }} {{ classes }}">
|
||||||
<div class="badge badge-warning {{ class }}" title="Requires re-uploading or editing">
|
{% if icon %}<i class="i-status-{{ slug }}"></i>{% endif %}
|
||||||
<i class="i-alert-triangle"></i>
|
<span>{{ label }}</span>
|
||||||
<span>{{ status }}</span>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{% elif 'disabled' in status.lower %}
|
|
||||||
<div class="badge badge-danger {{ class }}">
|
|
||||||
<i class="i-eye"></i>
|
|
||||||
<span>{{ status }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% elif 'deleted' in status.lower %}
|
|
||||||
<div class="badge badge-danger {{ class }}">
|
|
||||||
<i class="i-eye"></i>
|
|
||||||
<span>{{ status }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% elif 'awaiting' in status.lower %}
|
|
||||||
<div class="badge badge-info {{ class }}" title="Awaiting a review by a human being and not yet publicly visible">
|
|
||||||
<i class="i-eye"></i>
|
|
||||||
<span>{{ status }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% elif 'approved' in status.lower %}
|
|
||||||
<div class="badge badge-success {{ class }}">
|
|
||||||
<i class="i-check"></i>
|
|
||||||
<span>{{ status }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% elif 'confirmed' in status.lower %}
|
|
||||||
<div class="badge badge-danger {{ class }}">
|
|
||||||
<span>{{ status }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% elif 'untriaged' in status.lower %}
|
|
||||||
<div class="badge badge-warning {{ class }}">
|
|
||||||
<span>{{ status }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% elif 'resolved' in status.lower %}
|
|
||||||
<div class="badge badge-success {{ class }}">
|
|
||||||
<span>{{ status }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="badge badge-secondary {{ class }}">
|
|
||||||
<span>{{ status }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="page-item page-prev">
|
<li class="page-item page-prev">
|
||||||
<a href="?page={{ pager.previous_page_number }}" rel="prev"><i class="i-chevron-left"></i> {% trans "Prev" %}</a>
|
<a href="?page={{ pager.previous_page_number }}" rel="prev"><i class="i-chevron-left"></i><span class="d-md-inline d-none"> {% trans "Prev" %}</span></a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
{% if pager.has_next %}
|
{% if pager.has_next %}
|
||||||
<li class="page-item page-next">
|
<li class="page-item page-next">
|
||||||
<a href="?page={{ pager.next_page_number }}" rel="next">{% trans "Next" %} <i class="i-chevron-right"></i></a>
|
<a href="?page={{ pager.next_page_number }}" rel="next"><span class="d-md-inline d-none">{% trans "Next" %} </span><i class="i-chevron-right"></i></a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="page-item page-last">
|
<li class="page-item page-last">
|
||||||
|
@ -29,6 +29,17 @@ def absolute_url(context, path: str) -> str:
|
|||||||
return utils.absolutify(path, request=request)
|
return utils.absolutify(path, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
# Allows for example to go to another page of search
|
||||||
|
# results while keeping the search query.
|
||||||
|
# Credit: https://stackoverflow.com/questions/46026268/
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
def query_transform(context, **kwargs):
|
||||||
|
query = context['request'].GET.copy()
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
query[k] = v
|
||||||
|
return query.urlencode()
|
||||||
|
|
||||||
|
|
||||||
class PaginationRenderer:
|
class PaginationRenderer:
|
||||||
def __init__(self, pager):
|
def __init__(self, pager):
|
||||||
self.pager = pager
|
self.pager = pager
|
||||||
|
@ -91,8 +91,8 @@ class VersionFactory(DjangoModelFactory):
|
|||||||
if not extracted:
|
if not extracted:
|
||||||
return
|
return
|
||||||
|
|
||||||
tags = Platform.objects.filter(slug__in=extracted)
|
platforms = Platform.objects.filter(slug__in=extracted)
|
||||||
self.platforms.add(*tags)
|
self.platforms.add(*platforms)
|
||||||
|
|
||||||
@factory.post_generation
|
@factory.post_generation
|
||||||
def tags(self, create, extracted, **kwargs):
|
def tags(self, create, extracted, **kwargs):
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
import itertools
|
import itertools
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
import django.urls as urls
|
import django.urls as urls
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.regex_helper import normalize
|
from django.utils.regex_helper import normalize
|
||||||
|
|
||||||
|
from apitokens.models import UserToken
|
||||||
|
|
||||||
|
|
||||||
try: # Django 2.0
|
try: # Django 2.0
|
||||||
url_resolver_types = (urls.URLResolver,)
|
url_resolver_types = (urls.URLResolver,)
|
||||||
DJANGO_2 = True
|
DJANGO_2 = True
|
||||||
@ -109,3 +113,11 @@ class CheckFilePropertiesMixin:
|
|||||||
self.assertEqual(file.original_name, kwargs.get('original_name'))
|
self.assertEqual(file.original_name, kwargs.get('original_name'))
|
||||||
if 'size_bytes' in kwargs:
|
if 'size_bytes' in kwargs:
|
||||||
self.assertEqual(file.size_bytes, kwargs.get('size_bytes'))
|
self.assertEqual(file.size_bytes, kwargs.get('size_bytes'))
|
||||||
|
|
||||||
|
|
||||||
|
def create_user_token(*args, **kwargs) -> Tuple['UserToken', str]:
|
||||||
|
token_key = UserToken.generate_token_key()
|
||||||
|
kwargs['token_hash'] = UserToken.generate_hash(token_key)
|
||||||
|
kwargs['token_prefix'] = UserToken.generate_token_prefix(token_key)
|
||||||
|
token = UserToken.objects.create(*args, **kwargs)
|
||||||
|
return token, token_key
|
||||||
|
@ -10,7 +10,7 @@ EXTENSION_TYPE_CHOICES = Choices(
|
|||||||
('BPY', 1, _('Add-on')),
|
('BPY', 1, _('Add-on')),
|
||||||
('THEME', 2, _('Theme')),
|
('THEME', 2, _('Theme')),
|
||||||
)
|
)
|
||||||
STATUS_INCOMPLETE = 1
|
STATUS_DRAFT = 1
|
||||||
STATUS_AWAITING_REVIEW = 2
|
STATUS_AWAITING_REVIEW = 2
|
||||||
STATUS_APPROVED = 3
|
STATUS_APPROVED = 3
|
||||||
STATUS_DISABLED = 4
|
STATUS_DISABLED = 4
|
||||||
@ -18,7 +18,7 @@ STATUS_DISABLED_BY_AUTHOR = 5
|
|||||||
|
|
||||||
# Extension statuses
|
# Extension statuses
|
||||||
EXTENSION_STATUS_CHOICES = Choices(
|
EXTENSION_STATUS_CHOICES = Choices(
|
||||||
('INCOMPLETE', STATUS_INCOMPLETE, _('Incomplete')),
|
('DRAFT', STATUS_DRAFT, _('Draft')),
|
||||||
('AWAITING_REVIEW', STATUS_AWAITING_REVIEW, _('Awaiting Review')),
|
('AWAITING_REVIEW', STATUS_AWAITING_REVIEW, _('Awaiting Review')),
|
||||||
('APPROVED', STATUS_APPROVED, _('Approved')),
|
('APPROVED', STATUS_APPROVED, _('Approved')),
|
||||||
('DISABLED', STATUS_DISABLED, _('Disabled by staff')),
|
('DISABLED', STATUS_DISABLED, _('Disabled by staff')),
|
||||||
|
@ -83,6 +83,7 @@ class ExtensionAdmin(admin.ModelAdmin):
|
|||||||
'website',
|
'website',
|
||||||
'icon',
|
'icon',
|
||||||
'featured_image',
|
'featured_image',
|
||||||
|
'latest_version',
|
||||||
)
|
)
|
||||||
autocomplete_fields = ('team',)
|
autocomplete_fields = ('team',)
|
||||||
|
|
||||||
@ -104,6 +105,7 @@ class ExtensionAdmin(admin.ModelAdmin):
|
|||||||
'description',
|
'description',
|
||||||
('icon', 'featured_image'),
|
('icon', 'featured_image'),
|
||||||
'status',
|
'status',
|
||||||
|
'latest_version',
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -163,6 +163,18 @@ class ExtensionUpdateForm(forms.ModelForm):
|
|||||||
|
|
||||||
self.add_preview_formset.error_messages['too_few_forms'] = self.msg_need_previews
|
self.add_preview_formset.error_messages['too_few_forms'] = self.msg_need_previews
|
||||||
|
|
||||||
|
user_teams = self.request.user.teams.all()
|
||||||
|
if self.request.user in self.instance.authors.all() and len(user_teams) > 0:
|
||||||
|
team_slug = None
|
||||||
|
if self.instance.team:
|
||||||
|
team_slug = self.instance.team.slug
|
||||||
|
choices = [(None, 'None'), *[(team.slug, team.name) for team in user_teams]]
|
||||||
|
self.fields['team'] = forms.ChoiceField(
|
||||||
|
choices=choices,
|
||||||
|
required=False,
|
||||||
|
initial=team_slug,
|
||||||
|
)
|
||||||
|
|
||||||
def is_valid(self, *args, **kwargs) -> bool:
|
def is_valid(self, *args, **kwargs) -> bool:
|
||||||
"""Validate all nested forms and form(set)s first."""
|
"""Validate all nested forms and form(set)s first."""
|
||||||
if 'submit_draft' in self.data:
|
if 'submit_draft' in self.data:
|
||||||
@ -198,6 +210,27 @@ class ExtensionUpdateForm(forms.ModelForm):
|
|||||||
|
|
||||||
return all(is_valid_flags)
|
return all(is_valid_flags)
|
||||||
|
|
||||||
|
def clean_team(self):
|
||||||
|
# don't modify instance if the field value wasn't sent
|
||||||
|
# empty value reset the team
|
||||||
|
if 'team' in self.data:
|
||||||
|
# TODO permissions check
|
||||||
|
# shouldn't happen normally: the form doesn't render the select
|
||||||
|
if self.request.user not in self.instance.authors.all():
|
||||||
|
self.add_error('team', _('Not allowed to set the team'))
|
||||||
|
return
|
||||||
|
|
||||||
|
team_slug = self.cleaned_data['team']
|
||||||
|
if team_slug:
|
||||||
|
team = self.request.user.teams.filter(slug=team_slug).first()
|
||||||
|
if not team:
|
||||||
|
self.add_error('team', _('User does not belong to the team'))
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self.instance.team = team
|
||||||
|
else:
|
||||||
|
self.instance.team = None
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Perform additional validation and status changes."""
|
"""Perform additional validation and status changes."""
|
||||||
super().clean()
|
super().clean()
|
||||||
@ -206,7 +239,7 @@ class ExtensionUpdateForm(forms.ModelForm):
|
|||||||
if self.instance.status != self.instance.STATUSES.AWAITING_REVIEW:
|
if self.instance.status != self.instance.STATUSES.AWAITING_REVIEW:
|
||||||
self.add_error(None, self.msg_cannot_convert_to_draft)
|
self.add_error(None, self.msg_cannot_convert_to_draft)
|
||||||
else:
|
else:
|
||||||
self.instance.status = self.instance.STATUSES.INCOMPLETE
|
self.instance.status = self.instance.STATUSES.DRAFT
|
||||||
self.instance.converted_to_draft = True
|
self.instance.converted_to_draft = True
|
||||||
|
|
||||||
# Send the extension and version to the review, if possible
|
# Send the extension and version to the review, if possible
|
||||||
|
@ -29,7 +29,7 @@ class Migration(migrations.Migration):
|
|||||||
('name', models.CharField(max_length=255, unique=True)),
|
('name', models.CharField(max_length=255, unique=True)),
|
||||||
('description', models.TextField(help_text='\n<p><a href="https://commonmark.org/help/" rel="nofollow" target="_blank">Markdown</a>\nis supported.</p>\n')),
|
('description', models.TextField(help_text='\n<p><a href="https://commonmark.org/help/" rel="nofollow" target="_blank">Markdown</a>\nis supported.</p>\n')),
|
||||||
('tagline', models.CharField(help_text='A very short description', max_length=128)),
|
('tagline', models.CharField(help_text='A very short description', max_length=128)),
|
||||||
('status', models.PositiveSmallIntegerField(choices=[(1, 'Incomplete'), (2, 'Awaiting Review'), (3, 'Approved'), (4, 'Disabled by staff'), (5, 'Disabled by author')], default=1)),
|
('status', models.PositiveSmallIntegerField(choices=[(1, 'Draft'), (2, 'Awaiting Review'), (3, 'Approved'), (4, 'Disabled by staff'), (5, 'Disabled by author')], default=1)),
|
||||||
('doc_url', models.URLField(blank=True, help_text='URL of the documentation', null=True)),
|
('doc_url', models.URLField(blank=True, help_text='URL of the documentation', null=True)),
|
||||||
('tracker_url', models.URLField(blank=True, help_text='URL of the issue tracker', null=True)),
|
('tracker_url', models.URLField(blank=True, help_text='URL of the issue tracker', null=True)),
|
||||||
('homepage_url', models.URLField(blank=True, help_text='URL of the homepage', null=True)),
|
('homepage_url', models.URLField(blank=True, help_text='URL of the homepage', null=True)),
|
||||||
|
50
extensions/migrations/0031_extension_latest_version.py
Normal file
50
extensions/migrations/0031_extension_latest_version.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Generated by Django 4.2.11 on 2024-05-27 12:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
from constants.base import (
|
||||||
|
EXTENSION_STATUS_CHOICES,
|
||||||
|
FILE_STATUS_CHOICES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def valid_file_statuses(self):
|
||||||
|
if self.status == EXTENSION_STATUS_CHOICES.APPROVED:
|
||||||
|
return [FILE_STATUS_CHOICES.APPROVED]
|
||||||
|
return [FILE_STATUS_CHOICES.AWAITING_REVIEW, FILE_STATUS_CHOICES.APPROVED]
|
||||||
|
|
||||||
|
|
||||||
|
def populate_latest_version(apps, schema_editor):
|
||||||
|
Extension = apps.get_model('extensions', 'Extension')
|
||||||
|
to_update = []
|
||||||
|
for extension in Extension.objects.prefetch_related(
|
||||||
|
'versions',
|
||||||
|
'versions__file',
|
||||||
|
).all():
|
||||||
|
versions = extension.versions.all().order_by('-date_created')
|
||||||
|
latest_version = None
|
||||||
|
for version in versions:
|
||||||
|
if version.file.status not in valid_file_statuses(extension):
|
||||||
|
continue
|
||||||
|
latest_version = version
|
||||||
|
break
|
||||||
|
extension.latest_version = latest_version
|
||||||
|
to_update.append(extension)
|
||||||
|
Extension.objects.bulk_update(to_update, ['latest_version'])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('extensions', '0030_platform_version_platforms'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='extension',
|
||||||
|
name='latest_version',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='latest_version_of', to='extensions.version'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(populate_latest_version, reverse_code=migrations.RunPython.noop),
|
||||||
|
]
|
@ -16,8 +16,11 @@ from constants.base import (
|
|||||||
EXTENSION_STATUS_CHOICES,
|
EXTENSION_STATUS_CHOICES,
|
||||||
EXTENSION_TYPE_CHOICES,
|
EXTENSION_TYPE_CHOICES,
|
||||||
EXTENSION_TYPE_SLUGS,
|
EXTENSION_TYPE_SLUGS,
|
||||||
|
EXTENSION_TYPE_SLUGS_SINGULAR,
|
||||||
FILE_STATUS_CHOICES,
|
FILE_STATUS_CHOICES,
|
||||||
)
|
)
|
||||||
|
from files.models import File
|
||||||
|
from reviewers.models import ApprovalActivity
|
||||||
import common.help_texts
|
import common.help_texts
|
||||||
import extensions.fields
|
import extensions.fields
|
||||||
|
|
||||||
@ -128,12 +131,19 @@ class ExtensionManager(models.Manager):
|
|||||||
def unlisted(self):
|
def unlisted(self):
|
||||||
return self.exclude(status=self.model.STATUSES.APPROVED)
|
return self.exclude(status=self.model.STATUSES.APPROVED)
|
||||||
|
|
||||||
def authored_by(self, user_id: int):
|
def _authored_by_filter(self, user):
|
||||||
return self.filter(maintainer__user_id=user_id)
|
filter = Q(maintainer__user_id=user.pk)
|
||||||
|
user_teams = user.teams.all()
|
||||||
|
if user_teams:
|
||||||
|
filter = filter | Q(team__in=[t.pk for t in user_teams])
|
||||||
|
return filter
|
||||||
|
|
||||||
def listed_or_authored_by(self, user_id: int):
|
def authored_by(self, user):
|
||||||
|
return self.filter(self._authored_by_filter(user)).distinct()
|
||||||
|
|
||||||
|
def listed_or_authored_by(self, user):
|
||||||
return self.filter(
|
return self.filter(
|
||||||
Q(status=self.model.STATUSES.APPROVED) | Q(maintainer__user_id=user_id)
|
Q(status=self.model.STATUSES.APPROVED) | self._authored_by_filter(user)
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
|
|
||||||
@ -160,6 +170,13 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
|
|||||||
help_text='Whether the extension should be listed. It is kept in sync via signals.',
|
help_text='Whether the extension should be listed. It is kept in sync via signals.',
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
latest_version = models.ForeignKey(
|
||||||
|
'Version',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='latest_version_of',
|
||||||
|
)
|
||||||
|
|
||||||
featured_image = models.OneToOneField(
|
featured_image = models.OneToOneField(
|
||||||
'files.File',
|
'files.File',
|
||||||
@ -183,7 +200,7 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
|
|||||||
)
|
)
|
||||||
previews = FilterableManyToManyField('files.File', through='Preview', related_name='extensions')
|
previews = FilterableManyToManyField('files.File', through='Preview', related_name='extensions')
|
||||||
|
|
||||||
status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.INCOMPLETE)
|
status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.DRAFT)
|
||||||
support = models.URLField(
|
support = models.URLField(
|
||||||
help_text='URL for reporting issues or contact details for support.', null=True, blank=True
|
help_text='URL for reporting issues or contact details for support.', null=True, blank=True
|
||||||
)
|
)
|
||||||
@ -212,14 +229,14 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
|
|||||||
def type_slug(self) -> str:
|
def type_slug(self) -> str:
|
||||||
return EXTENSION_TYPE_SLUGS[self.type]
|
return EXTENSION_TYPE_SLUGS[self.type]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type_slug_singular(self) -> str:
|
||||||
|
return EXTENSION_TYPE_SLUGS_SINGULAR[self.type]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status_slug(self) -> str:
|
def status_slug(self) -> str:
|
||||||
return utils.slugify(EXTENSION_STATUS_CHOICES[self.status - 1][1])
|
return utils.slugify(EXTENSION_STATUS_CHOICES[self.status - 1][1])
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
self.clean()
|
|
||||||
return super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def update_metadata_from_version(self, version):
|
def update_metadata_from_version(self, version):
|
||||||
update_fields = set()
|
update_fields = set()
|
||||||
metadata = version.file.metadata
|
metadata = version.file.metadata
|
||||||
@ -325,34 +342,6 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
|
|||||||
return [FILE_STATUS_CHOICES.APPROVED]
|
return [FILE_STATUS_CHOICES.APPROVED]
|
||||||
return [FILE_STATUS_CHOICES.AWAITING_REVIEW, FILE_STATUS_CHOICES.APPROVED]
|
return [FILE_STATUS_CHOICES.AWAITING_REVIEW, FILE_STATUS_CHOICES.APPROVED]
|
||||||
|
|
||||||
@property
|
|
||||||
def latest_version(self):
|
|
||||||
"""Retrieve the latest version."""
|
|
||||||
versions = [
|
|
||||||
v for v in self.versions.all() if v.file and v.file.status in self.valid_file_statuses
|
|
||||||
]
|
|
||||||
if not versions:
|
|
||||||
return None
|
|
||||||
versions = sorted(versions, key=lambda v: v.date_created, reverse=True)
|
|
||||||
return versions[0]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_version(self):
|
|
||||||
"""Return the latest public listed version of an extension.
|
|
||||||
|
|
||||||
If the add-on is not public, it can return a listed version awaiting
|
|
||||||
review (since non-public add-ons should not have public versions).
|
|
||||||
|
|
||||||
If the add-on has not been created yet or is deleted, it returns None.
|
|
||||||
"""
|
|
||||||
if not self.id:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return self.version
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
def can_request_review(self):
|
def can_request_review(self):
|
||||||
"""Return whether an add-on can request a review or not."""
|
"""Return whether an add-on can request a review or not."""
|
||||||
if self.is_disabled or self.status in (
|
if self.is_disabled or self.status in (
|
||||||
@ -379,16 +368,20 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
|
|||||||
|
|
||||||
def should_redirect_to_submit_flow(self):
|
def should_redirect_to_submit_flow(self):
|
||||||
return (
|
return (
|
||||||
self.status == self.STATUSES.INCOMPLETE
|
self.status == self.STATUSES.DRAFT
|
||||||
and not self.has_complete_metadata()
|
and not self.has_complete_metadata()
|
||||||
and self.latest_version is not None
|
and self.latest_version is not None
|
||||||
)
|
)
|
||||||
|
|
||||||
def has_maintainer(self, user) -> bool:
|
def has_maintainer(self, user) -> bool:
|
||||||
"""Return True if given user is listed as a maintainer."""
|
"""Return True if given user is listed as a maintainer or is a member of the team."""
|
||||||
if user is None or user.is_anonymous:
|
if user is None or user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
return user in self.authors.all()
|
if user in self.authors.all():
|
||||||
|
return True
|
||||||
|
if self.team and user in self.team.users.all():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def can_rate(self, user) -> bool:
|
def can_rate(self, user) -> bool:
|
||||||
"""Return True if given user can rate this extension.
|
"""Return True if given user can rate this extension.
|
||||||
@ -414,6 +407,40 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
|
|||||||
lookup_field = 'slug'
|
lookup_field = 'slug'
|
||||||
return lookup_field
|
return lookup_field
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update_latest_version(self, skip_version=None):
|
||||||
|
versions = self.versions.select_related('file').order_by('-date_created')
|
||||||
|
latest_version = None
|
||||||
|
for version in versions:
|
||||||
|
if skip_version and version == skip_version:
|
||||||
|
continue
|
||||||
|
if version.file.status not in self.valid_file_statuses:
|
||||||
|
continue
|
||||||
|
latest_version = version
|
||||||
|
break
|
||||||
|
self.latest_version = latest_version
|
||||||
|
self.save(update_fields={'latest_version'})
|
||||||
|
if latest_version:
|
||||||
|
self.update_metadata_from_version(latest_version)
|
||||||
|
|
||||||
|
def update_is_listed(self):
|
||||||
|
should_be_listed = (
|
||||||
|
self.status == self.STATUSES.APPROVED
|
||||||
|
and self.versions.filter(file__status=File.STATUSES.APPROVED).count() > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# this method is called from post_save signal, this early return should prevent a loop
|
||||||
|
if self.is_listed == should_be_listed:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.is_listed = should_be_listed
|
||||||
|
update_fields = {'is_listed'}
|
||||||
|
if self.status == self.STATUSES.APPROVED and not should_be_listed:
|
||||||
|
self.status = self.STATUSES.DRAFT
|
||||||
|
update_fields.add('status')
|
||||||
|
|
||||||
|
self.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
|
||||||
class VersionPermission(CreatedModifiedMixin, models.Model):
|
class VersionPermission(CreatedModifiedMixin, models.Model):
|
||||||
name = models.CharField(max_length=128, null=False, blank=False, unique=True)
|
name = models.CharField(max_length=128, null=False, blank=False, unique=True)
|
||||||
@ -606,7 +633,7 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
|
|||||||
@property
|
@property
|
||||||
def is_listed(self):
|
def is_listed(self):
|
||||||
# To be public, version file must have a public status.
|
# To be public, version file must have a public status.
|
||||||
return self.file is not None and self.file.status == self.file.STATUSES.APPROVED
|
return self.file.status == self.file.STATUSES.APPROVED
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cannot_be_deleted_reasons(self) -> List[str]:
|
def cannot_be_deleted_reasons(self) -> List[str]:
|
||||||
@ -631,12 +658,14 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def download_url(self) -> str:
|
def download_url(self) -> str:
|
||||||
|
filename = f'{self.extension.type_slug_singular}-{self.extension.slug}-v{self.version}.zip'
|
||||||
return reverse(
|
return reverse(
|
||||||
'extensions:version-download',
|
'extensions:version-download',
|
||||||
kwargs={
|
kwargs={
|
||||||
'type_slug': self.extension.type_slug,
|
'type_slug': self.extension.type_slug,
|
||||||
'slug': self.extension.slug,
|
'slug': self.extension.slug,
|
||||||
'version': self.version,
|
'version': self.version,
|
||||||
|
'filename': filename,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -661,6 +690,41 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
is_new = self.pk is None
|
||||||
|
update_fields = kwargs.get('update_fields', None)
|
||||||
|
was_changed, old_state = self.pre_save_record(update_fields=update_fields)
|
||||||
|
self.record_status_change(was_changed, old_state, **kwargs)
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
if not is_new:
|
||||||
|
return
|
||||||
|
|
||||||
|
# auto approving our file if extension is already listed (i.e. have been approved)
|
||||||
|
if self.extension.is_listed:
|
||||||
|
args = {'f_id': self.file.pk, 'pk': self.pk, 's': self.file.source.name}
|
||||||
|
log.info('Auto-approving file pk=%(f_id)s of Version pk=%(pk)s source=%(s)s', args)
|
||||||
|
self.file.status = File.STATUSES.APPROVED
|
||||||
|
self.file.save(update_fields={'status', 'date_modified'})
|
||||||
|
|
||||||
|
ApprovalActivity(
|
||||||
|
type=ApprovalActivity.ActivityType.UPLOADED_NEW_VERSION,
|
||||||
|
user=self.file.user,
|
||||||
|
extension=self.extension,
|
||||||
|
message=f'uploaded new version: {self.version}',
|
||||||
|
).save()
|
||||||
|
|
||||||
|
self.extension.update_latest_version()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
if self == self.extension.latest_version:
|
||||||
|
# make sure self is not a candidate for latest_version, since it's being deleted
|
||||||
|
self.extension.update_latest_version(skip_version=self)
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Maintainer(CreatedModifiedMixin, models.Model):
|
class Maintainer(CreatedModifiedMixin, models.Model):
|
||||||
extension = models.ForeignKey(Extension, on_delete=models.CASCADE)
|
extension = models.ForeignKey(Extension, on_delete=models.CASCADE)
|
||||||
|
@ -4,11 +4,11 @@ import logging
|
|||||||
from actstream.actions import follow, unfollow
|
from actstream.actions import follow, unfollow
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.db.models.signals import m2m_changed, pre_save, post_save, pre_delete, post_delete
|
from django.db import transaction
|
||||||
|
from django.db.models.signals import m2m_changed, pre_save, post_save, pre_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from constants.activity import Flag
|
from constants.activity import Flag
|
||||||
from reviewers.models import ApprovalActivity
|
|
||||||
import extensions.models
|
import extensions.models
|
||||||
import files.models
|
import files.models
|
||||||
|
|
||||||
@ -46,42 +46,24 @@ def _log_deletion(
|
|||||||
|
|
||||||
|
|
||||||
@receiver(pre_save, sender=extensions.models.Extension)
|
@receiver(pre_save, sender=extensions.models.Extension)
|
||||||
@receiver(pre_save, sender=extensions.models.Version)
|
|
||||||
def _record_changes(
|
def _record_changes(
|
||||||
sender: object,
|
sender: object,
|
||||||
instance: Union[extensions.models.Extension, extensions.models.Version],
|
instance: extensions.models.Extension,
|
||||||
update_fields: object,
|
update_fields: object,
|
||||||
**kwargs: object,
|
**kwargs: object,
|
||||||
) -> None:
|
) -> None:
|
||||||
was_changed, old_state = instance.pre_save_record(update_fields=update_fields)
|
was_changed, old_state = instance.pre_save_record(update_fields=update_fields)
|
||||||
|
instance.sanitize('name', was_changed, old_state, **kwargs)
|
||||||
if hasattr(instance, 'name'):
|
instance.sanitize('description', was_changed, old_state, **kwargs)
|
||||||
instance.sanitize('name', was_changed, old_state, **kwargs)
|
|
||||||
if hasattr(instance, 'description'):
|
|
||||||
instance.sanitize('description', was_changed, old_state, **kwargs)
|
|
||||||
|
|
||||||
instance.record_status_change(was_changed, old_state, **kwargs)
|
instance.record_status_change(was_changed, old_state, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=extensions.models.Extension)
|
# TODO? move this out into version.approve that would take care of updating file.status and
|
||||||
def _update_search_index(sender, instance, **kw):
|
# recomputing extension's is_listed and latest_version fields
|
||||||
pass # TODO: update search index
|
|
||||||
|
|
||||||
|
|
||||||
def extension_should_be_listed(extension):
|
|
||||||
return (
|
|
||||||
extension.latest_version is not None
|
|
||||||
and extension.latest_version.is_listed
|
|
||||||
and extension.status == extension.STATUSES.APPROVED
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=extensions.models.Extension)
|
|
||||||
@receiver(post_save, sender=extensions.models.Version)
|
|
||||||
@receiver(post_save, sender=files.models.File)
|
@receiver(post_save, sender=files.models.File)
|
||||||
def _set_is_listed(
|
def _update_version(
|
||||||
sender: object,
|
sender: object,
|
||||||
instance: Union[extensions.models.Extension, extensions.models.Version, files.models.File],
|
instance: files.models.File,
|
||||||
raw: bool,
|
raw: bool,
|
||||||
*args: object,
|
*args: object,
|
||||||
**kwargs: object,
|
**kwargs: object,
|
||||||
@ -89,26 +71,27 @@ def _set_is_listed(
|
|||||||
if raw:
|
if raw:
|
||||||
return
|
return
|
||||||
|
|
||||||
if isinstance(instance, extensions.models.Extension):
|
if hasattr(instance, 'version'):
|
||||||
extension = instance
|
extension = instance.version.extension
|
||||||
else:
|
with transaction.atomic():
|
||||||
# Since signals is called very early on, we can't assume file.extension will be available.
|
# it's important to update is_listed before computing latest_version
|
||||||
extension = instance.extension
|
# because latest_version for listed and unlisted extensions are defined differently
|
||||||
if not extension:
|
extension.update_is_listed()
|
||||||
|
extension.update_latest_version()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=extensions.models.Extension)
|
||||||
|
def _set_is_listed(
|
||||||
|
sender: object,
|
||||||
|
instance: extensions.models.Extension,
|
||||||
|
raw: bool,
|
||||||
|
*args: object,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> None:
|
||||||
|
if raw:
|
||||||
return
|
return
|
||||||
|
|
||||||
old_is_listed = extension.is_listed
|
instance.update_is_listed()
|
||||||
new_is_listed = extension_should_be_listed(extension)
|
|
||||||
|
|
||||||
if old_is_listed == new_is_listed:
|
|
||||||
return
|
|
||||||
|
|
||||||
if extension.status == extensions.models.Extension.STATUSES.APPROVED and not new_is_listed:
|
|
||||||
extension.status = extensions.models.Extension.STATUSES.INCOMPLETE
|
|
||||||
|
|
||||||
logger.info('Extension pk=%s becomes listed', extension.pk)
|
|
||||||
extension.is_listed = new_is_listed
|
|
||||||
extension.save()
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=extensions.models.Extension)
|
@receiver(post_save, sender=extensions.models.Extension)
|
||||||
@ -148,10 +131,9 @@ def _update_authors_follow(instance, action, model, reverse, pk_set, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=extensions.models.Preview)
|
@receiver(post_save, sender=extensions.models.Preview)
|
||||||
@receiver(post_save, sender=extensions.models.Version)
|
|
||||||
def _auto_approve_subsequent_uploads(
|
def _auto_approve_subsequent_uploads(
|
||||||
sender: object,
|
sender: object,
|
||||||
instance: Union[extensions.models.Preview, extensions.models.Version],
|
instance: extensions.models.Preview,
|
||||||
created: bool,
|
created: bool,
|
||||||
raw: bool,
|
raw: bool,
|
||||||
**kwargs: object,
|
**kwargs: object,
|
||||||
@ -163,7 +145,7 @@ def _auto_approve_subsequent_uploads(
|
|||||||
if not instance.file_id:
|
if not instance.file_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
# N.B.: currently, subsequent version and preview uploads get approved automatically,
|
# N.B.: currently, subsequent preview uploads get approved automatically,
|
||||||
# if extension is currently listed (meaning, it was approved by a human already).
|
# if extension is currently listed (meaning, it was approved by a human already).
|
||||||
extension = instance.extension
|
extension = instance.extension
|
||||||
file = instance.file
|
file = instance.file
|
||||||
@ -172,45 +154,3 @@ def _auto_approve_subsequent_uploads(
|
|||||||
args = {'f_id': file.pk, 'pk': instance.pk, 'sender': sender, 's': file.source.name}
|
args = {'f_id': file.pk, 'pk': instance.pk, 'sender': sender, 's': file.source.name}
|
||||||
logger.info('Auto-approving file pk=%(f_id)s of %(sender)s pk=%(pk)s source=%(s)s', args)
|
logger.info('Auto-approving file pk=%(f_id)s of %(sender)s pk=%(pk)s source=%(s)s', args)
|
||||||
file.save(update_fields={'status', 'date_modified'})
|
file.save(update_fields={'status', 'date_modified'})
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=extensions.models.Version)
|
|
||||||
def _create_approval_activity_for_new_version_if_listed(
|
|
||||||
sender: object,
|
|
||||||
instance: extensions.models.Version,
|
|
||||||
created: bool,
|
|
||||||
raw: bool,
|
|
||||||
**kwargs: object,
|
|
||||||
):
|
|
||||||
if raw:
|
|
||||||
return
|
|
||||||
if not created:
|
|
||||||
return
|
|
||||||
extension = instance.extension
|
|
||||||
if not extension.is_listed or not instance.file:
|
|
||||||
return
|
|
||||||
ApprovalActivity(
|
|
||||||
type=ApprovalActivity.ActivityType.UPLOADED_NEW_VERSION,
|
|
||||||
user=instance.file.user,
|
|
||||||
extension=instance.extension,
|
|
||||||
message=f'uploaded new version: {instance.version}',
|
|
||||||
).save()
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=extensions.models.Version)
|
|
||||||
@receiver(post_save, sender=extensions.models.Version)
|
|
||||||
def _update_extension_metadata_from_latest_version(
|
|
||||||
sender: object,
|
|
||||||
instance: extensions.models.Version,
|
|
||||||
**kwargs: object,
|
|
||||||
):
|
|
||||||
# this code will also be triggered when an extension is deleted
|
|
||||||
# and it deletes all related versions
|
|
||||||
extension = instance.extension
|
|
||||||
latest_version = extension.latest_version
|
|
||||||
|
|
||||||
# should check in case we are deleting the latest version, then no need to update anything
|
|
||||||
if not latest_version:
|
|
||||||
return
|
|
||||||
|
|
||||||
extension.update_metadata_from_version(latest_version)
|
|
||||||
|
@ -38,7 +38,7 @@ function appendImageUploadForm() {
|
|||||||
<div class="details">
|
<div class="details">
|
||||||
<div>
|
<div>
|
||||||
<label for="${formsetPrefix}-${i}-caption">Image or Video</label>
|
<label for="${formsetPrefix}-${i}-caption">Image or Video</label>
|
||||||
<input class="js-input-img-caption form-control" id="${formsetPrefix}-${i}-caption" type="text" maxlength="255" name="${formsetPrefix}-${i}-caption" placeholder="Description">
|
<input class="js-input-img-caption form-control mb-2" id="${formsetPrefix}-${i}-caption" type="text" maxlength="255" name="${formsetPrefix}-${i}-caption" placeholder="Description">
|
||||||
</div>
|
</div>
|
||||||
<div class="details-buttons">
|
<div class="details-buttons">
|
||||||
<input accept="image/jpg,image/jpeg,image/png,image/webp,video/mp4" class="form-control form-control-sm js-input-img" id="id_${formsetPrefix}-${i}-source" type="file" name="${formsetPrefix}-${i}-source">
|
<input accept="image/jpg,image/jpeg,image/png,image/webp,video/mp4" class="form-control form-control-sm js-input-img" id="id_${formsetPrefix}-${i}-source" type="file" name="${formsetPrefix}-${i}-source">
|
||||||
|
@ -18,7 +18,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock hero_breadcrumbs %}
|
{% endblock hero_breadcrumbs %}
|
||||||
|
|
||||||
<h1>{% include "extensions/components/icon.html" with classes="icon-lg" %} {{ extension.name }}</h1>
|
<h1 class="d-flex">
|
||||||
|
{% include "extensions/components/icon.html" with classes="icon-lg me-2" %}
|
||||||
|
{{ extension.name }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div class="hero-subtitle">
|
<div class="hero-subtitle">
|
||||||
{% if latest.tagline %}
|
{% if latest.tagline %}
|
||||||
@ -39,45 +42,44 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not extension.is_approved %}
|
{% if not extension.is_approved %}
|
||||||
<span class="badge badge-outline badge-status-{{ extension.get_status_display|slugify }}">
|
{% include "common/components/status.html" with object=extension classes="badge-outline" %}
|
||||||
{{ extension.get_status_display }}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block hero_tabs %}
|
{% block hero_tabs %}
|
||||||
<nav class="hero-tabs">
|
<nav class="d-flex flex-column-reverse flex-md-row">
|
||||||
<a href="{{ extension.get_absolute_url }}" class="{% if extension.get_absolute_url == request.get_full_path %}is-active{% endif %}">
|
<div class="hero-tabs">
|
||||||
{% trans "About" %}
|
<a href="{{ extension.get_absolute_url }}" class="{% if extension.get_absolute_url == request.get_full_path %}is-active{% endif %}">
|
||||||
</a>
|
{% trans "About" %}
|
||||||
{% if latest.release_notes %}
|
|
||||||
<a href="{{ extension.get_absolute_url }}#new">
|
|
||||||
{% trans "What's New" %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if latest.permissions.all %}
|
|
||||||
<a href="{{ extension.get_absolute_url }}#permissions">
|
|
||||||
{% trans "Permissions" %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if extension.is_approved %}
|
|
||||||
<a href="{{ extension.get_ratings_url }}" class="{% if '/reviews/' in request.get_full_path %}is-active{% endif %}">
|
|
||||||
{% trans "Reviews" %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
<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">
|
|
||||||
{% if is_maintainer %}
|
|
||||||
<a href="{{ extension.get_manage_url }}" class="btn">
|
|
||||||
<i class="i-edit"></i> {% trans 'Edit' %}
|
|
||||||
</a>
|
</a>
|
||||||
|
{% if latest.release_notes %}
|
||||||
|
<a href="{{ extension.get_absolute_url }}#new">
|
||||||
|
{% trans "What's New" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if latest.permissions.all %}
|
||||||
|
<a href="{{ extension.get_absolute_url }}#permissions">
|
||||||
|
{% trans "Permissions" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if extension.is_approved %}
|
||||||
|
<a href="{{ extension.get_ratings_url }}" class="{% if '/reviews/' in request.get_full_path %}is-active{% endif %}">
|
||||||
|
{% trans "Reviews" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}">
|
||||||
|
{% trans "Version History" %}
|
||||||
|
</a>
|
||||||
|
</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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% if request.user.is_staff %}
|
{% if request.user.is_staff %}
|
||||||
@ -87,16 +89,7 @@
|
|||||||
<i class="i-chevron-down"></i>
|
<i class="i-chevron-down"></i>
|
||||||
</button>
|
</button>
|
||||||
<ul id="extension-admin-menu" class="dropdown-menu dropdown-menu-right js-dropdown-menu">
|
<ul id="extension-admin-menu" class="dropdown-menu dropdown-menu-right js-dropdown-menu">
|
||||||
<li>
|
{% include "extensions/components/dropdown_admin.html" %}
|
||||||
<a href="{% url 'admin:extensions_extension_change' extension.pk %}" class="dropdown-item is-admin">
|
|
||||||
<i class="i-edit"></i> {% trans 'Edit' %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'admin:ratings_rating_changelist' %}?extension_id={{ extension.pk }}" class="dropdown-item is-admin">
|
|
||||||
<i class="i-star"></i> {% trans 'Reviews' %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -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" %}
|
{% extends "common/base.html" %}
|
||||||
{% load i18n common pipeline %}
|
{% load common filters i18n pipeline %}
|
||||||
|
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
{% with extension=extension_form.instance %}
|
{% with extension=extension_form.instance %}
|
||||||
@ -38,16 +38,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card p-3 mb-3">
|
<section class="card p-3 mb-3">
|
||||||
{% for field in extension_form %}
|
{% include "extensions/components/extension_form.html" with extension_form=extension_form %}
|
||||||
{% if field != 'tags' %}
|
|
||||||
{# TODO: fix handling of tags #}
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
{% include "common/components/field.html" with placeholder="Enter the text here..." %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-4">
|
<section class="mt-4">
|
||||||
@ -60,7 +51,8 @@
|
|||||||
<section class="mt-4">
|
<section class="mt-4">
|
||||||
<h2>{% trans 'Media' %}</h2>
|
<h2>{% trans 'Media' %}</h2>
|
||||||
<div class="row flex">
|
<div class="row flex">
|
||||||
<div class="col-md-6">
|
{# TODO: @web-assets check media brakpoints utilities 'md' #}
|
||||||
|
<div class="col-md-6 mb-3 mb-md-0">
|
||||||
<div class="box p-3">
|
<div class="box p-3">
|
||||||
{% trans "Featured Image" as featured_image_label %}
|
{% trans "Featured Image" as featured_image_label %}
|
||||||
{% trans "A JPEG, PNG or WebP image, at least 1920 x 1080 and with aspect ratio of 16:9." as featured_image_help_text %}
|
{% trans "A JPEG, PNG or WebP image, at least 1920 x 1080 and with aspect ratio of 16:9." as featured_image_help_text %}
|
||||||
|
@ -13,51 +13,52 @@
|
|||||||
<h2 class="me-auto">{{ type }}</h2>
|
<h2 class="me-auto">{{ type }}</h2>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h2 class="align-items-center d-flex mb-0">
|
<h2 class="align-items-center d-flex mb-0">
|
||||||
<span class="me-3">{% blocktranslate %}Extensions with the tag{% endblocktranslate %}</span>
|
<span class="d-md-block d-none">
|
||||||
{% include "extensions/components/badge_tag.html" %}
|
<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>
|
</h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if tags %}
|
<div class="d-flex">
|
||||||
<div class="d-flex flex-column flex-md-row">
|
{% if tags %}
|
||||||
<div class="box dropdown me-md-3 p-2 rounded-2">
|
<div class="dropdown dropdown-filter-sort me-2">
|
||||||
<button class="align-items-center d-flex dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="js-dropdown-menu-filter">
|
<button class="align-items-center d-flex dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="js-dropdown-menu-filter">
|
||||||
{% if tag %}
|
{% if tag %}
|
||||||
{{ tag.name }}
|
{{ tag.name }}
|
||||||
{# TODO: @back-end add tags count dynamic #}
|
{# TODO: @back-end add tags count dynamic #}
|
||||||
<div class="align-items-center bg-secondary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
|
<div class="align-items-center bg-tertiary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
|
||||||
1
|
1
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
All
|
All
|
||||||
{# TODO: @back-end add tags count dynamic #}
|
{# TODO: @back-end add tags count dynamic #}
|
||||||
<div class="align-items-center bg-secondary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
|
<div class="align-items-center bg-tertiary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
|
||||||
1
|
1
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<i class="i-chevron-down"></i>
|
<i class="i-chevron-down"></i>
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-filter-sort dropdown-menu-right js-dropdown-menu" id="js-dropdown-menu-filter">
|
<ul class="dropdown-menu dropdown-menu-filter dropdown-menu-filter-sort dropdown-menu-right js-dropdown-menu" id="js-dropdown-menu-filter">
|
||||||
<li>
|
<li>
|
||||||
{% if tag %}
|
{# If tag is active, show button 'All'. #}
|
||||||
{# If tag is active, show button 'All'. #}
|
{# TODO @back-end: Find a proper way to get the plural tag type to build the URL. #}
|
||||||
{# TODO @back-end: Find a proper way to get the plural tag type to build the URL. #}
|
<a class="dropdown-item {% if not tag.name %}is-active{% endif %}" href="/{{ tag.get_type_display|slugify }}s/">
|
||||||
<a class="dropdown-item justify-content-between" href="/{{ tag.get_type_display|slugify }}s/">
|
All
|
||||||
All
|
<div class="align-items-center bg-tertiary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
|
||||||
<div class="align-items-center bg-secondary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
|
1
|
||||||
1
|
</div>
|
||||||
</div>
|
</a>
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{% for list_tag in tags %}
|
{% for list_tag in tags %}
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item justify-content-between" href="{% url "extensions:by-tag" tag_slug=list_tag.slug %}" title="{{ list_tag.name }}">
|
<a class="dropdown-item {% if tag.name == list_tag.name %}is-active{% endif %}" href="{% url "extensions:by-tag" tag_slug=list_tag.slug %}" title="{{ list_tag.name }}">
|
||||||
{{ list_tag.name }}
|
{{ list_tag.name }}
|
||||||
|
|
||||||
<div class="align-items-center bg-secondary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
|
<div class="align-items-center bg-tertiary d-flex h-4 fs-xs justify-content-center ms-2 rounded-circle w-4">
|
||||||
1
|
1
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@ -65,50 +66,50 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="box dropdown p-2 rounded-2">
|
{% endif %}
|
||||||
<button class="dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="js-dropdown-menu-sort">
|
<div class="dropdown dropdown-filter-sort">
|
||||||
Sort by <i class="i-chevron-down"></i>
|
<button class="dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="js-dropdown-menu-sort">
|
||||||
</button>
|
Sort by <i class="i-chevron-down"></i>
|
||||||
<ul class="dropdown-menu dropdown-menu-filter-sort dropdown-menu-right js-dropdown-menu" id="js-dropdown-menu-sort">
|
</button>
|
||||||
<li>
|
<ul class="dropdown-menu dropdown-menu-filter-sort dropdown-menu-right js-dropdown-menu" id="js-dropdown-menu-sort">
|
||||||
<a class="dropdown-item" href="#">
|
<li>
|
||||||
Newest First
|
<a class="dropdown-item" href="#">
|
||||||
</a>
|
Newest First
|
||||||
</li>
|
</a>
|
||||||
<li>
|
</li>
|
||||||
<a class="dropdown-item" href="#">
|
<li>
|
||||||
Recently Updated
|
<a class="dropdown-item" href="#">
|
||||||
</a>
|
Recently Updated
|
||||||
</li>
|
</a>
|
||||||
<li>
|
</li>
|
||||||
<a class="dropdown-item" href="#">
|
<li>
|
||||||
Most Reviewed
|
<a class="dropdown-item" href="#">
|
||||||
</a>
|
Most Reviewed
|
||||||
</li>
|
</a>
|
||||||
<li>
|
</li>
|
||||||
<a class="dropdown-item" href="#">
|
<li>
|
||||||
Rating
|
<a class="dropdown-item" href="#">
|
||||||
</a>
|
Rating
|
||||||
</li>
|
</a>
|
||||||
<li>
|
</li>
|
||||||
<a class="dropdown-item" href="#">
|
<li>
|
||||||
Downloads
|
<a class="dropdown-item" href="#">
|
||||||
</a>
|
Downloads
|
||||||
</li>
|
</a>
|
||||||
<li>
|
</li>
|
||||||
<a class="dropdown-item" href="#">
|
<li>
|
||||||
Title (A-Z)
|
<a class="dropdown-item" href="#">
|
||||||
</a>
|
Title (A-Z)
|
||||||
</li>
|
</a>
|
||||||
<li>
|
</li>
|
||||||
<a class="dropdown-item" href="#">
|
<li>
|
||||||
Title (Z-A)
|
<a class="dropdown-item" href="#">
|
||||||
</a>
|
Title (Z-A)
|
||||||
</li>
|
</a>
|
||||||
</ul>
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<div class="js-input-img-caption-helper">
|
<div class="js-input-img-caption-helper mb-2">
|
||||||
{% include "common/components/field.html" with field=inlineform.caption label='Image or Video' placeholder="Description" %}
|
{% include "common/components/field.html" with field=inlineform.caption label='Image or Video' placeholder="Description" %}
|
||||||
</div>
|
</div>
|
||||||
<div class="details-buttons js-input-img-helper">
|
<div class="details-buttons js-input-img-helper">
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
<span>{% trans 'Edit' %}</span>
|
<span>{% trans 'Edit' %}</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="align-items-center d-flex">
|
<div class="align-items-center d-flex">
|
||||||
{% include "common/components/status.html" with object=extension class="badge-tag" %}
|
{% include "common/components/status.html" with object=extension classes="badge-tag" %}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
{% extends "common/base.html" %}
|
{% extends "common/base.html" %}
|
||||||
{% load filters %}
|
{% load common filters i18n pipeline %}
|
||||||
{% load i18n common pipeline %}
|
|
||||||
{% block page_title %}{{ extension.name }}{% endblock page_title %}
|
{% block page_title %}{{ extension.name }}{% endblock page_title %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -29,13 +28,7 @@
|
|||||||
{{ form.errors }}
|
{{ form.errors }}
|
||||||
|
|
||||||
<section class="card p-3">
|
<section class="card p-3">
|
||||||
<div>
|
{% include "extensions/components/extension_form.html" with extension_form=form %}
|
||||||
{% include "common/components/field.html" with field=form.description label="Description" placeholder="Describe the extension..." %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{% include "common/components/field.html" with field=form.support placeholder="https://example.com" %}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-4">
|
<section class="mt-4">
|
||||||
|
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 django.test import TestCase
|
||||||
|
|
||||||
from common.tests.factories.extensions import create_version
|
from common.tests.factories.extensions import create_version
|
||||||
|
from files.models import File
|
||||||
|
|
||||||
|
|
||||||
class ApproveExtensionTest(TestCase):
|
class ApproveExtensionTest(TestCase):
|
||||||
fixtures = ['licenses']
|
fixtures = ['licenses']
|
||||||
|
|
||||||
def test_approve_extension(self): # TODO
|
def test_approve_extension(self):
|
||||||
create_version().extension
|
first_version = create_version()
|
||||||
|
extension = first_version.extension
|
||||||
|
self.assertFalse(extension.is_listed)
|
||||||
|
extension.approve()
|
||||||
|
self.assertTrue(extension.is_listed)
|
||||||
|
|
||||||
|
# auto approve of new versions
|
||||||
|
new_version = create_version(extension=extension)
|
||||||
|
extension.refresh_from_db()
|
||||||
|
self.assertEqual(new_version, extension.latest_version)
|
||||||
|
self.assertTrue(new_version.is_listed)
|
||||||
|
self.assertTrue(extension.is_listed)
|
||||||
|
|
||||||
|
# TODO stop supporting direct file status updates, introduce methods for Version object
|
||||||
|
# that would replace the signals logic
|
||||||
|
|
||||||
|
# latest_version of approved extension must be listed
|
||||||
|
# check that we rollback latest_version when file is not approved
|
||||||
|
new_version.file.status = File.STATUSES.AWAITING_REVIEW
|
||||||
|
new_version.file.save()
|
||||||
|
self.assertFalse(new_version.is_listed)
|
||||||
|
extension.refresh_from_db()
|
||||||
|
self.assertEqual(first_version, extension.latest_version)
|
||||||
|
self.assertTrue(extension.is_listed)
|
||||||
|
|
||||||
|
# break the first_version, check that nothing is listed anymore
|
||||||
|
first_version.file.status = File.STATUSES.AWAITING_REVIEW
|
||||||
|
first_version.file.save()
|
||||||
|
self.assertFalse(first_version.is_listed)
|
||||||
|
extension.refresh_from_db()
|
||||||
|
self.assertFalse(extension.is_listed)
|
||||||
|
|
||||||
|
# this looks weird, but that's the current definition of latest_version, it's different
|
||||||
|
# for listed and unlisted extensions:
|
||||||
|
# now the extension is not listed, its latest_version doesn't have to be the latest
|
||||||
|
# listed version
|
||||||
|
self.assertEqual(new_version, extension.latest_version)
|
||||||
|
@ -34,7 +34,7 @@ class DeleteTest(TestCase):
|
|||||||
extension = version.extension
|
extension = version.extension
|
||||||
version_file = version.file
|
version_file = version.file
|
||||||
self.assertEqual(version_file.get_status_display(), 'Awaiting Review')
|
self.assertEqual(version_file.get_status_display(), 'Awaiting Review')
|
||||||
self.assertEqual(extension.get_status_display(), 'Incomplete')
|
self.assertEqual(extension.get_status_display(), 'Draft')
|
||||||
self.assertFalse(extension.is_listed)
|
self.assertFalse(extension.is_listed)
|
||||||
self.assertEqual(extension.cannot_be_deleted_reasons, [])
|
self.assertEqual(extension.cannot_be_deleted_reasons, [])
|
||||||
preview_file = extension.previews.first()
|
preview_file = extension.previews.first()
|
||||||
@ -111,6 +111,7 @@ class DeleteTest(TestCase):
|
|||||||
'featured_image',
|
'featured_image',
|
||||||
'icon',
|
'icon',
|
||||||
'is_listed',
|
'is_listed',
|
||||||
|
'latest_version',
|
||||||
'name',
|
'name',
|
||||||
'pk',
|
'pk',
|
||||||
'slug',
|
'slug',
|
||||||
@ -151,7 +152,7 @@ class DeleteTest(TestCase):
|
|||||||
self.assertFalse(version.is_listed)
|
self.assertFalse(version.is_listed)
|
||||||
extension = version.extension
|
extension = version.extension
|
||||||
self.assertFalse(extension.is_listed)
|
self.assertFalse(extension.is_listed)
|
||||||
self.assertEqual(extension.get_status_display(), 'Incomplete')
|
self.assertEqual(extension.get_status_display(), 'Draft')
|
||||||
|
|
||||||
self.assertEqual(version.cannot_be_deleted_reasons, ['version_has_ratings'])
|
self.assertEqual(version.cannot_be_deleted_reasons, ['version_has_ratings'])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
@ -698,6 +698,7 @@ class VersionPermissionsTest(CreateFileTest):
|
|||||||
_file.status = File.STATUSES.APPROVED
|
_file.status = File.STATUSES.APPROVED
|
||||||
_file.save()
|
_file.save()
|
||||||
|
|
||||||
|
extension.refresh_from_db()
|
||||||
self.assertNotEqual(version_original, extension.latest_version)
|
self.assertNotEqual(version_original, extension.latest_version)
|
||||||
self.assertEqual(Extension.objects.count(), 1)
|
self.assertEqual(Extension.objects.count(), 1)
|
||||||
self.assertEqual(Version.objects.count(), 2)
|
self.assertEqual(Version.objects.count(), 2)
|
||||||
|
@ -20,7 +20,7 @@ class ExtensionTest(TestCase):
|
|||||||
extension__description='Extension description',
|
extension__description='Extension description',
|
||||||
extension__website='https://example.com/',
|
extension__website='https://example.com/',
|
||||||
extension__name='Extension name',
|
extension__name='Extension name',
|
||||||
extension__status=Extension.STATUSES.INCOMPLETE,
|
extension__status=Extension.STATUSES.DRAFT,
|
||||||
extension__support='https://example.com/',
|
extension__support='https://example.com/',
|
||||||
file__metadata={
|
file__metadata={
|
||||||
'name': 'Extension name',
|
'name': 'Extension name',
|
||||||
@ -94,7 +94,7 @@ class VersionTest(TestCase):
|
|||||||
extension__description='Extension description',
|
extension__description='Extension description',
|
||||||
extension__website='https://example.com/',
|
extension__website='https://example.com/',
|
||||||
extension__name='Extension name',
|
extension__name='Extension name',
|
||||||
extension__status=Extension.STATUSES.INCOMPLETE,
|
extension__status=Extension.STATUSES.DRAFT,
|
||||||
extension__support='https://example.com/',
|
extension__support='https://example.com/',
|
||||||
)
|
)
|
||||||
self.assertEqual(entries_for(self.version).count(), 0)
|
self.assertEqual(entries_for(self.version).count(), 0)
|
||||||
@ -142,7 +142,7 @@ class UpdateMetadataTest(TestCase):
|
|||||||
self.first_version = create_version(
|
self.first_version = create_version(
|
||||||
extension__description='Extension description',
|
extension__description='Extension description',
|
||||||
extension__name='name',
|
extension__name='name',
|
||||||
extension__status=Extension.STATUSES.INCOMPLETE,
|
extension__status=Extension.STATUSES.DRAFT,
|
||||||
extension__support='https://example.com/',
|
extension__support='https://example.com/',
|
||||||
extension__website='https://example.com/',
|
extension__website='https://example.com/',
|
||||||
file__metadata={
|
file__metadata={
|
||||||
@ -188,7 +188,7 @@ class UpdateMetadataTest(TestCase):
|
|||||||
extension__description='Extension description',
|
extension__description='Extension description',
|
||||||
extension__extension_id='lalalala',
|
extension__extension_id='lalalala',
|
||||||
extension__name='name',
|
extension__name='name',
|
||||||
extension__status=Extension.STATUSES.INCOMPLETE,
|
extension__status=Extension.STATUSES.DRAFT,
|
||||||
extension__support='https://example.com/',
|
extension__support='https://example.com/',
|
||||||
extension__website='https://example.com/',
|
extension__website='https://example.com/',
|
||||||
file__metadata={
|
file__metadata={
|
||||||
|
@ -571,7 +571,7 @@ class DraftsWarningTest(TestCase):
|
|||||||
def test_page_contains_warning(self):
|
def test_page_contains_warning(self):
|
||||||
version = create_version(extension__extension_id='draft_warning')
|
version = create_version(extension__extension_id='draft_warning')
|
||||||
extension = version.extension
|
extension = version.extension
|
||||||
self.assertEqual(extension.status, Extension.STATUSES.INCOMPLETE)
|
self.assertEqual(extension.status, Extension.STATUSES.DRAFT)
|
||||||
self.client.force_login(extension.authors.all()[0])
|
self.client.force_login(extension.authors.all()[0])
|
||||||
response = self.client.get(reverse_lazy('extensions:submit'))
|
response = self.client.get(reverse_lazy('extensions:submit'))
|
||||||
self.assertContains(response, extension.get_draft_url())
|
self.assertContains(response, extension.get_draft_url())
|
||||||
|
@ -5,10 +5,13 @@ from django.test import TestCase
|
|||||||
|
|
||||||
from common.tests.factories.extensions import create_approved_version, create_version
|
from common.tests.factories.extensions import create_approved_version, create_version
|
||||||
from common.tests.factories.files import FileFactory, ImageFactory
|
from common.tests.factories.files import FileFactory, ImageFactory
|
||||||
|
from common.tests.factories.teams import TeamFactory
|
||||||
|
from common.tests.factories.users import UserFactory
|
||||||
from common.tests.utils import _get_all_form_errors, CheckFilePropertiesMixin
|
from common.tests.utils import _get_all_form_errors, CheckFilePropertiesMixin
|
||||||
from extensions.models import Extension
|
from extensions.models import Extension
|
||||||
from files.models import File
|
from files.models import File
|
||||||
from reviewers.models import ApprovalActivity
|
from reviewers.models import ApprovalActivity
|
||||||
|
from teams.models import TeamsUsers
|
||||||
|
|
||||||
TEST_FILES_DIR = Path(__file__).resolve().parent / 'files'
|
TEST_FILES_DIR = Path(__file__).resolve().parent / 'files'
|
||||||
POST_DATA = {
|
POST_DATA = {
|
||||||
@ -218,7 +221,6 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase):
|
|||||||
},
|
},
|
||||||
_get_all_form_errors(response),
|
_get_all_form_errors(response),
|
||||||
)
|
)
|
||||||
self.assertFalse("TODO: It should also list previews as required")
|
|
||||||
|
|
||||||
def test_post_upload_validation_error_duplicate_images(self):
|
def test_post_upload_validation_error_duplicate_images(self):
|
||||||
extension = create_approved_version().extension
|
extension = create_approved_version().extension
|
||||||
@ -492,10 +494,135 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response2.status_code, 302)
|
self.assertEqual(response2.status_code, 302)
|
||||||
extension.refresh_from_db()
|
extension.refresh_from_db()
|
||||||
self.assertEqual(extension.status, extension.STATUSES.INCOMPLETE)
|
self.assertEqual(extension.status, extension.STATUSES.DRAFT)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
extension.review_activity.last().type, ApprovalActivity.ActivityType.AWAITING_CHANGES
|
extension.review_activity.last().type, ApprovalActivity.ActivityType.AWAITING_CHANGES
|
||||||
)
|
)
|
||||||
response3 = self.client.get(url)
|
response3 = self.client.get(url)
|
||||||
self.assertEqual(response3.status_code, 302)
|
self.assertEqual(response3.status_code, 302)
|
||||||
self.assertEqual(response3['Location'], extension.get_draft_url())
|
self.assertEqual(response3['Location'], extension.get_draft_url())
|
||||||
|
|
||||||
|
def test_team_field_in_draft_form(self):
|
||||||
|
version = create_version(
|
||||||
|
extension__status=Extension.STATUSES.DRAFT,
|
||||||
|
)
|
||||||
|
extension = version.extension
|
||||||
|
author = extension.authors.first()
|
||||||
|
self.client.force_login(author)
|
||||||
|
|
||||||
|
team = TeamFactory(slug='test-team')
|
||||||
|
TeamsUsers(team=team, user=author).save()
|
||||||
|
|
||||||
|
url = extension.get_draft_url()
|
||||||
|
response = self.client.get(url)
|
||||||
|
# a simple check that we have an input with the team option available
|
||||||
|
self.assertContains(response, 'value="test-team"')
|
||||||
|
|
||||||
|
# post the form to save the team field
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
**POST_DATA,
|
||||||
|
'team': 'test-team',
|
||||||
|
'save_draft': '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302, _get_all_form_errors(response))
|
||||||
|
extension.refresh_from_db()
|
||||||
|
self.assertEqual(extension.team.slug, 'test-team')
|
||||||
|
|
||||||
|
# can't assign an invalid team slug
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
**POST_DATA,
|
||||||
|
'team': '-',
|
||||||
|
'save_draft': '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200, _get_all_form_errors(response))
|
||||||
|
|
||||||
|
# add another team member, they shouldn't see the field
|
||||||
|
user = UserFactory()
|
||||||
|
team2 = TeamFactory(slug='test-team2')
|
||||||
|
TeamsUsers(team=team, user=user).save()
|
||||||
|
TeamsUsers(team=team2, user=user).save()
|
||||||
|
self.client.force_login(user)
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertNotContains(response, 'value="test-team"')
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
**POST_DATA,
|
||||||
|
'team': 'test-team2',
|
||||||
|
'save_draft': '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# the field is ignored: no error expected and the team wasn't updated
|
||||||
|
self.assertEqual(response.status_code, 302, _get_all_form_errors(response))
|
||||||
|
extension.refresh_from_db()
|
||||||
|
self.assertEqual(extension.team.slug, 'test-team')
|
||||||
|
|
||||||
|
def test_team_field_in_update_form(self):
|
||||||
|
"""This test is a copy-paste of the one above, only status, url and form data differ."""
|
||||||
|
version = create_version(
|
||||||
|
extension__status=Extension.STATUSES.APPROVED,
|
||||||
|
)
|
||||||
|
extension = version.extension
|
||||||
|
author = extension.authors.first()
|
||||||
|
self.client.force_login(author)
|
||||||
|
|
||||||
|
team = TeamFactory(slug='test-team')
|
||||||
|
TeamsUsers(team=team, user=author).save()
|
||||||
|
|
||||||
|
url = extension.get_manage_url()
|
||||||
|
response = self.client.get(url)
|
||||||
|
# a simple check that we have an input with the team option available
|
||||||
|
self.assertContains(response, 'value="test-team"')
|
||||||
|
|
||||||
|
# post the form to save the team field
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
**POST_DATA,
|
||||||
|
'team': 'test-team',
|
||||||
|
'save': '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302, _get_all_form_errors(response))
|
||||||
|
extension.refresh_from_db()
|
||||||
|
self.assertEqual(extension.team.slug, 'test-team')
|
||||||
|
|
||||||
|
# can't assign an invalid team slug
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
**POST_DATA,
|
||||||
|
'team': '-',
|
||||||
|
'save': '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200, _get_all_form_errors(response))
|
||||||
|
|
||||||
|
# add another team member, they shouldn't see the field
|
||||||
|
user = UserFactory()
|
||||||
|
team2 = TeamFactory(slug='test-team2')
|
||||||
|
TeamsUsers(team=team, user=user).save()
|
||||||
|
TeamsUsers(team=team2, user=user).save()
|
||||||
|
self.client.force_login(user)
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertNotContains(response, 'value="test-team"')
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
**POST_DATA,
|
||||||
|
'team': 'test-team2',
|
||||||
|
'save': '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# the field is ignored: no error expected and the team wasn't updated
|
||||||
|
self.assertEqual(response.status_code, 302, _get_all_form_errors(response))
|
||||||
|
extension.refresh_from_db()
|
||||||
|
self.assertEqual(extension.team.slug, 'test-team')
|
||||||
|
@ -4,10 +4,11 @@ from django.test import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from common.tests.factories.extensions import create_version, create_approved_version
|
from common.tests.factories.extensions import create_version, create_approved_version
|
||||||
|
from common.tests.factories.teams import TeamFactory
|
||||||
from common.tests.factories.users import UserFactory
|
from common.tests.factories.users import UserFactory
|
||||||
from extensions.models import Extension, Version
|
from extensions.models import Extension, Version
|
||||||
from files.models import File
|
from files.models import File
|
||||||
from teams.models import Team
|
from teams.models import Team, TeamsUsers
|
||||||
|
|
||||||
|
|
||||||
def _create_extension():
|
def _create_extension():
|
||||||
@ -18,7 +19,7 @@ def _create_extension():
|
|||||||
extension__description='**Description in bold**',
|
extension__description='**Description in bold**',
|
||||||
extension__support='https://example.com/issues/',
|
extension__support='https://example.com/issues/',
|
||||||
extension__website='https://example.com/',
|
extension__website='https://example.com/',
|
||||||
extension__status=Extension.STATUSES.INCOMPLETE,
|
extension__status=Extension.STATUSES.DRAFT,
|
||||||
extension__average_score=2.5,
|
extension__average_score=2.5,
|
||||||
file__metadata={
|
file__metadata={
|
||||||
'name': 'Test Add-on',
|
'name': 'Test Add-on',
|
||||||
@ -76,10 +77,12 @@ class PublicViewsTest(_BaseTestCase):
|
|||||||
self.assertIn('license', v)
|
self.assertIn('license', v)
|
||||||
self.assertIn('website', v)
|
self.assertIn('website', v)
|
||||||
self.assertIn('schema_version', v)
|
self.assertIn('schema_version', v)
|
||||||
|
# Blender expects urls in HTML anchors to end with .zip to handle drag&drop
|
||||||
|
self.assertEqual(v['archive_url'][-4:], '.zip')
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def test_home_page_view_api(self):
|
def test_api(self):
|
||||||
url = '/'
|
url = '/api/v1/extensions/'
|
||||||
self._test_format_json(url, HTTP_ACCEPT='application/json')
|
self._test_format_json(url, HTTP_ACCEPT='application/json')
|
||||||
|
|
||||||
def test_home_page_view_html(self):
|
def test_home_page_view_html(self):
|
||||||
@ -190,7 +193,7 @@ class ExtensionDetailViewTest(_BaseTestCase):
|
|||||||
|
|
||||||
self._check_detail_page(response, extension)
|
self._check_detail_page(response, extension)
|
||||||
|
|
||||||
def test_can_view_unlisted_extension_if_maintaner(self):
|
def test_can_view_unlisted_extension_if_maintainer(self):
|
||||||
extension = _create_extension()
|
extension = _create_extension()
|
||||||
|
|
||||||
self.client.force_login(extension.authors.first())
|
self.client.force_login(extension.authors.first())
|
||||||
@ -198,6 +201,20 @@ class ExtensionDetailViewTest(_BaseTestCase):
|
|||||||
|
|
||||||
self._check_detail_page(response, extension)
|
self._check_detail_page(response, extension)
|
||||||
|
|
||||||
|
def test_can_view_unlisted_extension_if_team_member(self):
|
||||||
|
extension = _create_extension()
|
||||||
|
|
||||||
|
team = TeamFactory(slug='test-team')
|
||||||
|
user = UserFactory()
|
||||||
|
TeamsUsers(team=team, user=user).save()
|
||||||
|
extension.team = team
|
||||||
|
extension.save()
|
||||||
|
|
||||||
|
self.client.force_login(user)
|
||||||
|
response = self.client.get(extension.get_manage_url())
|
||||||
|
|
||||||
|
self._check_detail_page(response, extension)
|
||||||
|
|
||||||
def test_can_view_publicly_listed_extension_anonymously(self):
|
def test_can_view_publicly_listed_extension_anonymously(self):
|
||||||
extension = _create_extension()
|
extension = _create_extension()
|
||||||
extension.approve()
|
extension.approve()
|
||||||
@ -245,7 +262,7 @@ class ExtensionManageViewTest(_BaseTestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
def test_can_view_manage_extension_page_if_maintaner(self):
|
def test_can_view_manage_extension_page_if_maintainer(self):
|
||||||
extension = _create_extension()
|
extension = _create_extension()
|
||||||
extension.approve()
|
extension.approve()
|
||||||
|
|
||||||
@ -254,6 +271,20 @@ class ExtensionManageViewTest(_BaseTestCase):
|
|||||||
|
|
||||||
self._check_manage_page(response, extension)
|
self._check_manage_page(response, extension)
|
||||||
|
|
||||||
|
def test_can_view_manage_extension_page_if_team_member(self):
|
||||||
|
extension = _create_extension()
|
||||||
|
extension.approve()
|
||||||
|
team = TeamFactory(slug='test-team')
|
||||||
|
user = UserFactory()
|
||||||
|
TeamsUsers(team=team, user=user).save()
|
||||||
|
extension.team = team
|
||||||
|
extension.save()
|
||||||
|
|
||||||
|
self.client.force_login(user)
|
||||||
|
response = self.client.get(extension.get_manage_url())
|
||||||
|
|
||||||
|
self._check_manage_page(response, extension)
|
||||||
|
|
||||||
|
|
||||||
class ListedExtensionsTest(_BaseTestCase):
|
class ListedExtensionsTest(_BaseTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@ -267,7 +298,7 @@ class ListedExtensionsTest(_BaseTestCase):
|
|||||||
self.assertEqual(self._listed_extensions_count(), 1)
|
self.assertEqual(self._listed_extensions_count(), 1)
|
||||||
|
|
||||||
def _listed_extensions_count(self):
|
def _listed_extensions_count(self):
|
||||||
response = self.client.get('/?format=json', HTTP_ACCEPT='application/json')
|
response = self.client.get('/api/v1/extensions/', HTTP_ACCEPT='application/json')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response['Content-Type'], 'application/json')
|
self.assertEqual(response['Content-Type'], 'application/json')
|
||||||
|
|
||||||
@ -354,3 +385,17 @@ class UpdateVersionViewTest(_BaseTestCase):
|
|||||||
self.assertEqual(response2.status_code, 302)
|
self.assertEqual(response2.status_code, 302)
|
||||||
version.refresh_from_db()
|
version.refresh_from_db()
|
||||||
self.assertEqual(version.blender_version_max, '4.2.0')
|
self.assertEqual(version.blender_version_max, '4.2.0')
|
||||||
|
|
||||||
|
|
||||||
|
class MyExtensionsTest(_BaseTestCase):
|
||||||
|
def test_team_members_see_extensions_in_my_extensions(self):
|
||||||
|
extension = _create_extension()
|
||||||
|
team = TeamFactory(slug='test-team')
|
||||||
|
user = UserFactory()
|
||||||
|
TeamsUsers(team=team, user=user).save()
|
||||||
|
extension.team = team
|
||||||
|
extension.save()
|
||||||
|
|
||||||
|
self.client.force_login(user)
|
||||||
|
response = self.client.get(reverse('extensions:manage-list'))
|
||||||
|
self.assertContains(response, extension.name)
|
||||||
|
@ -16,6 +16,11 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
# API
|
# API
|
||||||
path('api/v1/extensions/', api.ExtensionsAPIView.as_view(), name='api'),
|
path('api/v1/extensions/', api.ExtensionsAPIView.as_view(), name='api'),
|
||||||
|
path(
|
||||||
|
'api/v1/extensions/<str:extension_id>/versions/new/',
|
||||||
|
api.UploadExtensionVersionView.as_view(),
|
||||||
|
name='upload-extension-version',
|
||||||
|
),
|
||||||
# Public pages
|
# Public pages
|
||||||
path('', public.HomeView.as_view(), name='home'),
|
path('', public.HomeView.as_view(), name='home'),
|
||||||
path('search/', public.SearchView.as_view(), name='search'),
|
path('search/', public.SearchView.as_view(), name='search'),
|
||||||
@ -74,7 +79,7 @@ urlpatterns = [
|
|||||||
name='version-update',
|
name='version-update',
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
'<slug:slug>/<version>/download/',
|
'<slug:slug>/<version>/download/<filename>',
|
||||||
public.extension_version_download,
|
public.extension_version_download,
|
||||||
name='version-download',
|
name='version-download',
|
||||||
),
|
),
|
||||||
|
@ -1,14 +1,19 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers, status
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
from common.compare import is_in_version_range, version
|
from common.compare import is_in_version_range, version
|
||||||
from extensions.models import Extension, Platform
|
from extensions.models import Extension, Platform, Version
|
||||||
from extensions.utils import clean_json_dictionary_from_optional_fields
|
from extensions.utils import clean_json_dictionary_from_optional_fields
|
||||||
|
from extensions.views.manage import NewVersionView
|
||||||
|
from files.forms import FileFormSkipAgreed
|
||||||
|
|
||||||
|
|
||||||
from constants.base import (
|
from constants.base import (
|
||||||
@ -104,6 +109,7 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ExtensionsAPIView(APIView):
|
class ExtensionsAPIView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
serializer_class = ListedExtensionsSerializer
|
serializer_class = ListedExtensionsSerializer
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
@ -149,3 +155,76 @@ class ExtensionsAPIView(APIView):
|
|||||||
'version': 'v1',
|
'version': 'v1',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionVersionSerializer(serializers.Serializer):
|
||||||
|
version_file = serializers.FileField()
|
||||||
|
release_notes = serializers.CharField(max_length=1024, required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class UploadExtensionVersionView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=ExtensionVersionSerializer,
|
||||||
|
responses={201: 'Extension version uploaded successfully!'},
|
||||||
|
)
|
||||||
|
def post(self, request, extension_id, *args, **kwargs):
|
||||||
|
serializer = ExtensionVersionSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
version_file = serializer.validated_data['version_file']
|
||||||
|
release_notes = serializer.validated_data.get('release_notes', '')
|
||||||
|
|
||||||
|
extension = Extension.objects.filter(extension_id=extension_id).first()
|
||||||
|
if not extension:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'message': f'Extension "{extension_id}" not found',
|
||||||
|
},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not extension.has_maintainer(user):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'message': f'Extension "{extension_id}" not maintained by user "{user}"',
|
||||||
|
},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a NewVersionView instance to handle file creation
|
||||||
|
new_version_view = NewVersionView(request=request, extension=extension)
|
||||||
|
|
||||||
|
# Pass the version_file to the form
|
||||||
|
form = new_version_view.get_form(FileFormSkipAgreed)
|
||||||
|
form.fields['source'].initial = version_file
|
||||||
|
|
||||||
|
if not form.is_valid():
|
||||||
|
return Response({'message': form.errors}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Create the file instance
|
||||||
|
file_instance = form.save(commit=False)
|
||||||
|
file_instance.user = user
|
||||||
|
file_instance.save()
|
||||||
|
|
||||||
|
# Create the version from the file
|
||||||
|
version = Version.objects.update_or_create(
|
||||||
|
extension=extension,
|
||||||
|
file=file_instance,
|
||||||
|
release_notes=release_notes,
|
||||||
|
**file_instance.parsed_version_fields,
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'message': 'Extension version uploaded successfully!',
|
||||||
|
'extension_id': extension_id,
|
||||||
|
'version_file': version_file.name,
|
||||||
|
'release_notes': version.release_notes,
|
||||||
|
},
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
@ -99,7 +99,16 @@ class ManageListView(LoginRequiredMixin, ListView):
|
|||||||
template_name = 'extensions/manage/list.html'
|
template_name = 'extensions/manage/list.html'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Extension.objects.authored_by(user_id=self.request.user.pk)
|
return Extension.objects.authored_by(self.request.user).prefetch_related(
|
||||||
|
'authors',
|
||||||
|
'preview_set',
|
||||||
|
'preview_set__file',
|
||||||
|
'ratings',
|
||||||
|
'team',
|
||||||
|
'versions',
|
||||||
|
'versions__file',
|
||||||
|
'versions__tags',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UpdateExtensionView(
|
class UpdateExtensionView(
|
||||||
@ -121,7 +130,7 @@ class UpdateExtensionView(
|
|||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
extension = self.extension
|
extension = self.extension
|
||||||
if extension.status == extension.STATUSES.INCOMPLETE:
|
if extension.status == extension.STATUSES.DRAFT:
|
||||||
return redirect('extensions:draft', slug=extension.slug, type_slug=extension.type_slug)
|
return redirect('extensions:draft', slug=extension.slug, type_slug=extension.type_slug)
|
||||||
else:
|
else:
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
@ -330,12 +339,12 @@ class DraftExtensionView(
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def success_message(self) -> str:
|
def success_message(self) -> str:
|
||||||
if self.extension.status == Extension.STATUSES.INCOMPLETE:
|
if self.extension.status == Extension.STATUSES.DRAFT:
|
||||||
return "Updated successfully"
|
return "Updated successfully"
|
||||||
return "Submitted to the Approval Queue"
|
return "Submitted to the Approval Queue"
|
||||||
|
|
||||||
def test_func(self) -> bool:
|
def test_func(self) -> bool:
|
||||||
return self.extension.status == Extension.STATUSES.INCOMPLETE
|
return self.extension.status == Extension.STATUSES.DRAFT
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
form_kwargs = super().get_form_kwargs()
|
form_kwargs = super().get_form_kwargs()
|
||||||
|
@ -23,7 +23,7 @@ class ExtensionQuerysetMixin:
|
|||||||
if self.request.user.is_staff:
|
if self.request.user.is_staff:
|
||||||
return Extension.objects.all()
|
return Extension.objects.all()
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
return Extension.objects.listed_or_authored_by(user_id=self.request.user.pk)
|
return Extension.objects.listed_or_authored_by(self.request.user)
|
||||||
return Extension.objects.listed
|
return Extension.objects.listed
|
||||||
|
|
||||||
|
|
||||||
@ -32,7 +32,7 @@ class MaintainedExtensionMixin:
|
|||||||
|
|
||||||
def dispatch(self, *args, **kwargs):
|
def dispatch(self, *args, **kwargs):
|
||||||
self.extension = get_object_or_404(
|
self.extension = get_object_or_404(
|
||||||
Extension.objects.authored_by(user_id=self.request.user.pk),
|
Extension.objects.authored_by(self.request.user),
|
||||||
slug=self.kwargs['slug'],
|
slug=self.kwargs['slug'],
|
||||||
)
|
)
|
||||||
return super().dispatch(*args, **kwargs)
|
return super().dispatch(*args, **kwargs)
|
||||||
|
@ -16,8 +16,6 @@ from constants.base import (
|
|||||||
from stats.models import ExtensionDownload, VersionDownload
|
from stats.models import ExtensionDownload, VersionDownload
|
||||||
import teams.models
|
import teams.models
|
||||||
|
|
||||||
from .api import ExtensionsAPIView
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -32,14 +30,6 @@ class HomeView(ListedExtensionsView):
|
|||||||
paginate_by = 16
|
paginate_by = 16
|
||||||
template_name = 'extensions/home.html'
|
template_name = 'extensions/home.html'
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
|
||||||
"""Return the API view if requesting a JSON."""
|
|
||||||
if request.headers.get('Accept') == 'application/json':
|
|
||||||
api_view = ExtensionsAPIView.as_view()
|
|
||||||
return api_view(request, *args, **kwargs)
|
|
||||||
else:
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
q = (
|
q = (
|
||||||
@ -47,12 +37,12 @@ class HomeView(ListedExtensionsView):
|
|||||||
.get_queryset()
|
.get_queryset()
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
'authors',
|
'authors',
|
||||||
|
'latest_version__file',
|
||||||
|
'latest_version__tags',
|
||||||
'preview_set',
|
'preview_set',
|
||||||
'preview_set__file',
|
'preview_set__file',
|
||||||
'ratings',
|
'ratings',
|
||||||
'versions',
|
'team',
|
||||||
'versions__file',
|
|
||||||
'versions__tags',
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
context['addons'] = q.filter(type=EXTENSION_TYPE_CHOICES.BPY)[:8]
|
context['addons'] = q.filter(type=EXTENSION_TYPE_CHOICES.BPY)[:8]
|
||||||
@ -60,12 +50,17 @@ class HomeView(ListedExtensionsView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
def extension_version_download(request, type_slug, slug, version):
|
def extension_version_download(request, type_slug, slug, version, filename):
|
||||||
"""Download an extension version and count downloads."""
|
"""Download an extension version and count downloads.
|
||||||
|
|
||||||
|
The `filename` parameter is used to pass a file name ending with `.zip`.
|
||||||
|
This is a convention Blender uses to initiate an extension installation on an HTML anchor
|
||||||
|
drag&drop.
|
||||||
|
"""
|
||||||
extension_version = get_object_or_404(Version, extension__slug=slug, version=version)
|
extension_version = get_object_or_404(Version, extension__slug=slug, version=version)
|
||||||
ExtensionDownload.create_from_request(request, object_id=extension_version.extension_id)
|
ExtensionDownload.create_from_request(request, object_id=extension_version.extension_id)
|
||||||
VersionDownload.create_from_request(request, object_id=extension_version.pk)
|
VersionDownload.create_from_request(request, object_id=extension_version.pk)
|
||||||
return redirect(extension_version.downloadable_signed_url)
|
return redirect(extension_version.downloadable_signed_url + f'?filename={filename}')
|
||||||
|
|
||||||
|
|
||||||
class SearchView(ListedExtensionsView):
|
class SearchView(ListedExtensionsView):
|
||||||
@ -81,7 +76,9 @@ class SearchView(ListedExtensionsView):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
if self.kwargs.get('tag_slug'):
|
if self.kwargs.get('tag_slug'):
|
||||||
queryset = queryset.filter(versions__tags__slug=self.kwargs['tag_slug']).distinct()
|
queryset = queryset.filter(
|
||||||
|
latest_version__tags__slug=self.kwargs['tag_slug']
|
||||||
|
).distinct()
|
||||||
if self.kwargs.get('team_slug'):
|
if self.kwargs.get('team_slug'):
|
||||||
queryset = queryset.filter(team__slug=self.kwargs['team_slug'])
|
queryset = queryset.filter(team__slug=self.kwargs['team_slug'])
|
||||||
if self.kwargs.get('user_id'):
|
if self.kwargs.get('user_id'):
|
||||||
@ -99,17 +96,17 @@ class SearchView(ListedExtensionsView):
|
|||||||
Q(slug__icontains=token)
|
Q(slug__icontains=token)
|
||||||
| Q(name__icontains=token)
|
| Q(name__icontains=token)
|
||||||
| Q(description__icontains=token)
|
| Q(description__icontains=token)
|
||||||
| Q(versions__tags__name__icontains=token)
|
| Q(latest_version__tags__name__icontains=token)
|
||||||
)
|
)
|
||||||
queryset = queryset.filter(search_query).distinct()
|
queryset = queryset.filter(search_query).distinct()
|
||||||
return queryset.prefetch_related(
|
return queryset.prefetch_related(
|
||||||
'authors',
|
'authors',
|
||||||
|
'latest_version__file',
|
||||||
|
'latest_version__tags',
|
||||||
'preview_set',
|
'preview_set',
|
||||||
'preview_set__file',
|
'preview_set__file',
|
||||||
'ratings',
|
'ratings',
|
||||||
'versions',
|
'team',
|
||||||
'versions__file',
|
|
||||||
'versions__tags',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
@ -18,8 +18,8 @@ class UploadFileView(LoginRequiredMixin, CreateView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
drafts = Extension.objects.authored_by(user_id=self.request.user.pk).filter(
|
drafts = Extension.objects.authored_by(self.request.user).filter(
|
||||||
status=Extension.STATUSES.INCOMPLETE
|
status=Extension.STATUSES.DRAFT
|
||||||
)
|
)
|
||||||
context['drafts'] = drafts
|
context['drafts'] = drafts
|
||||||
return context
|
return context
|
||||||
@ -41,7 +41,7 @@ class UploadFileView(LoginRequiredMixin, CreateView):
|
|||||||
if parsed_extension_fields:
|
if parsed_extension_fields:
|
||||||
# Try to look up extension by the same author and file info
|
# Try to look up extension by the same author and file info
|
||||||
extension = (
|
extension = (
|
||||||
Extension.objects.authored_by(user_id=self.request.user.pk)
|
Extension.objects.authored_by(self.request.user)
|
||||||
.filter(type=self.file.type, **parsed_extension_fields)
|
.filter(type=self.file.type, **parsed_extension_fields)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
@ -167,6 +167,16 @@ class FileForm(forms.ModelForm):
|
|||||||
return self.cleaned_data
|
return self.cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class FileFormSkipAgreed(FileForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['agreed_with_terms'].required = False
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
self.cleaned_data['agreed_with_terms'] = True
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
|
||||||
class BaseMediaFileForm(forms.ModelForm):
|
class BaseMediaFileForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = files.models.File
|
model = files.models.File
|
||||||
|
@ -29,7 +29,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="notifications-item-nav">
|
<td class="notifications-item-nav">
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="btn btn-link dropdown-toggle js-dropdown-toggle active" data-toggle-menu-id="js-notifications-item-nav-{{ notification.id }}">
|
<button class="btn btn-link dropdown-toggle js-dropdown-toggle px-0" data-toggle-menu-id="js-notifications-item-nav-{{ notification.id }}">
|
||||||
<i class="i-more-vertical"></i>
|
<i class="i-more-vertical"></i>
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-right js-dropdown-menu" id="js-notifications-item-nav-{{ notification.id }}">
|
<ul class="dropdown-menu dropdown-menu-right js-dropdown-menu" id="js-notifications-item-nav-{{ notification.id }}">
|
||||||
|
@ -4,7 +4,20 @@
|
|||||||
roles: [common]
|
roles: [common]
|
||||||
vars:
|
vars:
|
||||||
playbook_type: deploy
|
playbook_type: deploy
|
||||||
|
lock_file_path: /tmp/deploy-{{project_slug}}.lock
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
- import_tasks: tasks/pull.yaml
|
- import_tasks: tasks/pull.yaml
|
||||||
- import_tasks: tasks/deploy.yaml
|
- import_tasks: tasks/deploy.yaml
|
||||||
|
|
||||||
|
pre_tasks:
|
||||||
|
- stat: path={{lock_file_path}}
|
||||||
|
register: lock_file
|
||||||
|
|
||||||
|
- fail: msg="Another deploy was already started by {{lock_file.stat.pw_name}} {{((ansible_date_time.epoch|float - lock_file.stat.mtime) / 60)|int}}min ago.\nAdd '-e override_lock=true' to override if the deploy was abandoned."
|
||||||
|
when: lock_file.stat.exists|bool and override_lock is undefined
|
||||||
|
|
||||||
|
- copy: dest={{lock_file_path}} content="{{ansible_user_id}} locked at {{now(fmt='%Y-%m-%d %H:%M:%S')}}"
|
||||||
|
|
||||||
|
post_tasks:
|
||||||
|
- file: path={{lock_file_path}} state=absent
|
||||||
|
@ -33,6 +33,10 @@ server {
|
|||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
alias {{ dir.media | regex_replace('\\/*$', '/') }};
|
alias {{ dir.media | regex_replace('\\/*$', '/') }};
|
||||||
|
if ($arg_filename) {
|
||||||
|
add_header Content-Disposition "attachment; filename=$arg_filename";
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
location /static/ {
|
location /static/ {
|
||||||
alias {{ dir.static | regex_replace('\\/*$', '/') }};
|
alias {{ dir.static | regex_replace('\\/*$', '/') }};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{% load i18n extensions %}
|
{% load i18n extensions %}
|
||||||
|
|
||||||
{% has_maintainer extension as is_maintainer %}
|
{% has_maintainer extension as is_maintainer %}
|
||||||
<div class="card p-3 mb-3 ratings-summary">
|
<div class="card p-3 mb-3 mt-2 mt-lg-0 ratings-summary">
|
||||||
{% if extension.text_ratings_count %}
|
{% if extension.text_ratings_count %}
|
||||||
<div class="summary-container">
|
<div class="summary-container">
|
||||||
<div class="summary-value">
|
<div class="summary-value">
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-8">
|
<div class="col-md-8">
|
||||||
<section>
|
<section>
|
||||||
{% if my_rating and not my_rating.is_listed %}
|
{% if my_rating and not my_rating.is_listed %}
|
||||||
{% include "ratings/components/rating.html" with rating=my_rating classes="mb-2" %}
|
{% include "ratings/components/rating.html" with rating=my_rating classes="mb-2" %}
|
||||||
@ -39,7 +39,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-4">
|
<div class="col-md-4">
|
||||||
{% include "ratings/components/summary.html" %}
|
{% include "ratings/components/summary.html" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
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,14 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.template import loader
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import common.help_texts
|
import common.help_texts
|
||||||
from extensions.models import Extension
|
|
||||||
from common.model_mixins import CreatedModifiedMixin, RecordDeletionMixin
|
from common.model_mixins import CreatedModifiedMixin, RecordDeletionMixin
|
||||||
from utils import absolutify, send_mail
|
|
||||||
|
|
||||||
from constants.base import EXTENSION_TYPE_CHOICES
|
from constants.base import EXTENSION_TYPE_CHOICES
|
||||||
from constants.reviewers import CANNED_RESPONSE_CATEGORY_CHOICES
|
from constants.reviewers import CANNED_RESPONSE_CATEGORY_CHOICES
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
logger = logging.getLogger('users')
|
|
||||||
|
|
||||||
|
|
||||||
class CannedResponse(CreatedModifiedMixin, models.Model):
|
class CannedResponse(CreatedModifiedMixin, models.Model):
|
||||||
@ -35,45 +27,6 @@ class CannedResponse(CreatedModifiedMixin, models.Model):
|
|||||||
return str(self.name)
|
return str(self.name)
|
||||||
|
|
||||||
|
|
||||||
class ReviewerSubscription(CreatedModifiedMixin, models.Model):
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
||||||
extension = models.ForeignKey(Extension, on_delete=models.CASCADE)
|
|
||||||
|
|
||||||
def send_notification(self, version):
|
|
||||||
logger.info(
|
|
||||||
'Sending extension update notice to %s for %s' % (self.user.email, self.extension.pk)
|
|
||||||
)
|
|
||||||
|
|
||||||
listing_url = absolutify(
|
|
||||||
reverse('extensions.detail', args=[self.extension.pk], add_prefix=False)
|
|
||||||
)
|
|
||||||
context = {
|
|
||||||
'name': self.extension.name,
|
|
||||||
'url': listing_url,
|
|
||||||
'number': version.version,
|
|
||||||
'review': absolutify(
|
|
||||||
reverse(
|
|
||||||
'reviewers.review',
|
|
||||||
kwargs={
|
|
||||||
'extension_id': self.extension.pk,
|
|
||||||
},
|
|
||||||
add_prefix=False,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
'SITE_URL': settings.SITE_URL,
|
|
||||||
}
|
|
||||||
# Not being localised because we don't know the reviewer's locale.
|
|
||||||
subject = 'Blender Extensions: %s Updated' % self.extension.name
|
|
||||||
template = loader.get_template('reviewers/emails/notify_update.ltxt')
|
|
||||||
send_mail(
|
|
||||||
subject,
|
|
||||||
template.render(context),
|
|
||||||
recipient_list=[self.user.email],
|
|
||||||
from_email=settings.EXTENSIONS_EMAIL,
|
|
||||||
use_deny_list=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ApprovalActivity(CreatedModifiedMixin, RecordDeletionMixin, models.Model):
|
class ApprovalActivity(CreatedModifiedMixin, RecordDeletionMixin, models.Model):
|
||||||
class ActivityType(models.TextChoices):
|
class ActivityType(models.TextChoices):
|
||||||
COMMENT = "COM", _("Comment")
|
COMMENT = "COM", _("Comment")
|
||||||
@ -84,7 +37,7 @@ class ApprovalActivity(CreatedModifiedMixin, RecordDeletionMixin, models.Model):
|
|||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
|
user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True)
|
||||||
extension = models.ForeignKey(
|
extension = models.ForeignKey(
|
||||||
Extension,
|
'extensions.Extension',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='review_activity',
|
related_name='review_activity',
|
||||||
)
|
)
|
||||||
|
@ -2,14 +2,22 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="ext-review-list-type">{{ extension.get_type_display }}</td>
|
<td class="ext-review-list-type">{{ extension.get_type_display }}</td>
|
||||||
<td class="ext-review-list-name">
|
<td class="ext-review-list-name">
|
||||||
<a href="{{ extension.get_review_url }}">
|
<div class="d-flex">
|
||||||
{% include "extensions/components/icon.html" %}
|
<a href="{{ extension.get_review_url }}">
|
||||||
</a>
|
{% include "extensions/components/icon.html" %}
|
||||||
<a href="{{ extension.get_review_url }}">
|
</a>
|
||||||
{{ extension.name }}
|
<a href="{{ extension.get_review_url }}" class="w-100">
|
||||||
</a>
|
{{ 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>
|
||||||
<td>{% include "extensions/components/authors.html" %}</td>
|
|
||||||
<td title="{{ extension.date_created }}">{{ extension.date_created|naturaltime_compact }}</td>
|
<td title="{{ extension.date_created }}">{{ extension.date_created|naturaltime_compact }}</td>
|
||||||
<td class="ext-review-list-activity" colspan="2">
|
<td class="ext-review-list-activity" colspan="2">
|
||||||
<a href="{{ extension.get_review_url }}#activity-{{ stats.last_activity.id }}">
|
<a href="{{ extension.get_review_url }}#activity-{{ stats.last_activity.id }}">
|
||||||
@ -25,10 +33,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<a href="{{ extension.get_review_url }}" class="text-decoration-none">
|
<a href="{{ extension.get_review_url }}" class="text-decoration-none">
|
||||||
{% with last_type=stats.last_type_display|default:"Awaiting Review" %}
|
{% with last_type=stats.last_type_display|default:"Awaiting Review" %}
|
||||||
<div class="d-block badge badge-status-{{ last_type|slugify }}">
|
{% include "common/components/status.html" with label=last_type slug=last_type|slugify object=extension classes="d-block" icon=True %}
|
||||||
<i class="i-eye"></i>
|
|
||||||
<span>{{ last_type }}</span>
|
|
||||||
</div>
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
@ -13,55 +13,37 @@
|
|||||||
{% endblock hero_breadcrumbs %}
|
{% endblock hero_breadcrumbs %}
|
||||||
|
|
||||||
{% block hero_tabs %}
|
{% block hero_tabs %}
|
||||||
<div class="hero-tabs">
|
<div class="d-flex flex-column-reverse flex-md-row">
|
||||||
<a href="#about">
|
<div class="hero-tabs">
|
||||||
{% trans "About" %}
|
<a href="#about">
|
||||||
</a>
|
{% trans "About" %}
|
||||||
<a href="#activity">
|
|
||||||
{% trans "Activity" %}
|
|
||||||
</a>
|
|
||||||
<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">
|
|
||||||
{% if is_maintainer %}
|
|
||||||
<a href="{{ extension.get_manage_url }}" class="btn">
|
|
||||||
<i class="i-edit"></i> {% trans 'Edit' %}
|
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#activity">
|
||||||
|
{% trans "Activity" %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}">
|
||||||
|
{% trans "Version History" %}
|
||||||
|
</a>
|
||||||
|
</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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% if request.user.is_staff %}
|
{% if request.user.is_staff %}
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="btn btn-admin dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="extension-admin-menu">
|
<button class="btn btn-admin dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="extension-admin-menu">
|
||||||
<span>Admin</span>
|
<span>Admin</span>
|
||||||
<i class="i-chevron-down"></i>
|
<i class="i-chevron-down"></i>
|
||||||
</button>
|
</button>
|
||||||
<ul id="extension-admin-menu" class="dropdown-menu dropdown-menu-right js-dropdown-menu">
|
<ul id="extension-admin-menu" class="dropdown-menu dropdown-menu-right js-dropdown-menu">
|
||||||
<li>
|
{% include "extensions/components/dropdown_admin.html" %}
|
||||||
<a href="{% url 'admin:extensions_extension_change' extension.pk %}" class="dropdown-item is-admin">
|
</ul>
|
||||||
{% trans 'Extension' %}
|
</div>
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% if extension.latest_version %}
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'admin:extensions_version_change' extension.latest_version.pk %}" class="dropdown-item is-admin">
|
|
||||||
{% trans 'Version' %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if extension.authors.all.0 %}
|
|
||||||
<li class="dropdown-divider"></li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'admin:users_user_change' extension.authors.all.0.pk %}" class="dropdown-item is-admin">
|
|
||||||
{% trans 'User' %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -103,10 +85,16 @@
|
|||||||
<li id="activity-{{ activity.id }}">
|
<li id="activity-{{ activity.id }}">
|
||||||
<article class="activity-item comment-card">
|
<article class="activity-item comment-card">
|
||||||
<i class="activity-icon {% if activity.type in status_change_types %}i-activity-{{ activity.get_type_display|slugify }}{% else %}i-comment{% endif %}"></i>
|
<i class="activity-icon {% if activity.type in status_change_types %}i-activity-{{ activity.get_type_display|slugify }}{% else %}i-comment{% endif %}"></i>
|
||||||
<aside>
|
<aside class="d-flex flex-column text-secondary">
|
||||||
<a href="{% url "extensions:by-author" user_id=activity.user.pk %}">
|
<a href="{% url "extensions:by-author" user_id=activity.user.pk %}">
|
||||||
{% include "users/components/profile_display.html" with user=activity.user classes="" %}
|
{% include "users/components/profile_display.html" with user=activity.user classes="" %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{% if is_maintainer %}
|
||||||
|
<span title="Extension Maintainer"><i class="i-mic"></i></span>
|
||||||
|
{% elif activity.user.is_moderator %}
|
||||||
|
<span title="Moderator"><i class="i-shield"></i></span>
|
||||||
|
{% endif %}
|
||||||
</aside>
|
</aside>
|
||||||
<div>
|
<div>
|
||||||
<header>
|
<header>
|
||||||
@ -123,7 +111,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
<li class="ms-auto">
|
<li class="ms-auto">
|
||||||
<a href="#activity-{{ activity.id }}" title="{{ activity.date_created }}">
|
<a href="#activity-{{ activity.id }}" title="{{ activity.date_created|date:'l jS, F Y - H:i' }}">
|
||||||
{{ activity.date_created|naturaltime_compact }}
|
{{ activity.date_created|naturaltime_compact }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -22,26 +22,28 @@
|
|||||||
|
|
||||||
<section class="ext-review-list">
|
<section class="ext-review-list">
|
||||||
{% if object_list %}
|
{% if object_list %}
|
||||||
<table class="table table-hover">
|
<div class="overflow-x-auto">
|
||||||
<thead>
|
<table class="table table-hover">
|
||||||
<tr>
|
<thead>
|
||||||
<th>{% trans "Type" %}</th>
|
<tr>
|
||||||
<th>{% trans "Name" %}</th>
|
<th>{% trans "Type" %}</th>
|
||||||
<th>{% trans "Author" %}</th>
|
<th>{% trans "Name" %}</th>
|
||||||
<th>{% trans "Submitted" %}</th>
|
<th>{% trans "Maintainer" %}</th>
|
||||||
<th colspan="2">{% trans "Activity" %}</th>
|
<th>{% trans "Submitted" %}</th>
|
||||||
<th></th>
|
<th colspan="2">{% trans "Activity" %}</th>
|
||||||
<th>{% trans "Status" %}</th>
|
<th></th>
|
||||||
</tr>
|
<th>{% trans "Status" %}</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{% for stats in object_list %}
|
<tbody>
|
||||||
{% with extension=stats.extension %}
|
{% for stats in object_list %}
|
||||||
{% include 'reviewers/components/review_list_item.html' with extension=extension stats=stats %}
|
{% with extension=stats.extension %}
|
||||||
{% endwith %}
|
{% include 'reviewers/components/review_list_item.html' with extension=extension stats=stats %}
|
||||||
{% endfor %}
|
{% endwith %}
|
||||||
</tbody>
|
{% endfor %}
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>{% trans "No extensions to review." %}</p>
|
<p>{% trans "No extensions to review." %}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models, transaction
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from common.model_mixins import CreatedModifiedMixin
|
from common.model_mixins import CreatedModifiedMixin
|
||||||
@ -49,3 +49,39 @@ class TeamsUsers(CreatedModifiedMixin, models.Model):
|
|||||||
@property
|
@property
|
||||||
def is_manager(self) -> bool:
|
def is_manager(self) -> bool:
|
||||||
return self.role == TEAM_ROLE_MANAGER
|
return self.role == TEAM_ROLE_MANAGER
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def delete(self):
|
||||||
|
# This runs when a user is leaving a team.
|
||||||
|
# If the user had authored an extension, other team members shouldn't have access to it,
|
||||||
|
# unless the extension has another maintainer who is still on that team.
|
||||||
|
for extension in self.user.extensions.filter(team=self.team).all():
|
||||||
|
# assuming small datasets, not optimizing db access
|
||||||
|
authors = extension.authors.all()
|
||||||
|
has_other_authors_from_the_team = False
|
||||||
|
for author in authors:
|
||||||
|
if author.pk == self.user.pk:
|
||||||
|
continue
|
||||||
|
if self.team in author.teams.all():
|
||||||
|
has_other_authors_from_the_team = True
|
||||||
|
break
|
||||||
|
if not has_other_authors_from_the_team:
|
||||||
|
extension.team = None
|
||||||
|
extension.save(update_fields={'team'})
|
||||||
|
|
||||||
|
return super().delete()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def may_leave(self) -> bool:
|
||||||
|
nr_of_managers = TeamsUsers.objects.filter(role=TEAM_ROLE_MANAGER, team=self.team).count()
|
||||||
|
user_is_manager = (
|
||||||
|
TeamsUsers.objects.filter(
|
||||||
|
role=TEAM_ROLE_MANAGER,
|
||||||
|
team=self.team,
|
||||||
|
user=self.user,
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if user_is_manager and nr_of_managers < 2:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
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,34 +4,66 @@
|
|||||||
<h1 class="mb-3">Teams</h1>
|
<h1 class="mb-3">Teams</h1>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<table class="table table-hover">
|
{% if team_memberships %}
|
||||||
<thead>
|
<table class="table table-hover">
|
||||||
<tr>
|
<thead>
|
||||||
<th class="w-100">
|
<tr>
|
||||||
Team name
|
<th class="w-100">
|
||||||
</th>
|
Team name
|
||||||
<th>
|
</th>
|
||||||
Role
|
<th>
|
||||||
</th>
|
Role
|
||||||
</tr>
|
</th>
|
||||||
</thead>
|
<th>
|
||||||
<tbody>
|
Users
|
||||||
{% for team_member in user.team_users.all %}
|
</th>
|
||||||
{% with team=team_member.team %}
|
<th></th>
|
||||||
<tr>
|
</tr>
|
||||||
<td>
|
</thead>
|
||||||
<a class="px-0" href="{{ team.get_absolute_url }}">{{ team.name }}</a>
|
<tbody>
|
||||||
</td>
|
{% for team_member in team_memberships %}
|
||||||
<td>
|
{% with team=team_member.team %}
|
||||||
<div class="badge">
|
<tr>
|
||||||
{{ team_member.get_role_display }}
|
<td>
|
||||||
</div>
|
<a class="px-0" href="{{ team.get_absolute_url }}">{{ team.name }}</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<td>
|
||||||
{% endwith %}
|
<div class="badge">
|
||||||
{% endfor %}
|
{{ team_member.get_role_display }}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock settings %}
|
{% 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'
|
app_name = 'teams'
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('settings/teams/', teams.views.TeamsView.as_view(), name='list'),
|
path('settings/teams/', teams.views.TeamsView.as_view(), name='list'),
|
||||||
|
path(
|
||||||
|
'settings/leave-team/<slug:slug>/',
|
||||||
|
teams.views.LeaveTeamView.as_view(),
|
||||||
|
name='leave-team',
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -1,12 +1,43 @@
|
|||||||
"""Team pages."""
|
"""Team pages."""
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.shortcuts import redirect
|
||||||
from django.views.generic import ListView
|
from django.views.generic import ListView
|
||||||
|
from django.views.generic.detail import DetailView
|
||||||
|
|
||||||
import teams.models
|
from extensions.models import Extension
|
||||||
|
from teams.models import Team, TeamsUsers
|
||||||
|
|
||||||
|
|
||||||
class TeamsView(LoginRequiredMixin, ListView):
|
class TeamsView(LoginRequiredMixin, ListView):
|
||||||
model = teams.models.Team
|
model = Team
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_context_data(self, **kwargs):
|
||||||
return self.request.user.teams.all()
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['team_memberships'] = (
|
||||||
|
self.request.user.team_users.select_related('team').order_by('team__name').all()
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class LeaveTeamView(LoginRequiredMixin, DetailView):
|
||||||
|
model = Team
|
||||||
|
template_name = 'teams/confirm_leave.html'
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
team = self.get_object()
|
||||||
|
team_user = TeamsUsers.objects.filter(team=team, user=self.request.user).first()
|
||||||
|
if team_user and team_user.may_leave:
|
||||||
|
team_user.delete()
|
||||||
|
return redirect('teams:list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
team = self.get_object()
|
||||||
|
team_user = TeamsUsers.objects.filter(team=team, user=self.request.user).first()
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context['may_leave'] = team_user.may_leave
|
||||||
|
context['will_lose_access_to'] = list(
|
||||||
|
Extension.objects.authored_by(self.request.user).exclude(
|
||||||
|
maintainer__user_id=self.request.user.pk
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
@ -10,10 +10,10 @@
|
|||||||
<div class="container-main">
|
<div class="container-main">
|
||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="d-none d-md-block col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="is-sticky pt-4">
|
<div class="is-sticky pt-4">
|
||||||
<nav class="box nav-drawer-nested p-3">
|
<nav class="box p-2">
|
||||||
<div class="nav-drawer-body fw-bold">
|
<div class="nav-drawer-body">
|
||||||
{% include 'users/settings/tabs.html' %}
|
{% include 'users/settings/tabs.html' %}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
@ -2,9 +2,9 @@
|
|||||||
<div class="nav nav-pills flex-column" role="tablist" aria-orientation="vertical">
|
<div class="nav nav-pills flex-column" role="tablist" aria-orientation="vertical">
|
||||||
{% include "common/components/nav_link.html" with name="users:my-profile" title="Profile" classes="i-home py-2" %}
|
{% include "common/components/nav_link.html" with name="users:my-profile" title="Profile" classes="i-home py-2" %}
|
||||||
|
|
||||||
{% if user.teams.count %}
|
{% include "common/components/nav_link.html" with name="teams:list" title="Teams" classes="i-users py-2" %}
|
||||||
{% include "common/components/nav_link.html" with name="teams:list" title="Teams" classes="i-users py-2" %}
|
|
||||||
{% endif %}
|
{% include "common/components/nav_link.html" with name="apitokens:list" title="Tokens" classes="i-lock py-2" %}
|
||||||
|
|
||||||
<div class="nav-pills-divider"></div>
|
<div class="nav-pills-divider"></div>
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user