Compare commits

...

9 Commits

38 changed files with 2163 additions and 1715 deletions

View File

@ -1,11 +1,12 @@
from ckeditor.fields import RichTextFormField
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
class PostForm(forms.ModelForm):
body = RichTextFormField(label="")
body = CKEditor5Field(config_name="extends")
image = forms.ImageField(help_text="better use horizontal images", required=False)
tags = forms.ModelMultipleChoiceField(
queryset=Tag.objects.all(), widget=forms.CheckboxSelectMultiple, required=True
@ -14,3 +15,9 @@ class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ["title", "body", "image", "tags"]
widgets = {
"body": CKEditor5Widget(
attrs={"class": "django_ckeditor_5"},
config_name="extends",
)
}

View File

@ -1,6 +1,5 @@
# Generated by Django 4.0.8 on 2022-11-23 08:30
import ckeditor_uploader.fields
import colorfield.fields
from django.conf import settings
from django.db import migrations, models
@ -56,7 +55,7 @@ class Migration(migrations.Migration):
),
),
("title", models.CharField(max_length=100)),
("body", ckeditor_uploader.fields.RichTextUploadingField()),
("body", models.TextField()),
("slug", models.SlugField(blank=True, max_length=20)),
("post_views", models.IntegerField(default=0)),
("rating", models.IntegerField(default=0)),

View 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(),
),
]

View File

@ -1,8 +1,8 @@
from ckeditor_uploader.fields import RichTextUploadingField
from colorfield.fields import ColorField
from django.db import models
from django.db.models import Count
from django.urls import reverse
from django_ckeditor_5.fields import CKEditor5Field
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
@ -16,7 +16,7 @@
class Post(BaseImageModel, ShortLinkModel, UserHistoryModel):
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")

View 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

View File

@ -22,9 +22,17 @@ class Meta:
class ListAlbumSerializer(serializers.ModelSerializer):
authors = serializers.SerializerMethodField(method_name="get_authors")
@extend_schema_field(ListAuthorSerializer(many=True))
def get_authors(self, obj):
return ListAuthorSerializer(
Author.objects.cache().filter(albums__id=obj.id), many=True
).data
class Meta:
model = Album
fields = ["name", "slug", "image_cropped"]
fields = ["name", "slug", "image_cropped", "authors"]
class SongSerializer(serializers.ModelSerializer):
@ -133,10 +141,32 @@ class Meta:
class PlaylistSerializer(SetUserModelSerializer):
creator = UserPublicInfoSerializer(read_only=True)
images = serializers.SerializerMethodField(method_name="get_images")
@extend_schema_field(serializers.ListField(child=serializers.ImageField()))
def get_images(self, obj):
# Get distinct album images from songs
images = (
Song.objects.cache()
.filter(
playlists__id__in=PlaylistSong.objects.cache()
.filter(playlist=obj)
.values("id")
)
.values_list("album__image", flat=True)
.distinct()[:4]
)
# Build absolute URI for each image
images = [
self.context["request"].build_absolute_uri(image)
for image in images
if image
]
return images
class Meta:
model = Playlist
fields = ["name", "length", "slug", "private", "creator"]
fields = ["name", "length", "slug", "images", "private", "creator"]
extra_kwargs = {
"slug": {"read_only": True},
"creator": {"read_only": True},
@ -144,13 +174,21 @@ class Meta:
}
class FullPlaylistSerializer(serializers.ModelSerializer):
songs = ListSongSerializer(many=True, read_only=True)
class FullPlaylistSerializer(PlaylistSerializer):
songs = serializers.SerializerMethodField(method_name="get_songs")
creator = UserPublicInfoSerializer(read_only=True)
@extend_schema_field(ListSongSerializer(many=True))
def get_songs(self, obj):
return ListSongSerializer(
Song.objects.cache().filter(playlists__id=obj.id),
many=True,
context=self.context,
).data
class Meta:
model = Playlist
fields = ["name", "private", "creator", "songs"]
fields = ["name", "private", "creator", "images", "songs"]
extra_kwargs = {
"slug": {"read_only": True},
"creator": {"read_only": True},
@ -158,8 +196,8 @@ class Meta:
class AddSongToPlaylistSerializer(serializers.ModelSerializer):
song = serializers.SlugField()
playlist = serializers.SlugField()
song = serializers.SlugField(write_only=True)
playlist = serializers.SlugField(write_only=True)
class Meta:
model = Playlist

View File

@ -39,24 +39,96 @@ class SongDocument(Document):
},
)
meta = fields.ObjectField(dynamic=True) # Added meta field here as dynamic object
meta = fields.ObjectField(dynamic=True)
class Index:
name = "songs"
settings = {"number_of_shards": 1, "number_of_replicas": 0}
# settings = {
# "number_of_shards": 1,
# "number_of_replicas": 0,
# "analysis": {
# "analyzer": {
# "russian_icu": {
# "type": "custom",
# "tokenizer": "icu_tokenizer",
# "filter": ["icu_folding","icu_normalizer"]
# }
# }
# }
# } TODO
settings = {
"number_of_shards": 1,
"number_of_replicas": 0,
"analysis": {
"filter": {
"russian_stop": {
"type": "stop",
"stopwords": "_russian_",
},
"russian_keywords": {
"type": "keyword_marker",
"keywords": ["пример"],
},
"russian_stemmer": {
"type": "stemmer",
"language": "russian",
},
"english_stemmer": {
"type": "stemmer",
"language": "english",
},
"autocomplete_filter": {
"type": "edge_ngram",
"min_gram": 1,
"max_gram": 20,
},
"synonym_filter": {
"type": "synonym",
"synonyms": [
"бит, трек => песня",
"песня, музыка, мелодия, композиция",
"певец, исполнитель, артист, музыкант",
"альбом, диск, пластинка, сборник, коллекция",
],
},
},
"analyzer": {
"russian": {
"tokenizer": "standard",
"filter": [
"russian_stop",
"russian_keywords",
"russian_stemmer",
],
},
"russian_with_synonyms_and_stemming": {
"tokenizer": "standard",
"filter": [
"lowercase",
"russian_stop",
"russian_keywords",
"russian_stemmer",
"synonym_filter",
],
},
"english_with_stemming": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"english_stemmer",
],
},
"autocomplete_with_stemming": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"autocomplete_filter",
"english_stemmer", # Apply English stemming for autocomplete
"russian_stemmer", # Include Russian stemming if applicable
],
},
"search_synonym_with_stemming": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"synonym_filter",
"english_stemmer", # Apply English stemming for synonym search
"russian_stemmer", # Include Russian stemming if processing Russian synonyms
],
},
},
},
}
class Django:
model = Song

