mirror of
https://github.com/Alexander-D-Karpov/akarpov
synced 2024-11-24 12:23:46 +03:00
added User API Integration token, major updates
This commit is contained in:
parent
5088ab308a
commit
4794664e6a
|
@ -1,11 +1,12 @@
|
||||||
from ckeditor.fields import RichTextFormField
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django_ckeditor_5.fields import CKEditor5Field
|
||||||
|
from django_ckeditor_5.widgets import CKEditor5Widget
|
||||||
|
|
||||||
from akarpov.blog.models import Post, Tag
|
from akarpov.blog.models import Post, Tag
|
||||||
|
|
||||||
|
|
||||||
class PostForm(forms.ModelForm):
|
class PostForm(forms.ModelForm):
|
||||||
body = RichTextFormField(label="")
|
body = CKEditor5Field(config_name="extends")
|
||||||
image = forms.ImageField(help_text="better use horizontal images", required=False)
|
image = forms.ImageField(help_text="better use horizontal images", required=False)
|
||||||
tags = forms.ModelMultipleChoiceField(
|
tags = forms.ModelMultipleChoiceField(
|
||||||
queryset=Tag.objects.all(), widget=forms.CheckboxSelectMultiple, required=True
|
queryset=Tag.objects.all(), widget=forms.CheckboxSelectMultiple, required=True
|
||||||
|
@ -14,3 +15,9 @@ class PostForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Post
|
model = Post
|
||||||
fields = ["title", "body", "image", "tags"]
|
fields = ["title", "body", "image", "tags"]
|
||||||
|
widgets = {
|
||||||
|
"body": CKEditor5Widget(
|
||||||
|
attrs={"class": "django_ckeditor_5"},
|
||||||
|
config_name="extends",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
# Generated by Django 4.0.8 on 2022-11-23 08:30
|
# Generated by Django 4.0.8 on 2022-11-23 08:30
|
||||||
|
|
||||||
import ckeditor_uploader.fields
|
|
||||||
import colorfield.fields
|
import colorfield.fields
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
@ -56,7 +55,7 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("title", models.CharField(max_length=100)),
|
("title", models.CharField(max_length=100)),
|
||||||
("body", ckeditor_uploader.fields.RichTextUploadingField()),
|
("body", models.TextField()),
|
||||||
("slug", models.SlugField(blank=True, max_length=20)),
|
("slug", models.SlugField(blank=True, max_length=20)),
|
||||||
("post_views", models.IntegerField(default=0)),
|
("post_views", models.IntegerField(default=0)),
|
||||||
("rating", models.IntegerField(default=0)),
|
("rating", models.IntegerField(default=0)),
|
||||||
|
|
18
akarpov/blog/migrations/0011_alter_post_body.py
Normal file
18
akarpov/blog/migrations/0011_alter_post_body.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-03-29 15:35
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
import django_ckeditor_5.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("blog", "0010_alter_tag_color"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="post",
|
||||||
|
name="body",
|
||||||
|
field=django_ckeditor_5.fields.CKEditor5Field(),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,8 +1,8 @@
|
||||||
from ckeditor_uploader.fields import RichTextUploadingField
|
|
||||||
from colorfield.fields import ColorField
|
from colorfield.fields import ColorField
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django_ckeditor_5.fields import CKEditor5Field
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
class Post(BaseImageModel, ShortLinkModel, UserHistoryModel):
|
class Post(BaseImageModel, ShortLinkModel, UserHistoryModel):
|
||||||
title = models.CharField(max_length=100, blank=False)
|
title = models.CharField(max_length=100, blank=False)
|
||||||
body = RichTextUploadingField(blank=False)
|
body = CKEditor5Field(blank=False, config_name="extends")
|
||||||
|
|
||||||
creator = models.ForeignKey(User, on_delete=models.CASCADE, related_name="posts")
|
creator = models.ForeignKey(User, on_delete=models.CASCADE, related_name="posts")
|
||||||
|
|
||||||
|
|
56
akarpov/music/api/permissions.py
Normal file
56
akarpov/music/api/permissions.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
from rest_framework import permissions
|
||||||
|
|
||||||
|
from akarpov.users.models import User, UserAPIToken
|
||||||
|
|
||||||
|
|
||||||
|
class GetBaseMusicPermission(permissions.BasePermission):
|
||||||
|
def get_token_data(self, request) -> (dict, User | None):
|
||||||
|
try:
|
||||||
|
token = request.headers["Authorization"]
|
||||||
|
if " " in token:
|
||||||
|
token = token.split(" ")[1]
|
||||||
|
except (KeyError, IndexError):
|
||||||
|
return {
|
||||||
|
"listen": False,
|
||||||
|
"upload": False,
|
||||||
|
"playlist": False,
|
||||||
|
}, None
|
||||||
|
try:
|
||||||
|
token = UserAPIToken.objects.cache().get(token=token)
|
||||||
|
except UserAPIToken.DoesNotExist:
|
||||||
|
return {
|
||||||
|
"listen": False,
|
||||||
|
"upload": False,
|
||||||
|
"playlist": False,
|
||||||
|
}, None
|
||||||
|
if "music" not in token.permissions:
|
||||||
|
return {
|
||||||
|
"listen": False,
|
||||||
|
"upload": False,
|
||||||
|
"playlist": False,
|
||||||
|
}, token.user
|
||||||
|
return token.permissions["music"], token.user
|
||||||
|
|
||||||
|
|
||||||
|
class CanListenToMusic(GetBaseMusicPermission):
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
token_data = self.get_token_data(request)
|
||||||
|
if "listen" in token_data:
|
||||||
|
return token_data["listen"]
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class CanUploadMusic(GetBaseMusicPermission):
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
token_data = self.get_token_data(request)
|
||||||
|
if "upload" in token_data:
|
||||||
|
return token_data["upload"]
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class CanManagePlaylists(GetBaseMusicPermission):
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
token_data = self.get_token_data(request)
|
||||||
|
if "playlist" in token_data:
|
||||||
|
return token_data["playlist"]
|
||||||
|
return False
|
|
@ -100,6 +100,7 @@
|
||||||
<li><a class="dropdown-item {% active_link 'tools:promocodes:activate' %}" href="{% url 'tools:promocodes:activate' %}">Activate promocode</a></li>
|
<li><a class="dropdown-item {% active_link 'tools:promocodes:activate' %}" href="{% url 'tools:promocodes:activate' %}">Activate promocode</a></li>
|
||||||
<li><a class="dropdown-item {% active_link 'users:history' %}" href="{% url 'users:history' %}">History</a></li>
|
<li><a class="dropdown-item {% active_link 'users:history' %}" href="{% url 'users:history' %}">History</a></li>
|
||||||
<li><a class="dropdown-item {% active_link 'users:enable_2fa' %}" href="{% url 'users:enable_2fa' %}">2FA</a></li>
|
<li><a class="dropdown-item {% active_link 'users:enable_2fa' %}" href="{% url 'users:enable_2fa' %}">2FA</a></li>
|
||||||
|
<li><a class="dropdown-item {% active_link 'users:enable_2fa' %}" href="{% url 'users:list_tokens' %}">API Tokens</a></li>
|
||||||
<li>
|
<li>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider">
|
||||||
</li>
|
</li>
|
||||||
|
@ -139,7 +140,7 @@
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer class="row bg-light py-1 mt-auto text-center">
|
<footer class="row bg-light py-1 mt-auto text-center">
|
||||||
<div class="col"> Writen by <a href="https://akarpov.ru/about">sanspie</a>, find source code <a href="https://github.com/Alexander-D-Karpov/akarpov">here</a>. Copyleft akarpov 2023</div>
|
<div class="col"> Writen by <a href="https://akarpov.ru/about">sanspie</a>, find source code <a href="https://github.com/Alexander-D-Karpov/akarpov">here</a>. Copyleft akarpov 2024</div>
|
||||||
</footer>
|
</footer>
|
||||||
<div id="toastContainer" class="toast-container position-fixed bottom-0 end-0 p-3">
|
<div id="toastContainer" class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||||
|
|
||||||
|
|
|
@ -8,13 +8,9 @@
|
||||||
<form class="pt-2" enctype="multipart/form-data" method="POST" id="designer-form">
|
<form class="pt-2" enctype="multipart/form-data" method="POST" id="designer-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.media }}
|
{{ form.media }}
|
||||||
{% for field in form %}
|
{{ form|crispy }}
|
||||||
{{ field|as_crispy_field }}
|
|
||||||
{% endfor %}
|
|
||||||
<div class="mt-4 flex justify-end space-x-4">
|
<div class="mt-4 flex justify-end space-x-4">
|
||||||
<button class="btn btn-secondary" type="submit" id="submit">
|
<input class="btn btn-secondary" type="submit" id="submit" value="Save Changes" />
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
12
akarpov/templates/users/confirm_delete_token.html
Normal file
12
akarpov/templates/users/confirm_delete_token.html
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2>Confirm Deletion</h2>
|
||||||
|
<p>Are you sure you want to delete this token?</p>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-danger">Yes, delete it</button>
|
||||||
|
<a href="{% url 'users:list_tokens' %}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
21
akarpov/templates/users/create_token.html
Normal file
21
akarpov/templates/users/create_token.html
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2>Create API Token</h2>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for field in form %}
|
||||||
|
{% if field.name|slice:":12" == 'permissions_' %}
|
||||||
|
<fieldset>
|
||||||
|
{{ field|as_crispy_field }}
|
||||||
|
</fieldset>
|
||||||
|
{% else %}
|
||||||
|
{{ field|as_crispy_field }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit" class="btn btn-primary mt-2">Create Token</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
27
akarpov/templates/users/list_tokens.html
Normal file
27
akarpov/templates/users/list_tokens.html
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load humanize %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2>My API Tokens</h2>
|
||||||
|
<div class="mb-3">
|
||||||
|
<a href="{% url 'users:create_token' %}" class="btn btn-primary">Create New Token</a>
|
||||||
|
</div>
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for token in tokens %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
{% if token.name %}{{ token.name }}{% else %}<em>Unnamed Token</em>{% endif %}
|
||||||
|
<br>
|
||||||
|
<small>{{ token.token|slice:":5" }}***{{ token.token|slice:"-5:" }}</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'users:view_token' token.id %}" class="btn btn-sm btn-outline-primary">Details</a>
|
||||||
|
<a href="{% url 'users:delete_token' token.id %}" class="btn btn-sm btn-outline-danger">Delete</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="list-group-item">No tokens found.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
17
akarpov/templates/users/token_created.html
Normal file
17
akarpov/templates/users/token_created.html
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2>Token Created Successfully</h2>
|
||||||
|
<p>Your new API token is:</p>
|
||||||
|
<p><code>{{ new_token }}</code><button class="btn" data-clipboard-text="{{ new_token }}">
|
||||||
|
<i style="font-size: 0.8em" class="bi bi-clipboard ml-2"></i>
|
||||||
|
</button></p>
|
||||||
|
<p>Please note it down. You won't be able to see it again.</p>
|
||||||
|
<a href="{% url 'users:list_tokens' %}" class="btn btn-primary mt-4">Back to Tokens</a>
|
||||||
|
</div>
|
||||||
|
<script src="{% static 'js/clipboard.min.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
new ClipboardJS('.btn');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
26
akarpov/templates/users/view_token.html
Normal file
26
akarpov/templates/users/view_token.html
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2>Token Details</h2>
|
||||||
|
{% if token.name %}
|
||||||
|
<p><strong>Name:</strong> {{ token.name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p><strong>Token:</strong> {{ token.token|slice:":5" }}***{{ token.token|slice:"-5:" }}</p>
|
||||||
|
<p><strong>Last Used:</strong> {{ token.last_used|date:"Y-m-d H:i:s" }} ({{ token.last_used|timesince }} ago)</p>
|
||||||
|
<p><strong>Active Until:</strong> {{ token.active_until|date:"Y-m-d" }}</p>
|
||||||
|
<p><strong>Permissions:</strong></p>
|
||||||
|
<ul>
|
||||||
|
{% for app, actions in token.permissions.items %}
|
||||||
|
<li><strong>{{ app|title }}:</strong>
|
||||||
|
<ul>
|
||||||
|
{% for action, value in actions.items %}
|
||||||
|
<li>{{ action|title }}: {{ value|yesno:"✅,❌" }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{# <a href="{% url 'edit_token' token.id %}" class="btn btn-primary">Edit</a> TODO #}
|
||||||
|
<a href="{% url 'users:delete_token' token.id %}" class="btn btn-danger">Delete</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -4,6 +4,7 @@
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .forms import UserAdminChangeForm, UserAdminCreationForm
|
from .forms import UserAdminChangeForm, UserAdminCreationForm
|
||||||
|
from .models import UserAPIToken
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
@ -33,3 +34,19 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||||
)
|
)
|
||||||
list_display = ["username", "is_superuser"]
|
list_display = ["username", "is_superuser"]
|
||||||
search_fields = ["username", "email"]
|
search_fields = ["username", "email"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserAPIToken)
|
||||||
|
class UserAPITokenAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["user", "active_until", "last_used"]
|
||||||
|
search_fields = ["user__username", "token"]
|
||||||
|
list_filter = ["active_until", "last_used"]
|
||||||
|
date_hierarchy = "active_until"
|
||||||
|
raw_id_fields = ["user"]
|
||||||
|
actions = ["deactivate_tokens"]
|
||||||
|
|
||||||
|
def deactivate_tokens(self, request, queryset):
|
||||||
|
queryset.update(active_until=None)
|
||||||
|
self.message_user(request, _("Tokens deactivated"))
|
||||||
|
|
||||||
|
deactivate_tokens.short_description = _("Deactivate selected tokens")
|
||||||
|
|
22
akarpov/users/api/authentification.py
Normal file
22
akarpov/users/api/authentification.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
from rest_framework.authentication import BaseAuthentication
|
||||||
|
|
||||||
|
from akarpov.users.models import UserAPIToken
|
||||||
|
from akarpov.users.tasks import set_last_active_token
|
||||||
|
|
||||||
|
|
||||||
|
class UserTokenAuthentication(BaseAuthentication):
|
||||||
|
def authenticate(self, request):
|
||||||
|
if "Authorization" not in request.headers:
|
||||||
|
return None
|
||||||
|
token = request.headers["Authorization"]
|
||||||
|
if " " in token:
|
||||||
|
token = token.split(" ")[1]
|
||||||
|
try:
|
||||||
|
token = UserAPIToken.objects.cache().get(token=token)
|
||||||
|
except UserAPIToken.DoesNotExist:
|
||||||
|
return None
|
||||||
|
if not token.is_active:
|
||||||
|
return None
|
||||||
|
set_last_active_token.delay(token.token)
|
||||||
|
|
||||||
|
return token.user, token
|
|
@ -1,10 +1,15 @@
|
||||||
|
import json
|
||||||
|
|
||||||
from allauth.account.forms import SignupForm
|
from allauth.account.forms import SignupForm
|
||||||
from allauth.socialaccount.forms import SignupForm as SocialSignupForm
|
from allauth.socialaccount.forms import SignupForm as SocialSignupForm
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth import forms as admin_forms
|
from django.contrib.auth import forms as admin_forms
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.forms import DateInput, TextInput
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from akarpov.users.models import UserAPIToken
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
@ -45,3 +50,80 @@ class UserSocialSignupForm(SocialSignupForm):
|
||||||
|
|
||||||
class OTPForm(forms.Form):
|
class OTPForm(forms.Form):
|
||||||
otp_token = forms.CharField()
|
otp_token = forms.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class TokenCreationForm(forms.ModelForm):
|
||||||
|
permissions = forms.MultipleChoiceField(
|
||||||
|
choices=[], # To be set in __init__
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserAPIToken
|
||||||
|
fields = ["name", "active_until", "permissions"]
|
||||||
|
widgets = {
|
||||||
|
"name": TextInput(attrs={"placeholder": "Token Name (Optional)"}),
|
||||||
|
"active_until": DateInput(attrs={"type": "date"}, format="%d.%m.%Y"),
|
||||||
|
}
|
||||||
|
# Make active_until not required
|
||||||
|
required = {
|
||||||
|
"active_until": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
permissions_context = kwargs.pop("permissions_context", None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if permissions_context:
|
||||||
|
for app, actions in permissions_context.items():
|
||||||
|
field_name = f"permissions_{app}"
|
||||||
|
self.fields[field_name] = forms.MultipleChoiceField(
|
||||||
|
choices=[(action, action) for action in actions.keys()],
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
required=False,
|
||||||
|
label=app.capitalize(),
|
||||||
|
initial=[
|
||||||
|
item
|
||||||
|
for sublist in kwargs.get("initial", {}).get(field_name, [])
|
||||||
|
for item in sublist
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.fields["active_until"].required = False
|
||||||
|
|
||||||
|
def get_permissions_choices(self):
|
||||||
|
permissions_choices = [
|
||||||
|
(f"{app}.{action}", description)
|
||||||
|
for app, actions in UserAPIToken.permission_template.items()
|
||||||
|
for action, description in actions.items()
|
||||||
|
]
|
||||||
|
return permissions_choices
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
structured_permissions = {
|
||||||
|
category: {perm: False for perm in permissions.keys()}
|
||||||
|
for category, permissions in UserAPIToken.permission_template.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
for category in structured_permissions.keys():
|
||||||
|
input_field_name = f"permissions_{category}"
|
||||||
|
if input_field_name in self.data:
|
||||||
|
selected_perms = self.data.getlist(input_field_name)
|
||||||
|
for perm in selected_perms:
|
||||||
|
if perm in structured_permissions[category]:
|
||||||
|
structured_permissions[category][perm] = True
|
||||||
|
|
||||||
|
cleaned_data["permissions"] = json.dumps(structured_permissions)
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
permissions_json = self.cleaned_data.get("permissions", "{}")
|
||||||
|
instance.permissions = json.loads(permissions_json)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
|
@ -6,6 +6,8 @@
|
||||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
|
from akarpov.users.models import UserAPIToken
|
||||||
|
|
||||||
|
|
||||||
class EmailVerificationMiddleware(MiddlewareMixin):
|
class EmailVerificationMiddleware(MiddlewareMixin):
|
||||||
def process_request(self, request):
|
def process_request(self, request):
|
||||||
|
@ -21,12 +23,20 @@ def __init__(self, get_response):
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
response = self.get_response(request)
|
response = self.get_response(request)
|
||||||
if (
|
if request.path_info == "/api/v1/auth/token/":
|
||||||
request.path_info.startswith("/api/v1/music/")
|
|
||||||
or request.path_info == "/api/v1/auth/token/"
|
|
||||||
):
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
if "Authorization" in request.headers:
|
||||||
|
try:
|
||||||
|
token = request.headers["Authorization"]
|
||||||
|
if " " in token:
|
||||||
|
token = token.split(" ")[1]
|
||||||
|
token = UserAPIToken.objects.cache().get(token=token)
|
||||||
|
request.token_permissions = token.permissions
|
||||||
|
return response
|
||||||
|
except (KeyError, AttributeError, UserAPIToken.DoesNotExist):
|
||||||
|
...
|
||||||
|
|
||||||
# Check user is authenticated and OTP token input is not completed
|
# Check user is authenticated and OTP token input is not completed
|
||||||
is_authenticated = request.user.is_authenticated
|
is_authenticated = request.user.is_authenticated
|
||||||
otp_not_verified = not request.session.get("otp_verified", False)
|
otp_not_verified = not request.session.get("otp_verified", False)
|
||||||
|
|
40
akarpov/users/migrations/0015_userapitoken.py
Normal file
40
akarpov/users/migrations/0015_userapitoken.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-03-29 15:18
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("users", "0014_alter_user_agree_data_to_be_sold"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserAPIToken",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("token", models.CharField(db_index=True, max_length=255, unique=True)),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("active_until", models.DateTimeField(null=True)),
|
||||||
|
("permissions", models.JSONField(default=dict)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="api_tokens",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
17
akarpov/users/migrations/0016_userapitoken_last_used.py
Normal file
17
akarpov/users/migrations/0016_userapitoken_last_used.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-03-29 18:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("users", "0015_userapitoken"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userapitoken",
|
||||||
|
name="last_used",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
17
akarpov/users/migrations/0017_userapitoken_name.py
Normal file
17
akarpov/users/migrations/0017_userapitoken_name.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-03-29 19:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("users", "0016_userapitoken_last_used"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userapitoken",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,9 +1,12 @@
|
||||||
|
import secrets
|
||||||
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from akarpov.common.models import BaseImageModel
|
from akarpov.common.models import BaseImageModel
|
||||||
|
@ -78,6 +81,46 @@ def __str__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class UserNotification:
|
class UserAPIToken(models.Model):
|
||||||
# TODO: add notification system
|
name = models.CharField(max_length=255, blank=True, null=True)
|
||||||
...
|
user = models.ForeignKey(
|
||||||
|
"User", related_name="api_tokens", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
token = models.CharField(max_length=255, unique=True, db_index=True)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
active_until = models.DateTimeField(null=True)
|
||||||
|
permissions = models.JSONField(default=dict)
|
||||||
|
last_used = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
permission_template = {
|
||||||
|
"music": {
|
||||||
|
"listen": "Listen to music",
|
||||||
|
"upload": "Upload music",
|
||||||
|
"playlist": "Manage playlists",
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"edit": "Edit user profile",
|
||||||
|
"delete": "Delete user profile",
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"shorten": "Shorten links",
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"upload": "Upload files",
|
||||||
|
"download": "Download files",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.token
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
return self.active_until is None or self.active_until > timezone.now()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_token():
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
while UserAPIToken.objects.filter(token=token).exists():
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
return token
|
||||||
|
|
11
akarpov/users/tasks.py
Normal file
11
akarpov/users/tasks.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
from celery import shared_task
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from akarpov.users.models import UserAPIToken
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def set_last_active_token(token: str):
|
||||||
|
token = UserAPIToken.objects.get(token=token)
|
||||||
|
token.last_used = timezone.now()
|
||||||
|
token.save()
|
|
@ -1,13 +1,17 @@
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
from akarpov.users.views import (
|
from akarpov.users.views import (
|
||||||
|
create_token,
|
||||||
|
delete_token,
|
||||||
enable_2fa_view,
|
enable_2fa_view,
|
||||||
enforce_otp_login,
|
enforce_otp_login,
|
||||||
|
list_tokens,
|
||||||
user_detail_view,
|
user_detail_view,
|
||||||
user_history_delete_view,
|
user_history_delete_view,
|
||||||
user_history_view,
|
user_history_view,
|
||||||
user_redirect_view,
|
user_redirect_view,
|
||||||
user_update_view,
|
user_update_view,
|
||||||
|
view_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = "users"
|
app_name = "users"
|
||||||
|
@ -17,7 +21,11 @@
|
||||||
path("update/", view=user_update_view, name="update"),
|
path("update/", view=user_update_view, name="update"),
|
||||||
path("history/", view=user_history_view, name="history"),
|
path("history/", view=user_history_view, name="history"),
|
||||||
path("history/delete", view=user_history_delete_view, name="history_delete"),
|
path("history/delete", view=user_history_delete_view, name="history_delete"),
|
||||||
path("<str:username>/", view=user_detail_view, name="detail"),
|
path("<str:username>", view=user_detail_view, name="detail"),
|
||||||
path("2fa/login", enforce_otp_login, name="enforce_otp_login"),
|
path("2fa/login", enforce_otp_login, name="enforce_otp_login"),
|
||||||
path("2fa/enable", enable_2fa_view, name="enable_2fa"),
|
path("2fa/enable", enable_2fa_view, name="enable_2fa"),
|
||||||
|
path("tokens/", list_tokens, name="list_tokens"),
|
||||||
|
path("tokens/create/", create_token, name="create_token"),
|
||||||
|
path("tokens/<int:token_id>/", view_token, name="view_token"),
|
||||||
|
path("tokens/<int:token_id>/delete/", delete_token, name="delete_token"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,16 +5,16 @@
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect, QueryDict
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView, RedirectView, UpdateView
|
from django.views.generic import DetailView, ListView, RedirectView, UpdateView
|
||||||
from django_otp import user_has_device
|
from django_otp import user_has_device
|
||||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
|
|
||||||
from akarpov.users.forms import OTPForm
|
from akarpov.users.forms import OTPForm, TokenCreationForm
|
||||||
from akarpov.users.models import UserHistory
|
from akarpov.users.models import UserAPIToken, UserHistory
|
||||||
from akarpov.users.services.history import create_history_warning_note
|
from akarpov.users.services.history import create_history_warning_note
|
||||||
from akarpov.users.services.two_factor import generate_qr_code
|
from akarpov.users.services.two_factor import generate_qr_code
|
||||||
from akarpov.users.themes.models import Theme
|
from akarpov.users.themes.models import Theme
|
||||||
|
@ -203,3 +203,70 @@ def enforce_otp_login(request):
|
||||||
else:
|
else:
|
||||||
form = OTPForm()
|
form = OTPForm()
|
||||||
return render(request, "users/otp_verify.html", {"form": form})
|
return render(request, "users/otp_verify.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def list_tokens(request):
|
||||||
|
tokens = UserAPIToken.objects.filter(user=request.user).order_by("-last_used")
|
||||||
|
return render(request, "users/list_tokens.html", {"tokens": tokens})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def create_token(request):
|
||||||
|
initial_data = {}
|
||||||
|
|
||||||
|
# Обработка параметров 'name' и 'active_until'
|
||||||
|
if "name" in request.GET:
|
||||||
|
initial_data["name"] = request.GET["name"]
|
||||||
|
if "active_until" in request.GET:
|
||||||
|
initial_data["active_until"] = request.GET["active_until"]
|
||||||
|
|
||||||
|
# Создаем QueryDict для разрешений, чтобы правильно обработать повторяющиеся ключи
|
||||||
|
permissions_query_dict = QueryDict("", mutable=True)
|
||||||
|
|
||||||
|
# Разбор параметров разрешений
|
||||||
|
permissions = request.GET.getlist("permissions")
|
||||||
|
for perm in permissions:
|
||||||
|
category, permission = perm.split(".")
|
||||||
|
permissions_query_dict.update({f"permissions_{category}": [permission]})
|
||||||
|
|
||||||
|
# Переводим QueryDict в обычный словарь для использования в initial
|
||||||
|
permissions_data = {key: value for key, value in permissions_query_dict.lists()}
|
||||||
|
|
||||||
|
initial_data.update(permissions_data)
|
||||||
|
|
||||||
|
for key, value_list in permissions_data.items():
|
||||||
|
initial_data[key] = [item for sublist in value_list for item in sublist]
|
||||||
|
|
||||||
|
form = TokenCreationForm(
|
||||||
|
initial=initial_data, permissions_context=UserAPIToken.permission_template
|
||||||
|
)
|
||||||
|
if request.method == "POST":
|
||||||
|
print(request.POST)
|
||||||
|
form = TokenCreationForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
new_token = form.save(commit=False)
|
||||||
|
new_token.user = request.user
|
||||||
|
new_token.token = UserAPIToken.generate_token()
|
||||||
|
new_token.save()
|
||||||
|
token_created = new_token.token
|
||||||
|
return render(
|
||||||
|
request, "users/token_created.html", {"new_token": token_created}
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(request, "users/create_token.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def view_token(request, token_id):
|
||||||
|
token = get_object_or_404(UserAPIToken, id=token_id, user=request.user)
|
||||||
|
return render(request, "users/view_token.html", {"token": token})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def delete_token(request, token_id):
|
||||||
|
token = get_object_or_404(UserAPIToken, id=token_id, user=request.user)
|
||||||
|
if request.method == "POST":
|
||||||
|
token.delete()
|
||||||
|
return redirect("users:list_tokens")
|
||||||
|
return render(request, "users/confirm_delete_token.html", {"token": token})
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Base settings to build other settings files upon.
|
Base settings to build other settings files upon.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import environ
|
import environ
|
||||||
|
@ -78,6 +79,7 @@
|
||||||
"auth.permission": {"ops": "all", "timeout": 60 * 15},
|
"auth.permission": {"ops": "all", "timeout": 60 * 15},
|
||||||
"music.*": {"ops": ("fetch", "get", "list"), "timeout": 60 * 15},
|
"music.*": {"ops": ("fetch", "get", "list"), "timeout": 60 * 15},
|
||||||
"otp_totp.totpdevice": {"ops": "all", "timeout": 15 * 60},
|
"otp_totp.totpdevice": {"ops": "all", "timeout": 15 * 60},
|
||||||
|
"users.userapitoken": {"ops": "all", "timeout": 20 * 60},
|
||||||
}
|
}
|
||||||
CACHEOPS_REDIS = env.str("REDIS_URL")
|
CACHEOPS_REDIS = env.str("REDIS_URL")
|
||||||
|
|
||||||
|
@ -131,8 +133,7 @@
|
||||||
"rest_framework.authtoken",
|
"rest_framework.authtoken",
|
||||||
"corsheaders",
|
"corsheaders",
|
||||||
"drf_spectacular",
|
"drf_spectacular",
|
||||||
"ckeditor",
|
"django_ckeditor_5",
|
||||||
"ckeditor_uploader",
|
|
||||||
"colorfield",
|
"colorfield",
|
||||||
"polymorphic",
|
"polymorphic",
|
||||||
"cacheops",
|
"cacheops",
|
||||||
|
@ -510,6 +511,7 @@
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
"rest_framework.authentication.SessionAuthentication",
|
"rest_framework.authentication.SessionAuthentication",
|
||||||
"rest_framework.authentication.TokenAuthentication",
|
"rest_framework.authentication.TokenAuthentication",
|
||||||
|
"akarpov.users.api.authentification.UserTokenAuthentication",
|
||||||
),
|
),
|
||||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||||
|
@ -530,24 +532,128 @@
|
||||||
|
|
||||||
# CKEDITOR
|
# CKEDITOR
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
CKEDITOR_UPLOAD_PATH = "uploads/"
|
CKEDITOR_5_UPLOAD_PATH = "uploads/"
|
||||||
CKEDITOR_CONFIGS = {
|
CKEDITOR_5_CONFIGS = {
|
||||||
"default": {
|
"default": {
|
||||||
"width": "full",
|
"toolbar": [
|
||||||
"extra_plugins": [
|
"heading",
|
||||||
"autosave",
|
"|",
|
||||||
"autogrow",
|
"bold",
|
||||||
"autolink",
|
"italic",
|
||||||
"autoembed",
|
"link",
|
||||||
"clipboard",
|
"bulletedList",
|
||||||
"dialog",
|
"numberedList",
|
||||||
"dialogui",
|
"blockQuote",
|
||||||
|
"imageUpload",
|
||||||
],
|
],
|
||||||
"autosave": {
|
|
||||||
"autoLoad": True,
|
|
||||||
"delay": 60,
|
|
||||||
"NotOlderThen": 20,
|
|
||||||
},
|
},
|
||||||
|
"extends": {
|
||||||
|
"blockToolbar": [
|
||||||
|
"paragraph",
|
||||||
|
"heading1",
|
||||||
|
"heading2",
|
||||||
|
"heading3",
|
||||||
|
"|",
|
||||||
|
"bulletedList",
|
||||||
|
"numberedList",
|
||||||
|
"|",
|
||||||
|
"blockQuote",
|
||||||
|
],
|
||||||
|
"toolbar": [
|
||||||
|
"heading",
|
||||||
|
"|",
|
||||||
|
"outdent",
|
||||||
|
"indent",
|
||||||
|
"|",
|
||||||
|
"bold",
|
||||||
|
"italic",
|
||||||
|
"link",
|
||||||
|
"underline",
|
||||||
|
"strikethrough",
|
||||||
|
"code",
|
||||||
|
"subscript",
|
||||||
|
"superscript",
|
||||||
|
"highlight",
|
||||||
|
"|",
|
||||||
|
"codeBlock",
|
||||||
|
"sourceEditing",
|
||||||
|
"insertImage",
|
||||||
|
"bulletedList",
|
||||||
|
"numberedList",
|
||||||
|
"todoList",
|
||||||
|
"|",
|
||||||
|
"blockQuote",
|
||||||
|
"imageUpload",
|
||||||
|
"|",
|
||||||
|
"fontSize",
|
||||||
|
"fontFamily",
|
||||||
|
"fontColor",
|
||||||
|
"fontBackgroundColor",
|
||||||
|
"mediaEmbed",
|
||||||
|
"removeFormat",
|
||||||
|
"insertTable",
|
||||||
|
],
|
||||||
|
"image": {
|
||||||
|
"toolbar": [
|
||||||
|
"imageTextAlternative",
|
||||||
|
"|",
|
||||||
|
"imageStyle:alignLeft",
|
||||||
|
"imageStyle:alignRight",
|
||||||
|
"imageStyle:alignCenter",
|
||||||
|
"imageStyle:side",
|
||||||
|
"|",
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"full",
|
||||||
|
"side",
|
||||||
|
"alignLeft",
|
||||||
|
"alignRight",
|
||||||
|
"alignCenter",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"contentToolbar": [
|
||||||
|
"tableColumn",
|
||||||
|
"tableRow",
|
||||||
|
"mergeTableCells",
|
||||||
|
"tableProperties",
|
||||||
|
"tableCellProperties",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"heading": {
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"model": "paragraph",
|
||||||
|
"title": "Paragraph",
|
||||||
|
"class": "ck-heading_paragraph",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "heading1",
|
||||||
|
"view": "h1",
|
||||||
|
"title": "Heading 1",
|
||||||
|
"class": "ck-heading_heading1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "heading2",
|
||||||
|
"view": "h2",
|
||||||
|
"title": "Heading 2",
|
||||||
|
"class": "ck-heading_heading2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "heading3",
|
||||||
|
"view": "h3",
|
||||||
|
"title": "Heading 3",
|
||||||
|
"class": "ck-heading_heading3",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"properties": {
|
||||||
|
"styles": "true",
|
||||||
|
"startIndex": "true",
|
||||||
|
"reversed": "true",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -43,7 +43,9 @@
|
||||||
path("forms/", include("akarpov.test_platform.urls", namespace="forms")),
|
path("forms/", include("akarpov.test_platform.urls", namespace="forms")),
|
||||||
path("tools/", include("akarpov.tools.urls", namespace="tools")),
|
path("tools/", include("akarpov.tools.urls", namespace="tools")),
|
||||||
path("gallery/", include("akarpov.gallery.urls", namespace="gallery")),
|
path("gallery/", include("akarpov.gallery.urls", namespace="gallery")),
|
||||||
path("ckeditor/", include("ckeditor_uploader.urls")),
|
path(
|
||||||
|
"ckeditor5/", include("django_ckeditor_5.urls"), name="ck_editor_5_upload_file"
|
||||||
|
),
|
||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
path("accounts/login/", OTPLoginView.as_view(), name="account_login"),
|
path("accounts/login/", OTPLoginView.as_view(), name="account_login"),
|
||||||
path("", include("akarpov.blog.urls", namespace="blog")),
|
path("", include("akarpov.blog.urls", namespace="blog")),
|
||||||
|
|
393
poetry.lock
generated
393
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
|
@ -27,7 +27,6 @@ django-allauth = "^0.54.0"
|
||||||
django-crispy-forms = "^2.1"
|
django-crispy-forms = "^2.1"
|
||||||
crispy-bootstrap5 = "^0.7"
|
crispy-bootstrap5 = "^0.7"
|
||||||
django-redis = "^5.2.0"
|
django-redis = "^5.2.0"
|
||||||
django-ckeditor = "^6.5.1"
|
|
||||||
django-colorfield = "^0.11.0"
|
django-colorfield = "^0.11.0"
|
||||||
djangorestframework = "^3.14.0"
|
djangorestframework = "^3.14.0"
|
||||||
django-rest-auth = "^0.9.5"
|
django-rest-auth = "^0.9.5"
|
||||||
|
@ -121,6 +120,7 @@ python-levenshtein = "^0.23.0"
|
||||||
pylast = "^5.2.0"
|
pylast = "^5.2.0"
|
||||||
textract = {git = "https://github.com/Alexander-D-Karpov/textract.git", branch = "master"}
|
textract = {git = "https://github.com/Alexander-D-Karpov/textract.git", branch = "master"}
|
||||||
librosa = "^0.10.1"
|
librosa = "^0.10.1"
|
||||||
|
django-ckeditor-5 = "^0.2.12"
|
||||||
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|
Loading…
Reference in New Issue
Block a user