Use a materialized Extension.latest_version field instead of a dynamic property #152
1
apitokens/__init__.py
Normal file
1
apitokens/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
default_app_config = 'apitokens.apps.TokensConfig'
|
6
apitokens/apps.py
Normal file
6
apitokens/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TokensConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apitokens'
|
33
apitokens/authentication.py
Normal file
33
apitokens/authentication.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from rest_framework.authentication import BaseAuthentication
|
||||||
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
from .models import UserToken
|
||||||
|
from utils import clean_ip_address
|
||||||
|
|
||||||
|
|
||||||
|
class UserTokenAuthentication(BaseAuthentication):
|
||||||
|
def authenticate(self, request):
|
||||||
|
auth_header = request.headers.get('Authorization')
|
||||||
|
|
||||||
|
if not auth_header:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_type, token_key = auth_header.split()
|
||||||
|
if token_type.lower() != 'bearer':
|
||||||
|
return None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_hash = UserToken.generate_hash(token_key=token_key)
|
||||||
|
token = UserToken.objects.get(token_hash=token_hash)
|
||||||
|
except UserToken.DoesNotExist:
|
||||||
|
raise AuthenticationFailed('Invalid token')
|
||||||
|
|
||||||
|
token.ip_address_last_access = clean_ip_address(request)
|
||||||
|
token.date_last_access = timezone.now()
|
||||||
|
token.save(update_fields={'ip_address_last_access', 'date_last_access'})
|
||||||
|
|
||||||
|
return (token.user, token)
|
30
apitokens/migrations/0001_initial.py
Normal file
30
apitokens/migrations/0001_initial.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 4.2.11 on 2024-05-25 09:43
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserToken',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('token_prefix', models.CharField(editable=False, max_length=5)),
|
||||||
|
('token_hash', models.CharField(editable=False, max_length=64, unique=True)),
|
||||||
|
('date_created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('date_last_access', models.DateTimeField(blank=True, editable=False, null=True)),
|
||||||
|
('ip_address_last_access', models.GenericIPAddressField(null=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
0
apitokens/migrations/__init__.py
Normal file
0
apitokens/migrations/__init__.py
Normal file
36
apitokens/models.py
Normal file
36
apitokens/models.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserToken(models.Model):
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tokens')
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
token_prefix = models.CharField(max_length=5, editable=False)
|
||||||
|
token_hash = models.CharField(max_length=64, editable=False, unique=True)
|
||||||
|
date_created = models.DateTimeField(auto_now_add=True)
|
||||||
|
date_last_access = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
|
ip_address_last_access = models.GenericIPAddressField(protocol='both', null=True)
|
||||||
|
|
||||||
|
def get_delete_url(self):
|
||||||
|
return reverse('apitokens:delete', kwargs={'pk': self.pk})
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.token_prefix} - {self.name}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_hash(token_key: str) -> str:
|
||||||
|
return hashlib.sha256(token_key.encode()).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_token_key() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_token_prefix(cls, token_key: str) -> str:
|
||||||
|
token_prefix_length = cls._meta.get_field('token_prefix').max_length
|
||||||
|
return token_key[:token_prefix_length]
|
30
apitokens/templates/apitokens/apitoken_confirm_delete.html
Normal file
30
apitokens/templates/apitokens/apitoken_confirm_delete.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{% extends "common/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-8 mx-auto my-4">
|
||||||
|
<div class="box">
|
||||||
|
<h2>
|
||||||
|
Delete API Token?
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
This will delete the token <b>{{ object.name }}</b> created at {{ object.date_created }}<br />
|
||||||
|
Any application which were relying on this token will require a new token.
|
||||||
|
</p>
|
||||||
|
<div class="btn-row-fluid">
|
||||||
|
<a href="{% url 'apitokens:list' %}" class="btn">
|
||||||
|
<i class="i-cancel"></i>
|
||||||
|
<span>{% trans 'Cancel' %}</span>
|
||||||
|
</a>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-block btn-danger">
|
||||||
|
<i class="i-trash"></i>
|
||||||
|
<span>{% trans 'Confirm Deletion' %}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
37
apitokens/templates/apitokens/apitoken_create.html
Normal file
37
apitokens/templates/apitokens/apitoken_create.html
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{% extends "common/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-8 mx-auto my-4">
|
||||||
|
<div class="box">
|
||||||
|
<h2>
|
||||||
|
User API Token
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Create a new user token to be used with the <a href="{% url 'swagger' %}">API</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="id_name">API Token Name:</label>
|
||||||
|
<input type="text" id="id_name" name="name" class="form-control" placeholder="Example Name" required>
|
||||||
|
{% if form.errors.name %}
|
||||||
|
<div class="warning">{{ form.errors.name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="btn-row-fluid">
|
||||||
|
<a href="{% url 'apitokens:list' %}" class="btn">
|
||||||
|
<i class="i-cancel"></i>
|
||||||
|
<span>{% trans 'Cancel' %}</span>
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-block btn-success">
|
||||||
|
<i class="i-plus"></i>
|
||||||
|
<span>{% trans 'Create API Token' %}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
62
apitokens/templates/apitokens/usertoken_list.html
Normal file
62
apitokens/templates/apitokens/usertoken_list.html
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
{% extends 'users/settings/base.html' %}
|
||||||
|
|
||||||
|
{% block settings %}
|
||||||
|
<h1 class="mb-3">Tokens</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="mb-3">
|
||||||
|
List of user tokens to automate tasks via the <a href="{% url 'swagger' %}">API</a>.
|
||||||
|
</div>
|
||||||
|
{% if tokens %}
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-nowrap">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th class="text-nowrap">
|
||||||
|
Token Begin
|
||||||
|
</th>
|
||||||
|
<th class="text-nowrap">
|
||||||
|
Created At
|
||||||
|
</th>
|
||||||
|
<th class="text-nowrap">
|
||||||
|
Last Access
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for token in tokens %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap w-100">{{token.name}}</td>
|
||||||
|
<td class="text-muted text-nowrap">{{token.token_prefix}}...</td>
|
||||||
|
<td class="text-nowrap">{{token.date_created}}</td>
|
||||||
|
<td class="text-nowrap">
|
||||||
|
{{ token.date_last_access|default_if_none:"-" }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-link dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="token-{{ token.token_prefix }}">
|
||||||
|
<i class="i-more-vertical"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-right js-dropdown-menu" id="token-{{ token.token_prefix }}">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ token.get_delete_url }}">
|
||||||
|
<i class="i-trash"></i>Delete token
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
<a href="{% url 'apitokens:create' %}" class="btn btn-primary"><i class="i-plus"></i>Token</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock settings %}
|
0
apitokens/tests/__init__.py
Normal file
0
apitokens/tests/__init__.py
Normal file
74
apitokens/tests/test_user_token.py
Normal file
74
apitokens/tests/test_user_token.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apitokens.models import UserToken
|
||||||
|
from common.tests.factories.users import UserFactory
|
||||||
|
from common.tests.utils import create_user_token
|
||||||
|
|
||||||
|
|
||||||
|
class UserTokenTest(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = UserFactory()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.assertEqual(UserToken.objects.count(), 0)
|
||||||
|
return super().setUp()
|
||||||
|
|
||||||
|
def test_token_displayed_only_once(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('apitokens:create'),
|
||||||
|
{
|
||||||
|
'name': 'Test Token',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertRedirects(response, reverse('apitokens:list'))
|
||||||
|
self.assertEqual(UserToken.objects.count(), 1)
|
||||||
|
token = UserToken.objects.first()
|
||||||
|
|
||||||
|
# Check if the success message with the token value is displayed
|
||||||
|
messages = list(response.wsgi_request._messages)
|
||||||
|
self.assertEqual(len(messages), 2)
|
||||||
|
|
||||||
|
token_key = messages[0].message
|
||||||
|
self.assertIn(token.token_prefix, token_key)
|
||||||
|
self.assertIn('Your new token has been generated', messages[1].message)
|
||||||
|
|
||||||
|
token_hash = UserToken.generate_hash(token_key)
|
||||||
|
self.assertEqual(token, UserToken.objects.get(token_hash=token_hash))
|
||||||
|
|
||||||
|
# Verify the token value is shown only on the creation page
|
||||||
|
response = self.client.get(reverse('apitokens:list'))
|
||||||
|
self.assertNotContains(response, token_key)
|
||||||
|
|
||||||
|
def test_list_page_does_not_display_full_token_value(self):
|
||||||
|
token, token_key = create_user_token(user=self.user, name='Test Token')
|
||||||
|
|
||||||
|
response = self.client.get(reverse('apitokens:list'))
|
||||||
|
self.assertContains(response, str(token.token_prefix))
|
||||||
|
self.assertNotContains(response, str(token_key))
|
||||||
|
|
||||||
|
def test_list_page_shows_last_access_time(self):
|
||||||
|
token = UserToken.objects.create(user=self.user, name='Test Token')
|
||||||
|
|
||||||
|
# Create a timezone-aware datetime object.
|
||||||
|
date_last_access_str = '1994-01-02 10:10:36'
|
||||||
|
date_last_access_naive = datetime.strptime(date_last_access_str, '%Y-%m-%d %H:%M:%S')
|
||||||
|
date_last_access_aware = timezone.make_aware(
|
||||||
|
date_last_access_naive, timezone.get_default_timezone()
|
||||||
|
)
|
||||||
|
token.date_last_access = date_last_access_aware
|
||||||
|
|
||||||
|
# Format the datetime to match the expected response format.
|
||||||
|
formatted_date = (
|
||||||
|
date_last_access_aware.strftime('%b. %-d, %Y, %-I:%M %p')
|
||||||
|
.replace('AM', 'a.m.')
|
||||||
|
.replace('PM', 'p.m.')
|
||||||
|
)
|
||||||
|
|
||||||
|
token.save()
|
||||||
|
response = self.client.get(reverse('apitokens:list'))
|
||||||
|
self.assertContains(response, formatted_date)
|
11
apitokens/urls.py
Normal file
11
apitokens/urls.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
import apitokens.views
|
||||||
|
|
||||||
|
|
||||||
|
app_name = 'apitokens'
|
||||||
|
urlpatterns = [
|
||||||
|
path('settings/tokens/', apitokens.views.TokensView.as_view(), name='list'),
|
||||||
|
path('tokens/create/', apitokens.views.CreateTokenView.as_view(), name='create'),
|
||||||
|
path('tokens/delete/<int:pk>/', apitokens.views.DeleteTokenView.as_view(), name='delete'),
|
||||||
|
]
|
78
apitokens/views.py
Normal file
78
apitokens/views.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.views.generic.edit import CreateView
|
||||||
|
from django.views.generic import ListView, DeleteView
|
||||||
|
from django.shortcuts import reverse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
|
||||||
|
|
||||||
|
from .models import UserToken
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TokensView(LoginRequiredMixin, ListView):
|
||||||
|
model = UserToken
|
||||||
|
context_object_name = 'tokens'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.request.user.tokens.all()
|
||||||
|
|
||||||
|
|
||||||
|
class UserTokenForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = UserToken
|
||||||
|
fields = ('name',)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.user = kwargs.pop('user', None) # Get user information from kwargs
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean_name(self):
|
||||||
|
name = self.cleaned_data.get('name')
|
||||||
|
if UserToken.objects.filter(user=self.user, name=name).exists():
|
||||||
|
error_message = mark_safe(f'A token with this name already exists: <b>{name}</b>.')
|
||||||
|
raise forms.ValidationError(error_message)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTokenView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
||||||
|
model = UserToken
|
||||||
|
form_class = UserTokenForm
|
||||||
|
template_name = 'apitokens/apitoken_create.html'
|
||||||
|
success_message = (
|
||||||
|
"Your new token has been generated. Copy it now as it will not be shown again."
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('apitokens:list')
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['user'] = self.request.user
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.user = self.request.user
|
||||||
|
|
||||||
|
token_key = UserToken.generate_token_key()
|
||||||
|
form.instance.token_hash = UserToken.generate_hash(token_key)
|
||||||
|
form.instance.token_prefix = UserToken.generate_token_prefix(token_key)
|
||||||
|
|
||||||
|
messages.info(self.request, token_key)
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteTokenView(LoginRequiredMixin, SuccessMessageMixin, DeleteView):
|
||||||
|
model = UserToken
|
||||||
|
template_name = 'apitokens/apitoken_confirm_delete.html'
|
||||||
|
success_url = reverse_lazy('apitokens:list')
|
||||||
|
success_message = "Token deleted successfully"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(user=self.request.user)
|
@ -1 +1 @@
|
|||||||
Subproject commit 6a52e5abcc118133b8cb51137b34bf856da716c4
|
Subproject commit 35a222528d621a198d25707e8399f61f0dc08fcc
|
@ -60,6 +60,7 @@ INSTALLED_APPS = [
|
|||||||
'rangefilter',
|
'rangefilter',
|
||||||
'reviewers',
|
'reviewers',
|
||||||
'stats',
|
'stats',
|
||||||
|
'apitokens',
|
||||||
'taggit',
|
'taggit',
|
||||||
'drf_spectacular',
|
'drf_spectacular',
|
||||||
'drf_spectacular_sidecar',
|
'drf_spectacular_sidecar',
|
||||||
@ -273,6 +274,8 @@ ACTSTREAM_SETTINGS = {
|
|||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': ('apitokens.authentication.UserTokenAuthentication',),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',),
|
||||||
}
|
}
|
||||||
|
|
||||||
SPECTACULAR_SETTINGS = {
|
SPECTACULAR_SETTINGS = {
|
||||||
|
@ -40,6 +40,7 @@ urlpatterns = [
|
|||||||
path('', include('teams.urls')),
|
path('', include('teams.urls')),
|
||||||
path('', include('reviewers.urls')),
|
path('', include('reviewers.urls')),
|
||||||
path('', include('notifications.urls')),
|
path('', include('notifications.urls')),
|
||||||
|
path('', include('apitokens.urls')),
|
||||||
path('api/swagger/', RedirectView.as_view(url='/api/v1/swagger/')),
|
path('api/swagger/', RedirectView.as_view(url='/api/v1/swagger/')),
|
||||||
path('api/v1/', SpectacularAPIView.as_view(), name='schema_v1'),
|
path('api/v1/', SpectacularAPIView.as_view(), name='schema_v1'),
|
||||||
path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'),
|
path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'),
|
||||||
|
@ -231,8 +231,12 @@
|
|||||||
border-radius: var(--border-radius-lg)
|
border-radius: var(--border-radius-lg)
|
||||||
border: var(--border-width) solid var(--border-color)
|
border: var(--border-width) solid var(--border-color)
|
||||||
display: flex
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
+padding(2, y)
|
+padding(2, y)
|
||||||
|
|
||||||
|
+media-md
|
||||||
|
flex-direction: row
|
||||||
|
|
||||||
.previews-list-item-thumbnail
|
.previews-list-item-thumbnail
|
||||||
margin: 0
|
margin: 0
|
||||||
+margin(2, y)
|
+margin(2, y)
|
||||||
@ -252,6 +256,7 @@
|
|||||||
.details
|
.details
|
||||||
+padding(3, x)
|
+padding(3, x)
|
||||||
flex: 1
|
flex: 1
|
||||||
|
width: 100%
|
||||||
|
|
||||||
label
|
label
|
||||||
font-size: var(--fs-sm)
|
font-size: var(--fs-sm)
|
||||||
|
@ -16,7 +16,13 @@
|
|||||||
align-items: center
|
align-items: center
|
||||||
border-bottom: thin solid rgba(white, .1)
|
border-bottom: thin solid rgba(white, .1)
|
||||||
display: flex
|
display: flex
|
||||||
|
flex-grow: 1
|
||||||
margin-top: auto
|
margin-top: auto
|
||||||
|
overflow-x: auto
|
||||||
|
overflow-y: hidden
|
||||||
|
|
||||||
|
a
|
||||||
|
white-space: nowrap
|
||||||
|
|
||||||
.dropdown-menu
|
.dropdown-menu
|
||||||
font-size: initial
|
font-size: initial
|
||||||
@ -55,3 +61,9 @@
|
|||||||
|
|
||||||
&::after
|
&::after
|
||||||
opacity: 1
|
opacity: 1
|
||||||
|
|
||||||
|
.hero-tabs-admin
|
||||||
|
justify-content: end
|
||||||
|
|
||||||
|
+media-sm
|
||||||
|
border-bottom: thin solid rgba(white, .1)
|
||||||
|
@ -3,9 +3,9 @@
|
|||||||
{% load common %}
|
{% load common %}
|
||||||
|
|
||||||
{% with default_title="Blender Extensions" %}
|
{% with default_title="Blender Extensions" %}
|
||||||
{% with default_author="Blender Institute" %}
|
{% with default_author="Blender Foundation" %}
|
||||||
|
|
||||||
{% with default_description="Blender Extensions is a web based service developed by Blender Institute that allows people to share open source add-ons for Blender." %}
|
{% with default_description="Blender Extensions is a web based service developed by Blender Foundation that allows people to share open source add-ons for Blender." %}
|
||||||
|
|
||||||
{% if not image_url %}
|
{% if not image_url %}
|
||||||
{% absolute_url default_image_path as image_url %}
|
{% absolute_url default_image_path as image_url %}
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
import itertools
|
import itertools
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
import django.urls as urls
|
import django.urls as urls
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.regex_helper import normalize
|
from django.utils.regex_helper import normalize
|
||||||
|
|
||||||
|
from apitokens.models import UserToken
|
||||||
|
|
||||||
|
|
||||||
try: # Django 2.0
|
try: # Django 2.0
|
||||||
url_resolver_types = (urls.URLResolver,)
|
url_resolver_types = (urls.URLResolver,)
|
||||||
DJANGO_2 = True
|
DJANGO_2 = True
|
||||||
@ -109,3 +113,11 @@ class CheckFilePropertiesMixin:
|
|||||||
self.assertEqual(file.original_name, kwargs.get('original_name'))
|
self.assertEqual(file.original_name, kwargs.get('original_name'))
|
||||||
if 'size_bytes' in kwargs:
|
if 'size_bytes' in kwargs:
|
||||||
self.assertEqual(file.size_bytes, kwargs.get('size_bytes'))
|
self.assertEqual(file.size_bytes, kwargs.get('size_bytes'))
|
||||||
|
|
||||||
|
|
||||||
|
def create_user_token(*args, **kwargs) -> Tuple['UserToken', str]:
|
||||||
|
token_key = UserToken.generate_token_key()
|
||||||
|
kwargs['token_hash'] = UserToken.generate_hash(token_key)
|
||||||
|
kwargs['token_prefix'] = UserToken.generate_token_prefix(token_key)
|
||||||
|
token = UserToken.objects.create(*args, **kwargs)
|
||||||
|
return token, token_key
|
||||||
|
@ -38,7 +38,7 @@ function appendImageUploadForm() {
|
|||||||
<div class="details">
|
<div class="details">
|
||||||
<div>
|
<div>
|
||||||
<label for="${formsetPrefix}-${i}-caption">Image or Video</label>
|
<label for="${formsetPrefix}-${i}-caption">Image or Video</label>
|
||||||
<input class="js-input-img-caption form-control" id="${formsetPrefix}-${i}-caption" type="text" maxlength="255" name="${formsetPrefix}-${i}-caption" placeholder="Description">
|
<input class="js-input-img-caption form-control mb-2" id="${formsetPrefix}-${i}-caption" type="text" maxlength="255" name="${formsetPrefix}-${i}-caption" placeholder="Description">
|
||||||
</div>
|
</div>
|
||||||
<div class="details-buttons">
|
<div class="details-buttons">
|
||||||
<input accept="image/jpg,image/jpeg,image/png,image/webp,video/mp4" class="form-control form-control-sm js-input-img" id="id_${formsetPrefix}-${i}-source" type="file" name="${formsetPrefix}-${i}-source">
|
<input accept="image/jpg,image/jpeg,image/png,image/webp,video/mp4" class="form-control form-control-sm js-input-img" id="id_${formsetPrefix}-${i}-source" type="file" name="${formsetPrefix}-${i}-source">
|
||||||
|
@ -49,7 +49,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block hero_tabs %}
|
{% block hero_tabs %}
|
||||||
<nav class="hero-tabs">
|
<nav class="d-flex flex-column-reverse flex-md-row">
|
||||||
|
<div class="hero-tabs">
|
||||||
<a href="{{ extension.get_absolute_url }}" class="{% if extension.get_absolute_url == request.get_full_path %}is-active{% endif %}">
|
<a href="{{ extension.get_absolute_url }}" class="{% if extension.get_absolute_url == request.get_full_path %}is-active{% endif %}">
|
||||||
{% trans "About" %}
|
{% trans "About" %}
|
||||||
</a>
|
</a>
|
||||||
@ -71,14 +72,14 @@
|
|||||||
<a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}">
|
<a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}">
|
||||||
{% trans "Version History" %}
|
{% trans "Version History" %}
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
<span class="ms-auto"></span>
|
<div class="btn-row hero-tabs-admin mb-3 mb-md-0">
|
||||||
|
|
||||||
<div class="btn-row">
|
|
||||||
{% if is_maintainer %}
|
{% if is_maintainer %}
|
||||||
|
<div>
|
||||||
<a href="{{ extension.get_manage_url }}" class="btn">
|
<a href="{{ extension.get_manage_url }}" class="btn">
|
||||||
<i class="i-edit"></i> {% trans 'Edit' %}
|
<i class="i-edit"></i> {% trans 'Edit' %}
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if request.user.is_staff %}
|
{% if request.user.is_staff %}
|
||||||
|
@ -51,7 +51,8 @@
|
|||||||
<section class="mt-4">
|
<section class="mt-4">
|
||||||
<h2>{% trans 'Media' %}</h2>
|
<h2>{% trans 'Media' %}</h2>
|
||||||
<div class="row flex">
|
<div class="row flex">
|
||||||
<div class="col-md-6">
|
{# TODO: @web-assets check media brakpoints utilities 'md' #}
|
||||||
|
<div class="col-md-6 mb-3 mb-md-0">
|
||||||
<div class="box p-3">
|
<div class="box p-3">
|
||||||
{% trans "Featured Image" as featured_image_label %}
|
{% trans "Featured Image" as featured_image_label %}
|
||||||
{% trans "A JPEG, PNG or WebP image, at least 1920 x 1080 and with aspect ratio of 16:9." as featured_image_help_text %}
|
{% trans "A JPEG, PNG or WebP image, at least 1920 x 1080 and with aspect ratio of 16:9." as featured_image_help_text %}
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<div class="js-input-img-caption-helper">
|
<div class="js-input-img-caption-helper mb-2">
|
||||||
{% include "common/components/field.html" with field=inlineform.caption label='Image or Video' placeholder="Description" %}
|
{% include "common/components/field.html" with field=inlineform.caption label='Image or Video' placeholder="Description" %}
|
||||||
</div>
|
</div>
|
||||||
<div class="details-buttons js-input-img-helper">
|
<div class="details-buttons js-input-img-helper">
|
||||||
|
118
extensions/tests/test_api.py
Normal file
118
extensions/tests/test_api.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase, APIClient
|
||||||
|
|
||||||
|
from common.tests.factories.users import UserFactory
|
||||||
|
from common.tests.factories.extensions import create_approved_version
|
||||||
|
from common.tests.utils import create_user_token
|
||||||
|
|
||||||
|
from extensions.models import Version
|
||||||
|
|
||||||
|
|
||||||
|
TEST_FILES_DIR = Path(__file__).resolve().parent / 'files'
|
||||||
|
|
||||||
|
|
||||||
|
class VersionUploadAPITest(APITestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = UserFactory()
|
||||||
|
self.token, self.token_key = create_user_token(user=self.user)
|
||||||
|
|
||||||
|
self.client = APIClient()
|
||||||
|
self.version = create_approved_version(
|
||||||
|
extension__extension_id="amaranth",
|
||||||
|
version="1.0.7",
|
||||||
|
file__user=self.user,
|
||||||
|
)
|
||||||
|
self.extension = self.version.extension
|
||||||
|
self.file_path = TEST_FILES_DIR / "amaranth-1.0.8.zip"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_upload_url(extension_id):
|
||||||
|
upload_url = reverse('extensions:upload-extension-version', args=(extension_id,))
|
||||||
|
return upload_url
|
||||||
|
|
||||||
|
def test_version_upload_unauthenticated(self):
|
||||||
|
with open(self.file_path, 'rb') as version_file:
|
||||||
|
response = self.client.post(
|
||||||
|
self._get_upload_url(self.extension.extension_id),
|
||||||
|
{
|
||||||
|
'version_file': version_file,
|
||||||
|
'release_notes': 'These are the release notes',
|
||||||
|
},
|
||||||
|
format='multipart',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
def test_version_upload_extension_not_maintained_by_user(self):
|
||||||
|
other_user = UserFactory()
|
||||||
|
other_extension = create_approved_version(
|
||||||
|
extension__extension_id='other_extension', file__user=other_user
|
||||||
|
).extension
|
||||||
|
|
||||||
|
with open(self.file_path, 'rb') as version_file:
|
||||||
|
response = self.client.post(
|
||||||
|
self._get_upload_url(other_extension.extension_id),
|
||||||
|
{
|
||||||
|
'version_file': version_file,
|
||||||
|
'release_notes': 'These are the release notes',
|
||||||
|
},
|
||||||
|
format='multipart',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {self.token_key}',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data['message'],
|
||||||
|
f'Extension "{other_extension.extension_id}" not maintained by user "{self.user.full_name}"',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_version_upload_extension_does_not_exist(self):
|
||||||
|
extension_name = 'extension_do_not_exist'
|
||||||
|
with open(self.file_path, 'rb') as version_file:
|
||||||
|
response = self.client.post(
|
||||||
|
self._get_upload_url(extension_name),
|
||||||
|
{
|
||||||
|
'version_file': version_file,
|
||||||
|
'release_notes': 'These are the release notes',
|
||||||
|
},
|
||||||
|
format='multipart',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {self.token_key}',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
self.assertEqual(response.data['message'], f'Extension "{extension_name}" not found')
|
||||||
|
|
||||||
|
def test_version_upload_success(self):
|
||||||
|
self.assertEqual(Version.objects.filter(extension=self.extension).count(), 1)
|
||||||
|
with open(self.file_path, 'rb') as version_file:
|
||||||
|
response = self.client.post(
|
||||||
|
self._get_upload_url(self.extension.extension_id),
|
||||||
|
{
|
||||||
|
'version_file': version_file,
|
||||||
|
'release_notes': 'These are the release notes',
|
||||||
|
},
|
||||||
|
format='multipart',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {self.token_key}',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(Version.objects.filter(extension=self.extension).count(), 2)
|
||||||
|
|
||||||
|
def test_date_last_access(self):
|
||||||
|
self.assertIsNone(self.token.date_last_access)
|
||||||
|
with open(self.file_path, 'rb') as version_file:
|
||||||
|
response = self.client.post(
|
||||||
|
self._get_upload_url(self.extension.extension_id),
|
||||||
|
{
|
||||||
|
'version_file': version_file,
|
||||||
|
'release_notes': 'These are the release notes',
|
||||||
|
},
|
||||||
|
format='multipart',
|
||||||
|
HTTP_AUTHORIZATION=f'Bearer {self.token_key}',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.token.refresh_from_db()
|
||||||
|
self.assertIsNotNone(self.token.date_last_access)
|
@ -16,6 +16,11 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
# API
|
# API
|
||||||
path('api/v1/extensions/', api.ExtensionsAPIView.as_view(), name='api'),
|
path('api/v1/extensions/', api.ExtensionsAPIView.as_view(), name='api'),
|
||||||
|
path(
|
||||||
|
'api/v1/extensions/<str:extension_id>/versions/new/',
|
||||||
|
api.UploadExtensionVersionView.as_view(),
|
||||||
|
name='upload-extension-version',
|
||||||
|
),
|
||||||
# Public pages
|
# Public pages
|
||||||
path('', public.HomeView.as_view(), name='home'),
|
path('', public.HomeView.as_view(), name='home'),
|
||||||
path('search/', public.SearchView.as_view(), name='search'),
|
path('search/', public.SearchView.as_view(), name='search'),
|
||||||
|
@ -1,14 +1,19 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers, status
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
from common.compare import is_in_version_range, version
|
from common.compare import is_in_version_range, version
|
||||||
from extensions.models import Extension, Platform
|
from extensions.models import Extension, Platform, Version
|
||||||
from extensions.utils import clean_json_dictionary_from_optional_fields
|
from extensions.utils import clean_json_dictionary_from_optional_fields
|
||||||
|
from extensions.views.manage import NewVersionView
|
||||||
|
from files.forms import FileFormSkipAgreed
|
||||||
|
|
||||||
|
|
||||||
from constants.base import (
|
from constants.base import (
|
||||||
@ -104,6 +109,7 @@ class ListedExtensionsSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ExtensionsAPIView(APIView):
|
class ExtensionsAPIView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
serializer_class = ListedExtensionsSerializer
|
serializer_class = ListedExtensionsSerializer
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
@ -149,3 +155,76 @@ class ExtensionsAPIView(APIView):
|
|||||||
'version': 'v1',
|
'version': 'v1',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionVersionSerializer(serializers.Serializer):
|
||||||
|
version_file = serializers.FileField()
|
||||||
|
release_notes = serializers.CharField(max_length=1024, required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class UploadExtensionVersionView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=ExtensionVersionSerializer,
|
||||||
|
responses={201: 'Extension version uploaded successfully!'},
|
||||||
|
)
|
||||||
|
def post(self, request, extension_id, *args, **kwargs):
|
||||||
|
serializer = ExtensionVersionSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
version_file = serializer.validated_data['version_file']
|
||||||
|
release_notes = serializer.validated_data.get('release_notes', '')
|
||||||
|
|
||||||
|
extension = Extension.objects.filter(extension_id=extension_id).first()
|
||||||
|
if not extension:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'message': f'Extension "{extension_id}" not found',
|
||||||
|
},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not extension.has_maintainer(user):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'message': f'Extension "{extension_id}" not maintained by user "{user}"',
|
||||||
|
},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a NewVersionView instance to handle file creation
|
||||||
|
new_version_view = NewVersionView(request=request, extension=extension)
|
||||||
|
|
||||||
|
# Pass the version_file to the form
|
||||||
|
form = new_version_view.get_form(FileFormSkipAgreed)
|
||||||
|
form.fields['source'].initial = version_file
|
||||||
|
|
||||||
|
if not form.is_valid():
|
||||||
|
return Response({'message': form.errors}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Create the file instance
|
||||||
|
file_instance = form.save(commit=False)
|
||||||
|
file_instance.user = user
|
||||||
|
file_instance.save()
|
||||||
|
|
||||||
|
# Create the version from the file
|
||||||
|
version = Version.objects.update_or_create(
|
||||||
|
extension=extension,
|
||||||
|
file=file_instance,
|
||||||
|
release_notes=release_notes,
|
||||||
|
**file_instance.parsed_version_fields,
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'message': 'Extension version uploaded successfully!',
|
||||||
|
'extension_id': extension_id,
|
||||||
|
'version_file': version_file.name,
|
||||||
|
'release_notes': version.release_notes,
|
||||||
|
},
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
@ -167,6 +167,16 @@ class FileForm(forms.ModelForm):
|
|||||||
return self.cleaned_data
|
return self.cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class FileFormSkipAgreed(FileForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['agreed_with_terms'].required = False
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
self.cleaned_data['agreed_with_terms'] = True
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
|
||||||
class BaseMediaFileForm(forms.ModelForm):
|
class BaseMediaFileForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = files.models.File
|
model = files.models.File
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
{% endblock hero_breadcrumbs %}
|
{% endblock hero_breadcrumbs %}
|
||||||
|
|
||||||
{% block hero_tabs %}
|
{% block hero_tabs %}
|
||||||
|
<div class="d-flex flex-column-reverse flex-md-row">
|
||||||
<div class="hero-tabs">
|
<div class="hero-tabs">
|
||||||
<a href="#about">
|
<a href="#about">
|
||||||
{% trans "About" %}
|
{% trans "About" %}
|
||||||
@ -23,14 +24,14 @@
|
|||||||
<a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}">
|
<a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}">
|
||||||
{% trans "Version History" %}
|
{% trans "Version History" %}
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
<span class="ms-auto"></span>
|
<div class="btn-row hero-tabs-admin mb-3 mb-md-0">
|
||||||
|
|
||||||
<div class="btn-row">
|
|
||||||
{% if is_maintainer %}
|
{% if is_maintainer %}
|
||||||
|
<div>
|
||||||
<a href="{{ extension.get_manage_url }}" class="btn">
|
<a href="{{ extension.get_manage_url }}" class="btn">
|
||||||
<i class="i-edit"></i> {% trans 'Edit' %}
|
<i class="i-edit"></i> {% trans 'Edit' %}
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if request.user.is_staff %}
|
{% if request.user.is_staff %}
|
||||||
|
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
{% include "common/components/nav_link.html" with name="teams:list" title="Teams" classes="i-users py-2" %}
|
{% include "common/components/nav_link.html" with name="teams:list" title="Teams" classes="i-users py-2" %}
|
||||||
|
|
||||||
|
{% include "common/components/nav_link.html" with name="apitokens:list" title="Tokens" classes="i-lock py-2" %}
|
||||||
|
|
||||||
<div class="nav-pills-divider"></div>
|
<div class="nav-pills-divider"></div>
|
||||||
|
|
||||||
{% include "common/components/nav_link.html" with name="users:my-profile-delete" title="Delete account" classes="i-trash py-2" %}
|
{% include "common/components/nav_link.html" with name="users:my-profile-delete" title="Delete account" classes="i-trash py-2" %}
|
||||||
|
Loading…
Reference in New Issue
Block a user