View File

@ -72,6 +72,16 @@ def load_track(
name = search_info["title"]
elif not name:
name = process_track_name(" ".join(p_name.strip().split("-")))
clear_name = [
"(Official HD Video)",
"(Official Music Video)",
"(Official Video)",
"Official Video",
"Official Music Video",
"Official HD Video",
]
for c in clear_name:
name = name.replace(c, "")
if not name:
name = orig_name

View File

@ -65,7 +65,7 @@ def process_mp3_file(path: str, user_id: int) -> None:
def analyze_music_loudness(mp3_file):
y, sr = librosa.load(mp3_file, sr=None)
frame_length = int(0.5 * sr)
frame_length = int(0.1 * sr)
stft = np.abs(librosa.stft(y, n_fft=frame_length, hop_length=frame_length))
rms_energy = librosa.feature.rms(
S=stft, frame_length=frame_length, hop_length=frame_length

View File

@ -360,6 +360,7 @@ def update_author_info(author: Author) -> None:
def search_all_platforms(track_name: str) -> dict:
print(track_name)
session = spotipy.Spotify(
auth_manager=spotipy.SpotifyClientCredentials(
client_id=settings.MUSIC_SPOTIFY_ID,

View File

@ -13,9 +13,9 @@ def search_song(query):
ES_Q(
"multi_match",
query=query,
fields=["name^3", "authors.name^2", "album.name"],
fields=["name^5", "authors.name^3", "album.name^3"],
fuzziness="AUTO",
), # Change here
),
ES_Q("wildcard", name__raw=f"*{query.lower()}*"),
ES_Q(
"nested",
@ -27,6 +27,7 @@ def search_song(query):
path="album",
query=ES_Q("wildcard", album__name__raw=f"*{query.lower()}*"),
),
ES_Q("wildcard", meta__raw=f"*{query.lower()}*"),
],
minimum_should_match=1,
)

View File

@ -157,22 +157,25 @@ def download_from_youtube_link(link: str, user_id: int) -> Song:
print(f"[processing] loading {title}")
info = search_all_platforms(title)
if not info["album_image"].startswith("/"):
r = requests.get(info["album_image"])
img_pth = str(
settings.MEDIA_ROOT
+ f"/{info['album_image'].split('/')[-1]}_{str(randint(100, 999))}"
)
with open(img_pth, "wb") as f:
f.write(r.content)
if "album_image" in info and info["album_image"]:
if not info["album_image"].startswith("/"):
r = requests.get(info["album_image"])
img_pth = str(
settings.MEDIA_ROOT
+ f"/{info['album_image'].split('/')[-1]}_{str(randint(100, 999))}"
)
with open(img_pth, "wb") as f:
f.write(r.content)
im = Image.open(img_pth)
im.save(str(f"{img_pth}.png"))
im = Image.open(img_pth)
im.save(str(f"{img_pth}.png"))
os.remove(img_pth)
img_pth = f"{img_pth}.png"
os.remove(img_pth)
img_pth = f"{img_pth}.png"
else:
img_pth = info["album_image"]
else:
img_pth = info["album_image"]
img_pth = None
if "genre" in info:
song = load_track(
path,

View File

@ -1,4 +1,5 @@
from datetime import timedelta
from urllib.parse import parse_qs, urlparse
import pylast
import spotipy
@ -28,7 +29,7 @@
logger = structlog.get_logger(__name__)
@shared_task
@shared_task(soft_time_limit=60 * 20, time_limit=60 * 30)
def list_tracks(url, user_id):
if "music.youtube.com" in url or "youtu.be" in url:
url = url.replace("music.youtube.com", "youtube.com")
@ -42,7 +43,6 @@ def list_tracks(url, user_id):
ytmusic = ytmusicapi.YTMusic()
channel_id = url.split("/")[-1]
channel_songs = ytmusic.get_artist(channel_id)["songs"]["results"]
print(channel_songs)
for song in channel_songs:
process_yb.apply_async(
@ -54,9 +54,19 @@ def list_tracks(url, user_id):
elif "playlist" in url or "&list=" in url:
ytmusic = ytmusicapi.YTMusic()
playlist_id = url.split("=")[-1]
playlist_songs = ytmusic.get_playlist(playlist_id)["tracks"]["results"]
# Parse the URL and the query string
parsed_url = urlparse(url)
parsed_qs = parse_qs(parsed_url.query)
# Get the playlist ID from the parsed query string
playlist_id = parsed_qs.get("list", [None])[0]
if playlist_id:
playlist_songs = ytmusic.get_playlist(playlist_id)["tracks"]
else:
raise ValueError("No playlist ID found in the URL.")
for song in playlist_songs:
process_yb.apply_async(
kwargs={

View File

@ -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 '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:list_tokens' %}">API Tokens</a></li>
<li>
<hr class="dropdown-divider">
</li>
@ -139,7 +140,7 @@
</div>
</main>
<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 2022</div>
</footer>
<div id="toastContainer" class="toast-container position-fixed bottom-0 end-0 p-3">

View File

@ -8,13 +8,9 @@
<form class="pt-2" enctype="multipart/form-data" method="POST" id="designer-form">
{% csrf_token %}
{{ form.media }}
{% for field in form %}
{{ field|as_crispy_field }}
{% endfor %}
{{ form|crispy }}
<div class="mt-4 flex justify-end space-x-4">
<button class="btn btn-secondary" type="submit" id="submit">
Save Changes
</button>
<input class="btn btn-secondary" type="submit" id="submit" value="Save Changes" />
</div>
</form>
{% endblock %}

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

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

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

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

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

View File

@ -4,6 +4,7 @@
from django.utils.translation import gettext_lazy as _
from .forms import UserAdminChangeForm, UserAdminCreationForm
from .models import UserAPIToken
User = get_user_model()
@ -33,3 +34,19 @@ class UserAdmin(auth_admin.UserAdmin):
)
list_display = ["username", "is_superuser"]
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")

View 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

View File

@ -1,10 +1,15 @@
import json
from allauth.account.forms import SignupForm
from allauth.socialaccount.forms import SignupForm as SocialSignupForm
from django import forms
from django.contrib.auth import forms as admin_forms
from django.contrib.auth import get_user_model
from django.forms import DateInput, TextInput
from django.utils.translation import gettext_lazy as _
from akarpov.users.models import UserAPIToken
User = get_user_model()
@ -45,3 +50,80 @@ class UserSocialSignupForm(SocialSignupForm):
class OTPForm(forms.Form):
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

View File

@ -6,6 +6,8 @@
from django_otp.plugins.otp_totp.models import TOTPDevice
from rest_framework.exceptions import AuthenticationFailed
from akarpov.users.models import UserAPIToken
class EmailVerificationMiddleware(MiddlewareMixin):
def process_request(self, request):
@ -21,12 +23,20 @@ def __init__(self, get_response):
def __call__(self, request):
response = self.get_response(request)
if (
request.path_info.startswith("/api/v1/music/")
or request.path_info == "/api/v1/auth/token/"
):
if request.path_info == "/api/v1/auth/token/":
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
is_authenticated = request.user.is_authenticated
otp_not_verified = not request.session.get("otp_verified", False)

View 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,
),
),
],
),
]

