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',
|
||||
'reviewers',
|
||||
'stats',
|
||||
'apitokens',
|
||||
'taggit',
|
||||
'drf_spectacular',
|
||||
'drf_spectacular_sidecar',
|
||||
@ -273,6 +274,8 @@ ACTSTREAM_SETTINGS = {
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': ('apitokens.authentication.UserTokenAuthentication',),
|
||||
'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',),
|
||||
}
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
|
@ -40,6 +40,7 @@ urlpatterns = [
|
||||
path('', include('teams.urls')),
|
||||
path('', include('reviewers.urls')),
|
||||
path('', include('notifications.urls')),
|
||||
path('', include('apitokens.urls')),
|
||||
path('api/swagger/', RedirectView.as_view(url='/api/v1/swagger/')),
|
||||
path('api/v1/', SpectacularAPIView.as_view(), name='schema_v1'),
|
||||
path('api/v1/swagger/', SpectacularSwaggerView.as_view(url_name='schema_v1'), name='swagger'),
|
||||
|
@ -231,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)
|
||||
@ -252,6 +256,7 @@
|
||||
.details
|
||||
+padding(3, x)
|
||||
flex: 1
|
||||
width: 100%
|
||||
|
||||
label
|
||||
font-size: var(--fs-sm)
|
||||
|
@ -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)
|
||||
|
@ -3,9 +3,9 @@
|
||||
{% load common %}
|
||||
|
||||
{% with default_title="Blender Extensions" %}
|
||||
{% with default_author="Blender Institute" %}
|
||||
{% with default_author="Blender Foundation" %}
|
||||
|
||||
{% with default_description="Blender Extensions is a web based service developed by Blender Institute that allows people to share open source add-ons for Blender." %}
|
||||
{% with default_description="Blender Extensions is a web based service developed by Blender Foundation that allows people to share open source add-ons for Blender." %}
|
||||
|
||||
{% if not image_url %}
|
||||
{% absolute_url default_image_path as image_url %}
|
||||
|
@ -1,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
|
||||
|
@ -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">
|
||||
|
@ -49,36 +49,37 @@
|
||||
</div>
|
||||
|
||||
{% block hero_tabs %}
|
||||
<nav 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>
|
||||
{% if latest.release_notes %}
|
||||
<a href="{{ extension.get_absolute_url }}#new">
|
||||
{% trans "What's New" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if latest.permissions.all %}
|
||||
<a href="{{ extension.get_absolute_url }}#permissions">
|
||||
{% trans "Permissions" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if extension.is_approved %}
|
||||
<a href="{{ extension.get_ratings_url }}" class="{% if '/reviews/' in request.get_full_path %}is-active{% endif %}">
|
||||
{% trans "Reviews" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}">
|
||||
{% trans "Version History" %}
|
||||
</a>
|
||||
|
||||
<span class="ms-auto"></span>
|
||||
|
||||
<div class="btn-row">
|
||||
{% if is_maintainer %}
|
||||
<a href="{{ extension.get_manage_url }}" class="btn">
|
||||
<i class="i-edit"></i> {% trans 'Edit' %}
|
||||
<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>
|
||||
{% if latest.release_notes %}
|
||||
<a href="{{ extension.get_absolute_url }}#new">
|
||||
{% trans "What's New" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if latest.permissions.all %}
|
||||
<a href="{{ extension.get_absolute_url }}#permissions">
|
||||
{% trans "Permissions" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if extension.is_approved %}
|
||||
<a href="{{ extension.get_ratings_url }}" class="{% if '/reviews/' in request.get_full_path %}is-active{% endif %}">
|
||||
{% trans "Reviews" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}">
|
||||
{% trans "Version History" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="btn-row hero-tabs-admin mb-3 mb-md-0">
|
||||
{% if is_maintainer %}
|
||||
<div>
|
||||
<a href="{{ extension.get_manage_url }}" class="btn">
|
||||
<i class="i-edit"></i> {% trans 'Edit' %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
|
@ -51,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 %}
|
||||
|
@ -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">
|
||||
|
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
|
||||
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'),
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -13,36 +13,37 @@
|
||||
{% endblock hero_breadcrumbs %}
|
||||
|
||||
{% block hero_tabs %}
|
||||
<div class="hero-tabs">
|
||||
<a href="#about">
|
||||
{% trans "About" %}
|
||||
</a>
|
||||
<a href="#activity">
|
||||
{% trans "Activity" %}
|
||||
</a>
|
||||
<a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}">
|
||||
{% trans "Version History" %}
|
||||
</a>
|
||||
|
||||
<span class="ms-auto"></span>
|
||||
|
||||
<div class="btn-row">
|
||||
{% if is_maintainer %}
|
||||
<a href="{{ extension.get_manage_url }}" class="btn">
|
||||
<i class="i-edit"></i> {% trans 'Edit' %}
|
||||
<div class="d-flex flex-column-reverse flex-md-row">
|
||||
<div class="hero-tabs">
|
||||
<a href="#about">
|
||||
{% trans "About" %}
|
||||
</a>
|
||||
<a href="#activity">
|
||||
{% trans "Activity" %}
|
||||
</a>
|
||||
<a href="{{ extension.get_versions_url }}" class="{% if '/versions/' in request.get_full_path %}is-active{% endif %}">
|
||||
{% trans "Version History" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="btn-row hero-tabs-admin mb-3 mb-md-0">
|
||||
{% if is_maintainer %}
|
||||
<div>
|
||||
<a href="{{ extension.get_manage_url }}" class="btn">
|
||||
<i class="i-edit"></i> {% trans 'Edit' %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-admin dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="extension-admin-menu">
|
||||
<span>Admin</span>
|
||||
<i class="i-chevron-down"></i>
|
||||
</button>
|
||||
<ul id="extension-admin-menu" class="dropdown-menu dropdown-menu-right js-dropdown-menu">
|
||||
{% include "extensions/components/dropdown_admin.html" %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-admin dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="extension-admin-menu">
|
||||
<span>Admin</span>
|
||||
<i class="i-chevron-down"></i>
|
||||
</button>
|
||||
<ul id="extension-admin-menu" class="dropdown-menu dropdown-menu-right js-dropdown-menu">
|
||||
{% include "extensions/components/dropdown_admin.html" %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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="apitokens:list" title="Tokens" classes="i-lock py-2" %}
|
||||
|
||||
<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" %}
|
||||
|
Loading…
Reference in New Issue
Block a user