Use a materialized Extension.latest_version field instead of a dynamic property #152

Merged
Oleg-Komarov merged 15 commits from latest-version-field into main 2024-05-27 17:58:56 +02:00
30 changed files with 711 additions and 63 deletions
Showing only changes of commit f61aff7a09 - Show all commits

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

@ -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)

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

@ -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,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

@ -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

@ -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 %}

View File

@ -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 %}

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

@ -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

@ -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'),

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

@ -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

@ -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>

View File

@ -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" %}