View 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),
),
]

View 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),
),
]

View File

@ -1,9 +1,12 @@
import secrets
from django.contrib.auth.models import AbstractUser
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MinValueValidator
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from akarpov.common.models import BaseImageModel
@ -78,6 +81,46 @@ def __str__(self):
return self
class UserNotification:
# TODO: add notification system
...
class UserAPIToken(models.Model):
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
View 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()

View File

@ -1,13 +1,17 @@
from django.urls import include, path
from akarpov.users.views import (
create_token,
delete_token,
enable_2fa_view,
enforce_otp_login,
list_tokens,
user_detail_view,
user_history_delete_view,
user_history_view,
user_redirect_view,
user_update_view,
view_token,
)
app_name = "users"
@ -17,7 +21,11 @@
path("update/", view=user_update_view, name="update"),
path("history/", view=user_history_view, name="history"),
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/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"),
]

View File

@ -5,16 +5,16 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.contrib.sites.shortcuts import get_current_site
from django.http import HttpResponseRedirect
from django.shortcuts import redirect, render
from django.http import HttpResponseRedirect, QueryDict
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, RedirectView, UpdateView
from django_otp import user_has_device
from django_otp.plugins.otp_totp.models import TOTPDevice
from akarpov.users.forms import OTPForm
from akarpov.users.models import UserHistory
from akarpov.users.forms import OTPForm, TokenCreationForm
from akarpov.users.models import UserAPIToken, UserHistory
from akarpov.users.services.history import create_history_warning_note
from akarpov.users.services.two_factor import generate_qr_code
from akarpov.users.themes.models import Theme
@ -203,3 +203,70 @@ def enforce_otp_login(request):
else:
form = OTPForm()
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})

