Extensions list: sort_by parameter #159

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

View File

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

1
apitokens/__init__.py Normal file
View File

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

6
apitokens/apps.py Normal file
View File

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

View File

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

View File

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

View File

36
apitokens/models.py Normal file
View File

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

View File

@ -0,0 +1,30 @@
{% extends "common/base.html" %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-8 mx-auto my-4">
<div class="box">
<h2>
Delete API Token?
</h2>
<p>
This will delete the token <b>{{ object.name }}</b> created at {{ object.date_created }}<br />
Any application which were relying on this token will require a new token.
</p>
<div class="btn-row-fluid">
<a href="{% url 'apitokens:list' %}" class="btn">
<i class="i-cancel"></i>
<span>{% trans 'Cancel' %}</span>
</a>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-block btn-danger">
<i class="i-trash"></i>
<span>{% trans 'Confirm Deletion' %}</span>
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock content %}

View File

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

View File

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

View File

View File

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

11
apitokens/urls.py Normal file
View File

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

78
apitokens/views.py Normal file
View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ from constants.base import (
EXTENSION_STATUS_CHOICES,
EXTENSION_TYPE_CHOICES,
EXTENSION_TYPE_SLUGS,
EXTENSION_TYPE_SLUGS_SINGULAR,
FILE_STATUS_CHOICES,
)
import common.help_texts
@ -128,12 +129,19 @@ class ExtensionManager(models.Manager):
def unlisted(self):
return self.exclude(status=self.model.STATUSES.APPROVED)
def authored_by(self, user_id: int):
return self.filter(maintainer__user_id=user_id)
def _authored_by_filter(self, user):
filter = Q(maintainer__user_id=user.pk)
user_teams = user.teams.all()
if user_teams:
filter = filter | Q(team__in=[t.pk for t in user_teams])
return filter
def listed_or_authored_by(self, user_id: int):
def authored_by(self, user):
return self.filter(self._authored_by_filter(user)).distinct()
def listed_or_authored_by(self, user):
return self.filter(
Q(status=self.model.STATUSES.APPROVED) | Q(maintainer__user_id=user_id)
Q(status=self.model.STATUSES.APPROVED) | self._authored_by_filter(user)
).distinct()
@ -183,7 +191,7 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
)
previews = FilterableManyToManyField('files.File', through='Preview', related_name='extensions')
status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.INCOMPLETE)
status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUSES.DRAFT)
support = models.URLField(
help_text='URL for reporting issues or contact details for support.', null=True, blank=True
)
@ -212,6 +220,10 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
def type_slug(self) -> str:
return EXTENSION_TYPE_SLUGS[self.type]
@property
def type_slug_singular(self) -> str:
return EXTENSION_TYPE_SLUGS_SINGULAR[self.type]
@property
def status_slug(self) -> str:
return utils.slugify(EXTENSION_STATUS_CHOICES[self.status - 1][1])
@ -336,23 +348,6 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
versions = sorted(versions, key=lambda v: v.date_created, reverse=True)
return versions[0]
@property
def current_version(self):
"""Return the latest public listed version of an extension.
If the add-on is not public, it can return a listed version awaiting
review (since non-public add-ons should not have public versions).
If the add-on has not been created yet or is deleted, it returns None.
"""
if not self.id:
return None
try:
return self.version
except ObjectDoesNotExist:
pass
return None
def can_request_review(self):
"""Return whether an add-on can request a review or not."""
if self.is_disabled or self.status in (
@ -379,16 +374,20 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
def should_redirect_to_submit_flow(self):
return (
self.status == self.STATUSES.INCOMPLETE
self.status == self.STATUSES.DRAFT
and not self.has_complete_metadata()
and self.latest_version is not None
)
def has_maintainer(self, user) -> bool:
"""Return True if given user is listed as a maintainer."""
"""Return True if given user is listed as a maintainer or is a member of the team."""
if user is None or user.is_anonymous:
return False
return user in self.authors.all()
if user in self.authors.all():
return True
if self.team and user in self.team.users.all():
return True
return False
def can_rate(self, user) -> bool:
"""Return True if given user can rate this extension.
@ -631,12 +630,14 @@ class Version(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Model
@property
def download_url(self) -> str:
filename = f'{self.extension.type_slug_singular}-{self.extension.slug}-v{self.version}.zip'
return reverse(
'extensions:version-download',
kwargs={
'type_slug': self.extension.type_slug,
'slug': self.extension.slug,
'version': self.version,
'filename': filename,
},
)

View File

@ -104,7 +104,7 @@ def _set_is_listed(
return
if extension.status == extensions.models.Extension.STATUSES.APPROVED and not new_is_listed:
extension.status = extensions.models.Extension.STATUSES.INCOMPLETE
extension.status = extensions.models.Extension.STATUSES.DRAFT
logger.info('Extension pk=%s becomes listed', extension.pk)
extension.is_listed = new_is_listed

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,8 +16,6 @@ from constants.base import (
from stats.models import ExtensionDownload, VersionDownload
import teams.models
from .api import ExtensionsAPIView
User = get_user_model()
log = logging.getLogger(__name__)
@ -32,14 +30,6 @@ class HomeView(ListedExtensionsView):
paginate_by = 16
template_name = 'extensions/home.html'
def dispatch(self, request, *args, **kwargs):
"""Return the API view if requesting a JSON."""
if request.headers.get('Accept') == 'application/json':
api_view = ExtensionsAPIView.as_view()
return api_view(request, *args, **kwargs)
else:
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
q = (
@ -50,6 +40,7 @@ class HomeView(ListedExtensionsView):
'preview_set',
'preview_set__file',
'ratings',
'team',
'versions',
'versions__file',
'versions__tags',
@ -60,12 +51,17 @@ class HomeView(ListedExtensionsView):
return context
def extension_version_download(request, type_slug, slug, version):
"""Download an extension version and count downloads."""
def extension_version_download(request, type_slug, slug, version, filename):
"""Download an extension version and count downloads.
The `filename` parameter is used to pass a file name ending with `.zip`.
This is a convention Blender uses to initiate an extension installation on an HTML anchor
drag&drop.
"""
extension_version = get_object_or_404(Version, extension__slug=slug, version=version)
ExtensionDownload.create_from_request(request, object_id=extension_version.extension_id)
VersionDownload.create_from_request(request, object_id=extension_version.pk)
return redirect(extension_version.downloadable_signed_url)
return redirect(extension_version.downloadable_signed_url + f'?filename={filename}')
class SearchView(ListedExtensionsView):
@ -107,6 +103,7 @@ class SearchView(ListedExtensionsView):
'preview_set',
'preview_set__file',
'ratings',
'team',
'versions',
'versions__file',
'versions__tags',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,49 @@
{% extends "common/base.html" %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-8 mx-auto my-4">
<div class="box">
<h2>
{% blocktranslate with team_name=object.name %}Leave team {{ team_name }}?{% endblocktranslate %}
</h2>
{% if may_leave %}
<p>
{% blocktranslate %}
If you wish to join the team again in the future, you will need to ask the team manager to add you back.
{% endblocktranslate %}
{% if will_lose_access_to %}
<br>
{% blocktranslate %}
You will lose access to all team extensions that were not uploaded by you:
{% endblocktranslate %}
<ul>
{% for extension in will_lose_access_to %}
<li><a href="{{ extension.get_absolute_url }}">{{ extension }}</a></li>
{% endfor %}
</ul>
{% endif %}
</p>
<div class="btn-row-fluid">
<a href="#" class="btn js-btn-back">
<i class="i-cancel"></i>
<span>{% trans 'Cancel' %}</span>
</a>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-block btn-danger">
<i class="i-log-out"></i>
<span>{% trans 'Confirm Leave' %}</span>
</button>
</form>
</div>
{% else %}
<p>
{% trans 'You cannot leave this team because you are the only manager.' %}
</p>
{% endif %}
</div>
</div>
</div>
{% endblock content %}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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