mirror of
https://github.com/Alexander-D-Karpov/akarpov
synced 2024-11-10 21:56:34 +03:00
Compare commits
24 Commits
fa7b0373e8
...
9e505ef135
Author | SHA1 | Date | |
---|---|---|---|
|
9e505ef135 | ||
2b2c16db2d | |||
ca199383d4 | |||
4794664e6a | |||
5088ab308a | |||
5e0fafd3b7 | |||
a2da7e724f | |||
7c9890975b | |||
3acd858598 | |||
87fc3a906f | |||
3e26cb24a6 | |||
6a7e7d5ade | |||
1524791779 | |||
6d9edbf95d | |||
6a21158a62 | |||
ffa1e9c69f | |||
a87385db78 | |||
aa49e4afc3 | |||
a309d5653d | |||
c81b387689 | |||
db72084d64 | |||
0189377aeb | |||
d3b1fe5fa1 | |||
b76a40aa02 |
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)),
|
||||
|
|
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 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")
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ def create_cropped_model_image(sender, instance, created, **kwargs):
|
|||
"app_label": model._meta.app_label,
|
||||
"model_name": model._meta.model_name,
|
||||
},
|
||||
countdown=2,
|
||||
countdown=5,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
from akarpov.utils.files import crop_image
|
||||
|
||||
|
||||
@shared_task()
|
||||
@shared_task(max_retries=3)
|
||||
def crop_model_image(pk: int, app_label: str, model_name: str):
|
||||
model = apps.get_model(app_label=app_label, model_name=model_name)
|
||||
instance = model.objects.get(pk=pk)
|
||||
|
|
|
@ -63,7 +63,7 @@ def filter(self, queryset):
|
|||
|
||||
if search_type in search_classes:
|
||||
search_instance = search_classes[search_type](
|
||||
queryset=File.objects.filter(user=self.request.user)
|
||||
queryset=File.objects.filter(user=self.request.user).nocache()
|
||||
)
|
||||
queryset = search_instance.search(query)
|
||||
return queryset
|
||||
|
|
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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import os
|
||||
import re
|
||||
|
||||
import requests
|
||||
from deep_translator import GoogleTranslator
|
||||
from django.core.files import File
|
||||
from django.db import transaction
|
||||
from django.utils.text import slugify
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.id3 import APIC, ID3, TCON, TORY, TextFrame
|
||||
|
@ -10,9 +13,32 @@
|
|||
from pydub import AudioSegment
|
||||
|
||||
from akarpov.music.models import Album, Author, Song
|
||||
from akarpov.music.services.info import search_all_platforms
|
||||
from akarpov.music.services.info import generate_readable_slug, search_all_platforms
|
||||
from akarpov.users.models import User
|
||||
from akarpov.utils.generators import generate_charset
|
||||
|
||||
|
||||
def get_or_create_author(author_name):
|
||||
with transaction.atomic():
|
||||
author = Author.objects.filter(name__iexact=author_name).order_by("id").first()
|
||||
if author is None:
|
||||
author = Author.objects.create(name=author_name)
|
||||
return author
|
||||
|
||||
|
||||
def process_track_name(track_name: str) -> str:
|
||||
# Split the track name by dash and parentheses
|
||||
parts = track_name.split(" - ")
|
||||
processed_parts = []
|
||||
|
||||
for part in parts:
|
||||
if "feat" in part:
|
||||
continue
|
||||
if "(" in part:
|
||||
part = part.split("(")[0].strip()
|
||||
processed_parts.append(part)
|
||||
|
||||
processed_track_name = " - ".join(processed_parts)
|
||||
return processed_track_name
|
||||
|
||||
|
||||
def load_track(
|
||||
|
@ -25,14 +51,41 @@ def load_track(
|
|||
link: str | None = None,
|
||||
**kwargs,
|
||||
) -> Song:
|
||||
p_name = path.split("/")[-1]
|
||||
query = f"{name if name else p_name} - {album if album else ''} - {', '.join(authors) if authors else ''}"
|
||||
p_name = process_track_name(
|
||||
" ".join(path.split("/")[-1].split(".")[0].strip().split())
|
||||
)
|
||||
query = (
|
||||
f"{process_track_name(name) if name else p_name} "
|
||||
f"- {album if album else ''} - {', '.join(authors) if authors else ''}"
|
||||
)
|
||||
search_info = search_all_platforms(query)
|
||||
orig_name = name if name else p_name
|
||||
|
||||
if image_path and search_info.get("album_image", None):
|
||||
os.remove(search_info["album_image"])
|
||||
if "title" in search_info:
|
||||
title = re.sub(r"\W+", "", search_info["title"]).lower()
|
||||
name_clean = re.sub(r"\W+", "", orig_name).lower()
|
||||
|
||||
# Check if title is in name
|
||||
if title in name_clean:
|
||||
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
|
||||
|
||||
name = name or search_info.get("title", p_name)
|
||||
album = album or search_info.get("album_name", None)
|
||||
authors = authors or search_info.get("artists", [])
|
||||
genre = kwargs.get("genre") or search_info.get("genre", None)
|
||||
|
@ -44,15 +97,6 @@ def load_track(
|
|||
if album and type(album) is str and album.startswith("['"):
|
||||
album = album.replace("['", "").replace("']", "")
|
||||
|
||||
re_authors = []
|
||||
if authors:
|
||||
for x in authors:
|
||||
try:
|
||||
re_authors.append(Author.objects.get(name=x))
|
||||
except Author.DoesNotExist:
|
||||
re_authors.append(Author.objects.create(name=x))
|
||||
authors = re_authors
|
||||
album_name = None
|
||||
if album:
|
||||
if type(album) is str:
|
||||
album_name = album
|
||||
|
@ -61,12 +105,16 @@ def load_track(
|
|||
else:
|
||||
album_name = None
|
||||
if album_name:
|
||||
try:
|
||||
album = Album.objects.get(name=album_name)
|
||||
except Album.DoesNotExist:
|
||||
album = Album.objects.create(name=album_name)
|
||||
if not album_name:
|
||||
album = None
|
||||
album, created = Album.objects.get_or_create(
|
||||
name__iexact=album_name, defaults={"name": album_name}
|
||||
)
|
||||
|
||||
processed_authors = []
|
||||
if authors:
|
||||
for author_name in authors:
|
||||
author = get_or_create_author(author_name)
|
||||
processed_authors.append(author)
|
||||
authors = processed_authors
|
||||
|
||||
if sng := Song.objects.filter(
|
||||
name=name if name else p_name,
|
||||
|
@ -82,6 +130,14 @@ def load_track(
|
|||
path = mp3_path
|
||||
|
||||
tag = MP3(path, ID3=ID3)
|
||||
|
||||
if image_path and image_path.startswith("http"):
|
||||
response = requests.get(image_path)
|
||||
se = image_path.split("/")[-1]
|
||||
image_path = f'/tmp/{generate_readable_slug(name, Song)}.{"png" if "." not in se else se.split(".")[-1]}'
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
|
||||
if image_path:
|
||||
if not image_path.endswith(".png"):
|
||||
nm = image_path
|
||||
|
@ -173,19 +229,7 @@ def load_track(
|
|||
if os.path.exists(image_path):
|
||||
os.remove(image_path)
|
||||
|
||||
if generated_name and not Song.objects.filter(slug=generated_name).exists():
|
||||
if len(generated_name) > 20:
|
||||
generated_name = generated_name.split("-")[0]
|
||||
if len(generated_name) > 20:
|
||||
generated_name = generated_name[:20]
|
||||
if not Song.objects.filter(slug=generated_name).exists():
|
||||
song.slug = generated_name
|
||||
song.save()
|
||||
else:
|
||||
song.slug = generated_name[:14] + "_" + generate_charset(5)
|
||||
song.save()
|
||||
else:
|
||||
song.slug = generated_name
|
||||
song.slug = generate_readable_slug(song.name, Song)
|
||||
song.save()
|
||||
|
||||
return song
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
from deep_translator import GoogleTranslator
|
||||
from django.conf import settings
|
||||
from django.core.files import File
|
||||
from django.db import transaction
|
||||
from django.utils.text import slugify
|
||||
from spotipy import SpotifyClientCredentials
|
||||
from yandex_music import Client, Cover
|
||||
|
@ -16,6 +17,34 @@
|
|||
from akarpov.utils.text import is_similar_artist, normalize_text
|
||||
|
||||
|
||||
def generate_readable_slug(name: str, model) -> str:
|
||||
# Translate and slugify the name
|
||||
slug = str(
|
||||
slugify(
|
||||
GoogleTranslator(source="auto", target="en").translate(
|
||||
name,
|
||||
target_language="en",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if len(slug) > 20:
|
||||
slug = slug[:20]
|
||||
last_dash = slug.rfind("-")
|
||||
if last_dash != -1:
|
||||
slug = slug[:last_dash]
|
||||
|
||||
while model.objects.filter(slug=slug).exists():
|
||||
if len(slug) > 14:
|
||||
slug = slug[:14]
|
||||
last_dash = slug.rfind("-")
|
||||
if last_dash != -1:
|
||||
slug = slug[:last_dash]
|
||||
slug = slug + "_" + generate_charset(5)
|
||||
|
||||
return slug
|
||||
|
||||
|
||||
def create_spotify_session() -> spotipy.Spotify:
|
||||
if not settings.MUSIC_SPOTIFY_ID or not settings.MUSIC_SPOTIFY_SECRET:
|
||||
raise ConnectionError("No spotify credentials provided")
|
||||
|
@ -197,15 +226,6 @@ def update_album_info(album: AlbumModel, author_name: str = None) -> None:
|
|||
|
||||
# Combine and prioritize Spotify data
|
||||
album_data = {}
|
||||
if yandex_album_info:
|
||||
album_data.update(
|
||||
{
|
||||
"name": album_data.get("name", yandex_album_info.title),
|
||||
"genre": album_data.get("genre", yandex_album_info.genre),
|
||||
"description": yandex_album_info.description,
|
||||
"type": yandex_album_info.type,
|
||||
}
|
||||
)
|
||||
|
||||
if spotify_album_info:
|
||||
album_data = {
|
||||
|
@ -215,6 +235,15 @@ def update_album_info(album: AlbumModel, author_name: str = None) -> None:
|
|||
"link": spotify_album_info["external_urls"]["spotify"],
|
||||
"genre": spotify_album_info.get("genres", []),
|
||||
}
|
||||
if yandex_album_info:
|
||||
album_data.update(
|
||||
{
|
||||
"name": album_data.get("name", yandex_album_info.title),
|
||||
"genre": album_data.get("genre", yandex_album_info.genre),
|
||||
"description": yandex_album_info.description,
|
||||
"type": yandex_album_info.type,
|
||||
}
|
||||
)
|
||||
|
||||
album.meta = album_data
|
||||
album.save()
|
||||
|
@ -262,19 +291,7 @@ def update_album_info(album: AlbumModel, author_name: str = None) -> None:
|
|||
album_authors.append(author)
|
||||
album.authors.set(album_authors)
|
||||
|
||||
if generated_name and not AlbumModel.objects.filter(slug=generated_name).exists():
|
||||
if len(generated_name) > 20:
|
||||
generated_name = generated_name.split("-")[0]
|
||||
if len(generated_name) > 20:
|
||||
generated_name = generated_name[:20]
|
||||
if not AlbumModel.objects.filter(slug=generated_name).exists():
|
||||
album.slug = generated_name
|
||||
album.save()
|
||||
else:
|
||||
album.slug = generated_name[:14] + "_" + generate_charset(5)
|
||||
album.save()
|
||||
else:
|
||||
album.slug = generated_name
|
||||
album.slug = generate_readable_slug(album.name, AlbumModel)
|
||||
album.save()
|
||||
|
||||
|
||||
|
@ -288,6 +305,13 @@ def update_author_info(author: Author) -> None:
|
|||
|
||||
# Combine and prioritize Spotify data
|
||||
author_data = {}
|
||||
if spotify_artist_info:
|
||||
author_data = {
|
||||
"name": spotify_artist_info.get("name", author.name),
|
||||
"genres": spotify_artist_info.get("genres", []),
|
||||
"popularity": spotify_artist_info.get("popularity", 0),
|
||||
"link": spotify_artist_info["external_urls"]["spotify"],
|
||||
}
|
||||
if yandex_artist_info:
|
||||
author_data.update(
|
||||
{
|
||||
|
@ -297,15 +321,8 @@ def update_author_info(author: Author) -> None:
|
|||
}
|
||||
)
|
||||
|
||||
if spotify_artist_info:
|
||||
author_data = {
|
||||
"name": spotify_artist_info.get("name", author.name),
|
||||
"genres": spotify_artist_info.get("genres", []),
|
||||
"popularity": spotify_artist_info.get("popularity", 0),
|
||||
"link": spotify_artist_info["external_urls"]["spotify"],
|
||||
}
|
||||
|
||||
author.meta = author_data
|
||||
with transaction.atomic():
|
||||
author.save()
|
||||
|
||||
# Handle Author Image - Prefer Spotify, fallback to Yandex
|
||||
|
@ -337,23 +354,13 @@ def update_author_info(author: Author) -> None:
|
|||
os.remove(image_path)
|
||||
author.save()
|
||||
|
||||
if generated_name and not Author.objects.filter(slug=generated_name).exists():
|
||||
if len(generated_name) > 20:
|
||||
generated_name = generated_name.split("-")[0]
|
||||
if len(generated_name) > 20:
|
||||
generated_name = generated_name[:20]
|
||||
if not Author.objects.filter(slug=generated_name).exists():
|
||||
author.slug = generated_name
|
||||
author.save()
|
||||
else:
|
||||
author.slug = generated_name[:14] + "_" + generate_charset(5)
|
||||
author.save()
|
||||
else:
|
||||
author.slug = generated_name
|
||||
author.slug = generate_readable_slug(author.name, Author)
|
||||
with transaction.atomic():
|
||||
author.save()
|
||||
|
||||
|
||||
def search_all_platforms(track_name: str) -> dict:
|
||||
print(track_name)
|
||||
session = spotipy.Spotify(
|
||||
auth_manager=spotipy.SpotifyClientCredentials(
|
||||
client_id=settings.MUSIC_SPOTIFY_ID,
|
||||
|
@ -373,7 +380,6 @@ def search_all_platforms(track_name: str) -> dict:
|
|||
for existing_artist in combined_artists
|
||||
):
|
||||
combined_artists.add(normalized_artist)
|
||||
|
||||
genre = spotify_info.get("genre") or yandex_info.get("genre")
|
||||
if type(genre) is list:
|
||||
genre = sorted(genre, key=lambda x: len(x))
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import threading
|
||||
|
||||
import spotipy
|
||||
from django.conf import settings
|
||||
from spotdl import Song, Spotdl
|
||||
from spotipy.oauth2 import SpotifyClientCredentials
|
||||
|
||||
from akarpov.music.services.db import load_track
|
||||
|
||||
|
||||
def create_session() -> spotipy.Spotify:
|
||||
if not settings.MUSIC_SPOTIFY_ID or not settings.MUSIC_SPOTIFY_SECRET:
|
||||
|
@ -18,3 +23,72 @@ def create_session() -> spotipy.Spotify:
|
|||
def search(name: str, session: spotipy.Spotify, search_type="track"):
|
||||
res = session.search(name, type=search_type)
|
||||
return res
|
||||
|
||||
|
||||
thread_local = threading.local()
|
||||
|
||||
|
||||
def get_spotdl_client():
|
||||
if not hasattr(thread_local, "spotdl_client"):
|
||||
spot_settings = {
|
||||
"simple_tui": True,
|
||||
"log_level": "ERROR",
|
||||
"lyrics_providers": ["genius", "azlyrics", "musixmatch"],
|
||||
"threads": 6,
|
||||
"format": "mp3",
|
||||
"ffmpeg": "ffmpeg",
|
||||
"sponsor_block": True,
|
||||
}
|
||||
thread_local.spotdl_client = Spotdl(
|
||||
client_id=settings.MUSIC_SPOTIFY_ID,
|
||||
client_secret=settings.MUSIC_SPOTIFY_SECRET,
|
||||
user_auth=False,
|
||||
headless=False,
|
||||
downloader_settings=spot_settings,
|
||||
)
|
||||
return thread_local.spotdl_client
|
||||
|
||||
|
||||
def download_url(url, user_id=None):
|
||||
spotdl_client = get_spotdl_client()
|
||||
session = create_session()
|
||||
|
||||
if "track" in url:
|
||||
songs = [Song.from_url(url)]
|
||||
elif "album" in url:
|
||||
album_tracks = session.album(url)["tracks"]["items"]
|
||||
songs = [
|
||||
Song.from_url(track["external_urls"]["spotify"]) for track in album_tracks
|
||||
]
|
||||
elif "artist" in url:
|
||||
artist_top_tracks = session.artist_top_tracks(url)["tracks"]
|
||||
songs = [
|
||||
Song.from_url(track["external_urls"]["spotify"])
|
||||
for track in artist_top_tracks
|
||||
]
|
||||
elif "playlist" in url:
|
||||
playlist_tracks = session.playlist_items(url)["items"]
|
||||
songs = [
|
||||
Song.from_url(track["track"]["external_urls"]["spotify"])
|
||||
for track in playlist_tracks
|
||||
]
|
||||
else:
|
||||
return None
|
||||
|
||||
for song in songs:
|
||||
res = spotdl_client.download(song)
|
||||
if res:
|
||||
song, path = res
|
||||
else:
|
||||
return None
|
||||
load_track(
|
||||
path=str(path),
|
||||
image_path=song.cover_url,
|
||||
user_id=user_id,
|
||||
authors=song.artists,
|
||||
album=song.album_name,
|
||||
name=song.name,
|
||||
link=song.url,
|
||||
genre=song.genres[0] if song.genres else None,
|
||||
release=song.date,
|
||||
)
|
||||
|
|
|
@ -6,9 +6,11 @@
|
|||
import requests
|
||||
import yt_dlp
|
||||
from django.conf import settings
|
||||
from django.utils.text import slugify
|
||||
from PIL import Image
|
||||
from pydub import AudioSegment
|
||||
from pytube import Search, YouTube
|
||||
from spotdl.providers.audio import YouTubeMusic
|
||||
|
||||
from akarpov.music.models import Song
|
||||
from akarpov.music.services.db import load_track
|
||||
|
@ -17,22 +19,28 @@
|
|||
final_filename = None
|
||||
|
||||
|
||||
ydl_opts = {
|
||||
"format": "m4a/bestaudio/best",
|
||||
"postprocessors": [
|
||||
{ # Extract audio using ffmpeg
|
||||
"key": "FFmpegExtractAudio",
|
||||
"preferredcodec": "m4a",
|
||||
}
|
||||
],
|
||||
"outtmpl": f"{settings.MEDIA_ROOT}/%(uploader)s_%(title)s.%(ext)s",
|
||||
}
|
||||
ytmusic = YouTubeMusic()
|
||||
|
||||
|
||||
def download_file(url):
|
||||
ydl_opts = {
|
||||
"format": "bestaudio/best",
|
||||
"outtmpl": f"{settings.MEDIA_ROOT}/%(uploader)s_%(title)s.%(ext)s",
|
||||
"postprocessors": [
|
||||
{"key": "SponsorBlock"}, # Skip sponsor segments
|
||||
{
|
||||
"key": "FFmpegExtractAudio",
|
||||
"preferredcodec": "mp3",
|
||||
"preferredquality": "192",
|
||||
}, # Extract audio
|
||||
{"key": "EmbedThumbnail"}, # Embed Thumbnail
|
||||
{"key": "FFmpegMetadata"}, # Apply correct metadata
|
||||
],
|
||||
}
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url)
|
||||
return info["requested_downloads"][0]["_filename"]
|
||||
info = ydl.extract_info(url, download=True)
|
||||
filename = ydl.prepare_filename(info)
|
||||
return os.path.splitext(filename)[0] + ".mp3"
|
||||
|
||||
|
||||
def parse_description(description: str) -> list:
|
||||
|
@ -66,7 +74,7 @@ def parse_description(description: str) -> list:
|
|||
def download_from_youtube_link(link: str, user_id: int) -> Song:
|
||||
song = None
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
with yt_dlp.YoutubeDL({"ignoreerrors": True, "extract_flat": True}) as ydl:
|
||||
info_dict = ydl.extract_info(link, download=False)
|
||||
title = info_dict.get("title", None)
|
||||
description = info_dict.get("description", None)
|
||||
|
@ -75,8 +83,17 @@ def download_from_youtube_link(link: str, user_id: int) -> Song:
|
|||
|
||||
# convert to mp3
|
||||
print(f"[processing] {title} converting to mp3")
|
||||
path = orig_path.replace(orig_path.split(".")[-1], "mp3")
|
||||
path = (
|
||||
"/".join(orig_path.split("/")[:-1])
|
||||
+ "/"
|
||||
+ slugify(orig_path.split("/")[-1].split(".")[0])
|
||||
+ ".mp3"
|
||||
)
|
||||
if orig_path.endswith(".mp3"):
|
||||
os.rename(orig_path, path)
|
||||
else:
|
||||
AudioSegment.from_file(orig_path).export(path)
|
||||
if orig_path != path:
|
||||
os.remove(orig_path)
|
||||
print(f"[processing] {title} converting to mp3: done")
|
||||
|
||||
|
@ -140,6 +157,7 @@ def download_from_youtube_link(link: str, user_id: int) -> Song:
|
|||
print(f"[processing] loading {title}")
|
||||
|
||||
info = search_all_platforms(title)
|
||||
if "album_image" in info and info["album_image"]:
|
||||
if not info["album_image"].startswith("/"):
|
||||
r = requests.get(info["album_image"])
|
||||
img_pth = str(
|
||||
|
@ -156,6 +174,8 @@ def download_from_youtube_link(link: str, user_id: int) -> Song:
|
|||
img_pth = f"{img_pth}.png"
|
||||
else:
|
||||
img_pth = info["album_image"]
|
||||
else:
|
||||
img_pth = None
|
||||
if "genre" in info:
|
||||
song = load_track(
|
||||
path,
|
||||
|
@ -175,6 +195,7 @@ def download_from_youtube_link(link: str, user_id: int) -> Song:
|
|||
info["album_name"],
|
||||
title,
|
||||
)
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
return song
|
||||
|
|
|
@ -17,7 +17,7 @@ def auto_delete_file_on_delete(sender, instance, **kwargs):
|
|||
|
||||
@receiver(post_save, sender=Song)
|
||||
def song_create(sender, instance: Song, created, **kwargs):
|
||||
if instance.volume is None:
|
||||
if instance.volume is None and instance.file:
|
||||
set_song_volume(instance)
|
||||
|
||||
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
from datetime import timedelta
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pylast
|
||||
import spotipy
|
||||
import structlog
|
||||
import ytmusicapi
|
||||
from asgiref.sync import async_to_sync
|
||||
from celery import shared_task
|
||||
from channels.layers import get_channel_layer
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import now
|
||||
from pytube import Channel, Playlist
|
||||
from spotipy import SpotifyClientCredentials
|
||||
|
||||
from akarpov.music.api.serializers import SongSerializer
|
||||
from akarpov.music.models import (
|
||||
|
@ -19,27 +22,75 @@
|
|||
UserListenHistory,
|
||||
UserMusicProfile,
|
||||
)
|
||||
from akarpov.music.services import yandex, youtube
|
||||
from akarpov.music.services import spotify, yandex, youtube
|
||||
from akarpov.music.services.file import load_dir, load_file
|
||||
from akarpov.utils.celery import get_scheduled_tasks_name
|
||||
|
||||
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.yandex.ru" in url:
|
||||
if "music.youtube.com" in url or "youtu.be" in url:
|
||||
url = url.replace("music.youtube.com", "youtube.com")
|
||||
url = url.replace("youtu.be", "youtube.com")
|
||||
if "spotify.com" in url:
|
||||
spotify.download_url(url, user_id)
|
||||
elif "music.yandex.ru" in url:
|
||||
yandex.load_playlist(url, user_id)
|
||||
elif "channel" in url or "/c/" in url:
|
||||
p = Channel(url)
|
||||
for video in p.video_urls:
|
||||
process_yb.apply_async(kwargs={"url": video, "user_id": user_id})
|
||||
if "youtube.com" in url:
|
||||
if "channel" in url or "/c/" in url:
|
||||
ytmusic = ytmusicapi.YTMusic()
|
||||
channel_id = url.split("/")[-1]
|
||||
channel_songs = ytmusic.get_artist(channel_id)["songs"]["results"]
|
||||
|
||||
for song in channel_songs:
|
||||
process_yb.apply_async(
|
||||
kwargs={
|
||||
"url": f"https://youtube.com/watch?v={song['videoId']}",
|
||||
"user_id": user_id,
|
||||
}
|
||||
)
|
||||
|
||||
elif "playlist" in url or "&list=" in url:
|
||||
p = Playlist(url)
|
||||
for video in p.video_urls:
|
||||
process_yb.apply_async(kwargs={"url": video, "user_id": user_id})
|
||||
ytmusic = ytmusicapi.YTMusic()
|
||||
|
||||
# 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={
|
||||
"url": f"https://music.youtube.com/watch?v={song['videoId']}",
|
||||
"user_id": user_id,
|
||||
}
|
||||
)
|
||||
else:
|
||||
process_yb.apply_async(kwargs={"url": url, "user_id": user_id})
|
||||
else:
|
||||
spotify_manager = SpotifyClientCredentials(
|
||||
client_id=settings.MUSIC_SPOTIFY_ID,
|
||||
client_secret=settings.MUSIC_SPOTIFY_SECRET,
|
||||
)
|
||||
spotify_search = spotipy.Spotify(client_credentials_manager=spotify_manager)
|
||||
|
||||
results = spotify_search.search(q=url, type="track", limit=1)
|
||||
top_track = (
|
||||
results["tracks"]["items"][0] if results["tracks"]["items"] else None
|
||||
)
|
||||
|
||||
if top_track:
|
||||
spotify.download_url(top_track["external_urls"]["spotify"], user_id)
|
||||
url = top_track["external_urls"]["spotify"]
|
||||
|
||||
return url
|
||||
|
||||
|
@ -87,8 +138,6 @@ def start_next_song(previous_ids: list):
|
|||
async_to_sync(channel_layer.group_send)(
|
||||
"radio_main", {"type": "song", "data": data}
|
||||
)
|
||||
song.played += 1
|
||||
song.save(update_fields=["played"])
|
||||
if RadioSong.objects.filter(slug="").exists():
|
||||
r = RadioSong.objects.get(slug="")
|
||||
r.song = song
|
||||
|
|
19
akarpov/static/js/jquery.mask.min.js
vendored
Normal file
19
akarpov/static/js/jquery.mask.min.js
vendored
Normal file
|
@ -0,0 +1,19 @@
|
|||
// jQuery Mask Plugin v1.14.16
|
||||
// github.com/igorescobar/jQuery-Mask-Plugin
|
||||
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.findInternal=function(a,n,f){a instanceof String&&(a=String(a));for(var p=a.length,k=0;k<p;k++){var b=a[k];if(n.call(f,b,k,a))return{i:k,v:b}}return{i:-1,v:void 0}};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;
|
||||
$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(a,n,f){a!=Array.prototype&&a!=Object.prototype&&(a[n]=f.value)};$jscomp.getGlobal=function(a){return"undefined"!=typeof window&&window===a?a:"undefined"!=typeof global&&null!=global?global:a};$jscomp.global=$jscomp.getGlobal(this);
|
||||
$jscomp.polyfill=function(a,n,f,p){if(n){f=$jscomp.global;a=a.split(".");for(p=0;p<a.length-1;p++){var k=a[p];k in f||(f[k]={});f=f[k]}a=a[a.length-1];p=f[a];n=n(p);n!=p&&null!=n&&$jscomp.defineProperty(f,a,{configurable:!0,writable:!0,value:n})}};$jscomp.polyfill("Array.prototype.find",function(a){return a?a:function(a,f){return $jscomp.findInternal(this,a,f).v}},"es6","es3");
|
||||
(function(a,n,f){"function"===typeof define&&define.amd?define(["jquery"],a):"object"===typeof exports&&"undefined"===typeof Meteor?module.exports=a(require("jquery")):a(n||f)})(function(a){var n=function(b,d,e){var c={invalid:[],getCaret:function(){try{var a=0,r=b.get(0),h=document.selection,d=r.selectionStart;if(h&&-1===navigator.appVersion.indexOf("MSIE 10")){var e=h.createRange();e.moveStart("character",-c.val().length);a=e.text.length}else if(d||"0"===d)a=d;return a}catch(C){}},setCaret:function(a){try{if(b.is(":focus")){var c=
|
||||
b.get(0);if(c.setSelectionRange)c.setSelectionRange(a,a);else{var g=c.createTextRange();g.collapse(!0);g.moveEnd("character",a);g.moveStart("character",a);g.select()}}}catch(B){}},events:function(){b.on("keydown.mask",function(a){b.data("mask-keycode",a.keyCode||a.which);b.data("mask-previus-value",b.val());b.data("mask-previus-caret-pos",c.getCaret());c.maskDigitPosMapOld=c.maskDigitPosMap}).on(a.jMaskGlobals.useInput?"input.mask":"keyup.mask",c.behaviour).on("paste.mask drop.mask",function(){setTimeout(function(){b.keydown().keyup()},
|
||||
100)}).on("change.mask",function(){b.data("changed",!0)}).on("blur.mask",function(){f===c.val()||b.data("changed")||b.trigger("change");b.data("changed",!1)}).on("blur.mask",function(){f=c.val()}).on("focus.mask",function(b){!0===e.selectOnFocus&&a(b.target).select()}).on("focusout.mask",function(){e.clearIfNotMatch&&!k.test(c.val())&&c.val("")})},getRegexMask:function(){for(var a=[],b,c,e,t,f=0;f<d.length;f++)(b=l.translation[d.charAt(f)])?(c=b.pattern.toString().replace(/.{1}$|^.{1}/g,""),e=b.optional,
|
||||
(b=b.recursive)?(a.push(d.charAt(f)),t={digit:d.charAt(f),pattern:c}):a.push(e||b?c+"?":c)):a.push(d.charAt(f).replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&"));a=a.join("");t&&(a=a.replace(new RegExp("("+t.digit+"(.*"+t.digit+")?)"),"($1)?").replace(new RegExp(t.digit,"g"),t.pattern));return new RegExp(a)},destroyEvents:function(){b.off("input keydown keyup paste drop blur focusout ".split(" ").join(".mask "))},val:function(a){var c=b.is("input")?"val":"text";if(0<arguments.length){if(b[c]()!==a)b[c](a);
|
||||
c=b}else c=b[c]();return c},calculateCaretPosition:function(a){var d=c.getMasked(),h=c.getCaret();if(a!==d){var e=b.data("mask-previus-caret-pos")||0;d=d.length;var g=a.length,f=a=0,l=0,k=0,m;for(m=h;m<d&&c.maskDigitPosMap[m];m++)f++;for(m=h-1;0<=m&&c.maskDigitPosMap[m];m--)a++;for(m=h-1;0<=m;m--)c.maskDigitPosMap[m]&&l++;for(m=e-1;0<=m;m--)c.maskDigitPosMapOld[m]&&k++;h>g?h=10*d:e>=h&&e!==g?c.maskDigitPosMapOld[h]||(e=h,h=h-(k-l)-a,c.maskDigitPosMap[h]&&(h=e)):h>e&&(h=h+(l-k)+f)}return h},behaviour:function(d){d=
|
||||
d||window.event;c.invalid=[];var e=b.data("mask-keycode");if(-1===a.inArray(e,l.byPassKeys)){e=c.getMasked();var h=c.getCaret(),g=b.data("mask-previus-value")||"";setTimeout(function(){c.setCaret(c.calculateCaretPosition(g))},a.jMaskGlobals.keyStrokeCompensation);c.val(e);c.setCaret(h);return c.callbacks(d)}},getMasked:function(a,b){var h=[],f=void 0===b?c.val():b+"",g=0,k=d.length,n=0,p=f.length,m=1,r="push",u=-1,w=0;b=[];if(e.reverse){r="unshift";m=-1;var x=0;g=k-1;n=p-1;var A=function(){return-1<
|
||||
g&&-1<n}}else x=k-1,A=function(){return g<k&&n<p};for(var z;A();){var y=d.charAt(g),v=f.charAt(n),q=l.translation[y];if(q)v.match(q.pattern)?(h[r](v),q.recursive&&(-1===u?u=g:g===x&&g!==u&&(g=u-m),x===u&&(g-=m)),g+=m):v===z?(w--,z=void 0):q.optional?(g+=m,n-=m):q.fallback?(h[r](q.fallback),g+=m,n-=m):c.invalid.push({p:n,v:v,e:q.pattern}),n+=m;else{if(!a)h[r](y);v===y?(b.push(n),n+=m):(z=y,b.push(n+w),w++);g+=m}}a=d.charAt(x);k!==p+1||l.translation[a]||h.push(a);h=h.join("");c.mapMaskdigitPositions(h,
|
||||
b,p);return h},mapMaskdigitPositions:function(a,b,d){a=e.reverse?a.length-d:0;c.maskDigitPosMap={};for(d=0;d<b.length;d++)c.maskDigitPosMap[b[d]+a]=1},callbacks:function(a){var g=c.val(),h=g!==f,k=[g,a,b,e],l=function(a,b,c){"function"===typeof e[a]&&b&&e[a].apply(this,c)};l("onChange",!0===h,k);l("onKeyPress",!0===h,k);l("onComplete",g.length===d.length,k);l("onInvalid",0<c.invalid.length,[g,a,b,c.invalid,e])}};b=a(b);var l=this,f=c.val(),k;d="function"===typeof d?d(c.val(),void 0,b,e):d;l.mask=
|
||||
d;l.options=e;l.remove=function(){var a=c.getCaret();l.options.placeholder&&b.removeAttr("placeholder");b.data("mask-maxlength")&&b.removeAttr("maxlength");c.destroyEvents();c.val(l.getCleanVal());c.setCaret(a);return b};l.getCleanVal=function(){return c.getMasked(!0)};l.getMaskedVal=function(a){return c.getMasked(!1,a)};l.init=function(g){g=g||!1;e=e||{};l.clearIfNotMatch=a.jMaskGlobals.clearIfNotMatch;l.byPassKeys=a.jMaskGlobals.byPassKeys;l.translation=a.extend({},a.jMaskGlobals.translation,e.translation);
|
||||
l=a.extend(!0,{},l,e);k=c.getRegexMask();if(g)c.events(),c.val(c.getMasked());else{e.placeholder&&b.attr("placeholder",e.placeholder);b.data("mask")&&b.attr("autocomplete","off");g=0;for(var f=!0;g<d.length;g++){var h=l.translation[d.charAt(g)];if(h&&h.recursive){f=!1;break}}f&&b.attr("maxlength",d.length).data("mask-maxlength",!0);c.destroyEvents();c.events();g=c.getCaret();c.val(c.getMasked());c.setCaret(g)}};l.init(!b.is("input"))};a.maskWatchers={};var f=function(){var b=a(this),d={},e=b.attr("data-mask");
|
||||
b.attr("data-mask-reverse")&&(d.reverse=!0);b.attr("data-mask-clearifnotmatch")&&(d.clearIfNotMatch=!0);"true"===b.attr("data-mask-selectonfocus")&&(d.selectOnFocus=!0);if(p(b,e,d))return b.data("mask",new n(this,e,d))},p=function(b,d,e){e=e||{};var c=a(b).data("mask"),f=JSON.stringify;b=a(b).val()||a(b).text();try{return"function"===typeof d&&(d=d(b)),"object"!==typeof c||f(c.options)!==f(e)||c.mask!==d}catch(w){}},k=function(a){var b=document.createElement("div");a="on"+a;var e=a in b;e||(b.setAttribute(a,
|
||||
"return;"),e="function"===typeof b[a]);return e};a.fn.mask=function(b,d){d=d||{};var e=this.selector,c=a.jMaskGlobals,f=c.watchInterval;c=d.watchInputs||c.watchInputs;var k=function(){if(p(this,b,d))return a(this).data("mask",new n(this,b,d))};a(this).each(k);e&&""!==e&&c&&(clearInterval(a.maskWatchers[e]),a.maskWatchers[e]=setInterval(function(){a(document).find(e).each(k)},f));return this};a.fn.masked=function(a){return this.data("mask").getMaskedVal(a)};a.fn.unmask=function(){clearInterval(a.maskWatchers[this.selector]);
|
||||
delete a.maskWatchers[this.selector];return this.each(function(){var b=a(this).data("mask");b&&b.remove().removeData("mask")})};a.fn.cleanVal=function(){return this.data("mask").getCleanVal()};a.applyDataMask=function(b){b=b||a.jMaskGlobals.maskElements;(b instanceof a?b:a(b)).filter(a.jMaskGlobals.dataMaskAttr).each(f)};k={maskElements:"input,td,span,div",dataMaskAttr:"*[data-mask]",dataMask:!0,watchInterval:300,watchInputs:!0,keyStrokeCompensation:10,useInput:!/Chrome\/[2-4][0-9]|SamsungBrowser/.test(window.navigator.userAgent)&&
|
||||
k("input"),watchDataMask:!1,byPassKeys:[9,16,17,18,36,37,38,39,40,91],translation:{0:{pattern:/\d/},9:{pattern:/\d/,optional:!0},"#":{pattern:/\d/,recursive:!0},A:{pattern:/[a-zA-Z0-9]/},S:{pattern:/[a-zA-Z]/}}};a.jMaskGlobals=a.jMaskGlobals||{};k=a.jMaskGlobals=a.extend(!0,{},k,a.jMaskGlobals);k.dataMask&&a.applyDataMask();setInterval(function(){a.jMaskGlobals.watchDataMask&&a.applyDataMask()},k.watchInterval)},window.jQuery,window.Zepto);
|
|
@ -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">
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
|
|
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 %}
|
|
@ -1,6 +1,17 @@
|
|||
{% extends "base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block inline_javascript %}
|
||||
<script>
|
||||
document.getElementById('totp').addEventListener('input', function (e) {
|
||||
e.target.value = e.target.value.replace(/[^\d]/g, '');
|
||||
if (e.target.value.length > 6) {
|
||||
e.target.value = e.target.value.slice(0, 6);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
@ -13,8 +24,14 @@
|
|||
<br>
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
{% crispy form %}
|
||||
<button type="submit" class="btn btn-primary btn-block" value="Disable 2FA">
|
||||
<div class="input-group gap-4">
|
||||
<input name="{{ form.otp_token.name }}"
|
||||
id="totp" type="tel" pattern="[0-9]{6}" inputmode="numeric" class="form-control rounded" placeholder="______" aria-label="TOTP" required autofocus>
|
||||
{% if form.otp_token.errors %}
|
||||
{% endif %}
|
||||
<div class="error-message">{{ form.otp_token.errors }}</div>
|
||||
<button type="submit" class="btn btn-primary btn-block" value="Disable 2FA"> Disable 2FA </button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
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 %}
|
|
@ -1,16 +1,40 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load crispy_forms_tags static %}
|
||||
|
||||
{% block javascript %}
|
||||
<script src="{% static 'js/jquery.min.js' %}"></script>
|
||||
<script src="{% static 'js/jquery.mask.min.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block inline_javascript %}
|
||||
<script>
|
||||
$(document).ready(function(){
|
||||
document.getElementById('totp').addEventListener('input', function (e) {
|
||||
e.target.value = e.target.value.replace(/[^\d]/g, '');
|
||||
if (e.target.value.length > 6) {
|
||||
e.target.value = e.target.value.slice(0, 6);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="d-flex justify-content-center align-items-center" style="height: calc(100vh - 60px);">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">Enter OTP</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<form method="POST" class="form">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button type="submit" class="btn btn-primary mt-2">Submit OTP</button>
|
||||
<div class="input-group gap-4">
|
||||
<input name="{{ form.otp_token.name }}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" maxlength="6" size="6" minlength="6"
|
||||
id="totp" type="tel" pattern="\d{6}" inputmode="numeric" class="form-control rounded" placeholder="______" aria-label="TOTP" required autofocus>
|
||||
{% if form.otp_token.errors %}
|
||||
{% endif %}
|
||||
<div class="error-message">{{ form.otp_token.errors }}</div>
|
||||
<button type="submit" class="btn btn-primary btn-block" value="Disable 2FA"> Submit </button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
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 %}
|
|
@ -1,7 +1,7 @@
|
|||
from abc import abstractmethod
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.urls import reverse
|
||||
from model_utils.models import TimeStampedModel
|
||||
|
||||
|
@ -79,6 +79,7 @@ def create_model_link(sender, instance, created, **kwargs):
|
|||
|
||||
link.save()
|
||||
instance.short_link = link
|
||||
with transaction.atomic():
|
||||
instance.save()
|
||||
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
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
|
|
@ -78,3 +78,7 @@ def update(self, instance, validated_data):
|
|||
instance.set_password(validated_data["password"])
|
||||
instance.save(update_fields=["password"])
|
||||
return instance
|
||||
|
||||
|
||||
class OTPSerializer(serializers.Serializer):
|
||||
token = serializers.CharField(required=True)
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
UserRetrieveAPIViewSet,
|
||||
UserRetrieveIdAPIAPIView,
|
||||
UserUpdatePasswordAPIView,
|
||||
VerifyOTPView,
|
||||
)
|
||||
|
||||
app_name = "users_api"
|
||||
|
@ -18,6 +19,11 @@
|
|||
UserRetireUpdateSelfAPIViewSet.as_view(),
|
||||
name="self",
|
||||
),
|
||||
path(
|
||||
"self/otp/verify/",
|
||||
VerifyOTPView.as_view(),
|
||||
name="otp_verify",
|
||||
),
|
||||
path(
|
||||
"self/password",
|
||||
UserUpdatePasswordAPIView.as_view(),
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import generics, permissions, status, views
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from akarpov.common.api.pagination import SmallResultsSetPagination
|
||||
from akarpov.common.jwt import sign_jwt
|
||||
from akarpov.users.api.serializers import (
|
||||
OTPSerializer,
|
||||
UserEmailVerification,
|
||||
UserFullPublicInfoSerializer,
|
||||
UserFullSerializer,
|
||||
|
@ -105,3 +108,24 @@ class UserUpdatePasswordAPIView(generics.UpdateAPIView):
|
|||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
|
||||
class VerifyOTPView(generics.GenericAPIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = OTPSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
serializer = OTPSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
otp_token = serializer.validated_data.get("token")
|
||||
|
||||
device = TOTPDevice.objects.filter(user=request.user).first()
|
||||
if device.verify_token(otp_token):
|
||||
return Response({"status": "OTP Token validated successfully"})
|
||||
else:
|
||||
return Response(
|
||||
{"error": "OTP Token is invalid"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
from cacheops import cached_as
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import resolve
|
||||
from django.urls import resolve, reverse
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
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):
|
||||
|
@ -20,11 +23,27 @@ def __init__(self, get_response):
|
|||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
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)
|
||||
on_2fa_page = resolve(request.path_info).url_name == "enforce_otp_login"
|
||||
on_2fa_page = resolve(request.path_info).url_name in [
|
||||
"enforce_otp_login",
|
||||
"otp_verify",
|
||||
]
|
||||
|
||||
# Caches the checker for has_otp_device
|
||||
@cached_as(
|
||||
|
@ -33,7 +52,6 @@ def __call__(self, request):
|
|||
def has_otp_device(user):
|
||||
return TOTPDevice.objects.devices_for_user(user, confirmed=True).exists()
|
||||
|
||||
# Enforce OTP token input, if user is authenticated, has OTP enabled but has not verified OTP
|
||||
if (
|
||||
is_authenticated
|
||||
and has_otp_device(request.user)
|
||||
|
@ -41,6 +59,17 @@ def has_otp_device(user):
|
|||
and not on_2fa_page
|
||||
):
|
||||
request.session["next"] = request.get_full_path()
|
||||
|
||||
# If the API is being accessed, return a JsonResponse
|
||||
if request.path_info.startswith("/api/"):
|
||||
return JsonResponse(
|
||||
{
|
||||
"error": "2FA is required.",
|
||||
"link": reverse("users:enforce_otp_login"),
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
return redirect("users:enforce_otp_login")
|
||||
|
||||
return response
|
||||
|
|
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.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
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 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"),
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
@ -103,6 +103,8 @@ def get_redirect_url(self):
|
|||
def enable_2fa_view(request):
|
||||
user = request.user
|
||||
devices = TOTPDevice.objects.filter(user=user, confirmed=True)
|
||||
qr_code_svg = None
|
||||
totp_key = None
|
||||
|
||||
if devices.exists():
|
||||
if request.method == "POST":
|
||||
|
@ -184,7 +186,11 @@ def form_valid(self, form):
|
|||
|
||||
@login_required
|
||||
def enforce_otp_login(request):
|
||||
# TODO gather next url from loginrequired
|
||||
next_url = request.GET.get("next")
|
||||
|
||||
if not next_url:
|
||||
next_url = request.session.get("next", reverse_lazy("home"))
|
||||
|
||||
if request.method == "POST":
|
||||
form = OTPForm(request.POST)
|
||||
if form.is_valid():
|
||||
|
@ -192,8 +198,75 @@ def enforce_otp_login(request):
|
|||
device = TOTPDevice.objects.filter(user=request.user).first()
|
||||
if device.verify_token(otp_token):
|
||||
request.session["otp_verified"] = True
|
||||
success_url = request.session.pop("next", None) or reverse_lazy("home")
|
||||
return HttpResponseRedirect(success_url)
|
||||
request.session.pop("next", None)
|
||||
return redirect(next_url)
|
||||
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})
|
||||
|
|
|
@ -33,6 +33,13 @@ RUN apt-get update && \
|
|||
apt-get purge -y --auto-remove -o APT:AutoRemove:RecommendsImportant=false && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Make ssh dir
|
||||
RUN mkdir -p /root/.ssh/
|
||||
|
||||
# Create known_hosts and add github to it
|
||||
RUN touch /root/.ssh/known_hosts
|
||||
RUN ssh-keyscan -t rsa github.com >> /root/.ssh/known_hosts
|
||||
|
||||
RUN pip install "poetry==$POETRY_VERSION"
|
||||
RUN python -m venv /venv
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
4
compose/production/elasticsearch/Dockerfile
Normal file
4
compose/production/elasticsearch/Dockerfile
Normal 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
|
|
@ -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",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -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")),
|
||||
|
|
|
@ -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
2939
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"
|
||||
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]
|
||||
|
|
Loading…
Reference in New Issue
Block a user