View File

@ -5,4 +5,4 @@ set -o nounset
/install_preview_dependencies
celery -A config.celery_app worker --loglevel=info -c 5
celery -A config.celery_app worker --autoscale 20 -l INFO

View File

@ -0,0 +1,4 @@
FROM elasticsearch:8.11.1
# Install the ICU plugin
RUN bin/elasticsearch-plugin install https://akarpov.ru/media/analysis-icu-8.11.1.zip

View File

@ -1,6 +1,7 @@
"""
Base settings to build other settings files upon.
"""
from pathlib import Path
import environ
@ -78,6 +79,7 @@
"auth.permission": {"ops": "all", "timeout": 60 * 15},
"music.*": {"ops": ("fetch", "get", "list"), "timeout": 60 * 15},
"otp_totp.totpdevice": {"ops": "all", "timeout": 15 * 60},
"users.userapitoken": {"ops": "all", "timeout": 20 * 60},
}
CACHEOPS_REDIS = env.str("REDIS_URL")
@ -131,8 +133,7 @@
"rest_framework.authtoken",
"corsheaders",
"drf_spectacular",
"ckeditor",
"ckeditor_uploader",
"django_ckeditor_5",
"colorfield",
"polymorphic",
"cacheops",
@ -510,6 +511,7 @@
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
"akarpov.users.api.authentification.UserTokenAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
@ -530,24 +532,128 @@
# CKEDITOR
# ------------------------------------------------------------------------------
CKEDITOR_UPLOAD_PATH = "uploads/"
CKEDITOR_CONFIGS = {
CKEDITOR_5_UPLOAD_PATH = "uploads/"
CKEDITOR_5_CONFIGS = {
"default": {
"width": "full",
"extra_plugins": [
"autosave",
"autogrow",
"autolink",
"autoembed",
"clipboard",
"dialog",
"dialogui",
"toolbar": [
"heading",
"|",
"bold",
"italic",
"link",
"bulletedList",
"numberedList",
"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",
}
},
}

View File

@ -43,7 +43,9 @@
path("forms/", include("akarpov.test_platform.urls", namespace="forms")),
path("tools/", include("akarpov.tools.urls", namespace="tools")),
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/login/", OTPLoginView.as_view(), name="account_login"),
path("", include("akarpov.blog.urls", namespace="blog")),

View File

@ -101,7 +101,9 @@ services:
command: /start-flower
elasticsearch:
image: elasticsearch:8.11.1
build:
context: .
dockerfile: ./compose/production/elasticsearch/Dockerfile
ports:
- "9200:9200"
- "9300:9300"

2939
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,6 @@ django-allauth = "^0.54.0"
django-crispy-forms = "^2.1"
crispy-bootstrap5 = "^0.7"
django-redis = "^5.2.0"
django-ckeditor = "^6.5.1"
django-colorfield = "^0.11.0"
djangorestframework = "^3.14.0"
django-rest-auth = "^0.9.5"
@ -121,6 +120,7 @@ python-levenshtein = "^0.23.0"
pylast = "^5.2.0"
textract = {git = "https://github.com/Alexander-D-Karpov/textract.git", branch = "master"}
librosa = "^0.10.1"
django-ckeditor-5 = "^0.2.12"
[build-system]