mirror of
https://github.com/Alexander-D-Karpov/akarpov
synced 2024-11-23 23:53:42 +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 import forms
|
||||||
|
from django_ckeditor_5.fields import CKEditor5Field
|
||||||
|
from django_ckeditor_5.widgets import CKEditor5Widget
|
||||||
|
|
||||||
from akarpov.blog.models import Post, Tag
|
from akarpov.blog.models import Post, Tag
|
||||||
|
|
||||||
|
|
||||||
class PostForm(forms.ModelForm):
|
class PostForm(forms.ModelForm):
|
||||||
body = RichTextFormField(label="")
|
body = CKEditor5Field(config_name="extends")
|
||||||
image = forms.ImageField(help_text="better use horizontal images", required=False)
|
image = forms.ImageField(help_text="better use horizontal images", required=False)
|
||||||
tags = forms.ModelMultipleChoiceField(
|
tags = forms.ModelMultipleChoiceField(
|
||||||
queryset=Tag.objects.all(), widget=forms.CheckboxSelectMultiple, required=True
|
queryset=Tag.objects.all(), widget=forms.CheckboxSelectMultiple, required=True
|
||||||
|
@ -14,3 +15,9 @@ class PostForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Post
|
model = Post
|
||||||
fields = ["title", "body", "image", "tags"]
|
fields = ["title", "body", "image", "tags"]
|
||||||
|
widgets = {
|
||||||
|
"body": CKEditor5Widget(
|
||||||
|
attrs={"class": "django_ckeditor_5"},
|
||||||
|
config_name="extends",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
# Generated by Django 4.0.8 on 2022-11-23 08:30
|
# Generated by Django 4.0.8 on 2022-11-23 08:30
|
||||||
|
|
||||||
import ckeditor_uploader.fields
|
|
||||||
import colorfield.fields
|
import colorfield.fields
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
@ -56,7 +55,7 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("title", models.CharField(max_length=100)),
|
("title", models.CharField(max_length=100)),
|
||||||
("body", ckeditor_uploader.fields.RichTextUploadingField()),
|
("body", models.TextField()),
|
||||||
("slug", models.SlugField(blank=True, max_length=20)),
|
("slug", models.SlugField(blank=True, max_length=20)),
|
||||||
("post_views", models.IntegerField(default=0)),
|
("post_views", models.IntegerField(default=0)),
|
||||||
("rating", models.IntegerField(default=0)),
|
("rating", models.IntegerField(default=0)),
|
||||||
|
|
18
akarpov/blog/migrations/0011_alter_post_body.py
Normal file
18
akarpov/blog/migrations/0011_alter_post_body.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-03-29 15:35
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
import django_ckeditor_5.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("blog", "0010_alter_tag_color"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="post",
|
||||||
|
name="body",
|
||||||
|
field=django_ckeditor_5.fields.CKEditor5Field(),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,8 +1,8 @@
|
||||||
from ckeditor_uploader.fields import RichTextUploadingField
|
|
||||||
from colorfield.fields import ColorField
|
from colorfield.fields import ColorField
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django_ckeditor_5.fields import CKEditor5Field
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
class Post(BaseImageModel, ShortLinkModel, UserHistoryModel):
|
class Post(BaseImageModel, ShortLinkModel, UserHistoryModel):
|
||||||
title = models.CharField(max_length=100, blank=False)
|
title = models.CharField(max_length=100, blank=False)
|
||||||
body = RichTextUploadingField(blank=False)
|
body = CKEditor5Field(blank=False, config_name="extends")
|
||||||
|
|
||||||
creator = models.ForeignKey(User, on_delete=models.CASCADE, related_name="posts")
|
creator = models.ForeignKey(User, on_delete=models.CASCADE, related_name="posts")
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ def create_cropped_model_image(sender, instance, created, **kwargs):
|
||||||
"app_label": model._meta.app_label,
|
"app_label": model._meta.app_label,
|
||||||
"model_name": model._meta.model_name,
|
"model_name": model._meta.model_name,
|
||||||
},
|
},
|
||||||
countdown=2,
|
countdown=5,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
from akarpov.utils.files import crop_image
|
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):
|
def crop_model_image(pk: int, app_label: str, model_name: str):
|
||||||
model = apps.get_model(app_label=app_label, model_name=model_name)
|
model = apps.get_model(app_label=app_label, model_name=model_name)
|
||||||
instance = model.objects.get(pk=pk)
|
instance = model.objects.get(pk=pk)
|
||||||
|
|
|
@ -63,7 +63,7 @@ def filter(self, queryset):
|
||||||
|
|
||||||
if search_type in search_classes:
|
if search_type in search_classes:
|
||||||
search_instance = search_classes[search_type](
|
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)
|
queryset = search_instance.search(query)
|
||||||
return queryset
|
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):
|
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:
|
class Meta:
|
||||||
model = Album
|
model = Album
|
||||||
fields = ["name", "slug", "image_cropped"]
|
fields = ["name", "slug", "image_cropped", "authors"]
|
||||||
|
|
||||||
|
|
||||||
class SongSerializer(serializers.ModelSerializer):
|
class SongSerializer(serializers.ModelSerializer):
|
||||||
|
@ -133,10 +141,32 @@ class Meta:
|
||||||
|
|
||||||
class PlaylistSerializer(SetUserModelSerializer):
|
class PlaylistSerializer(SetUserModelSerializer):
|
||||||
creator = UserPublicInfoSerializer(read_only=True)
|
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:
|
class Meta:
|
||||||
model = Playlist
|
model = Playlist
|
||||||
fields = ["name", "length", "slug", "private", "creator"]
|
fields = ["name", "length", "slug", "images", "private", "creator"]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"slug": {"read_only": True},
|
"slug": {"read_only": True},
|
||||||
"creator": {"read_only": True},
|
"creator": {"read_only": True},
|
||||||
|
@ -144,13 +174,21 @@ class Meta:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class FullPlaylistSerializer(serializers.ModelSerializer):
|
class FullPlaylistSerializer(PlaylistSerializer):
|
||||||
songs = ListSongSerializer(many=True, read_only=True)
|
songs = serializers.SerializerMethodField(method_name="get_songs")
|
||||||
creator = UserPublicInfoSerializer(read_only=True)
|
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:
|
class Meta:
|
||||||
model = Playlist
|
model = Playlist
|
||||||
fields = ["name", "private", "creator", "songs"]
|
fields = ["name", "private", "creator", "images", "songs"]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"slug": {"read_only": True},
|
"slug": {"read_only": True},
|
||||||
"creator": {"read_only": True},
|
"creator": {"read_only": True},
|
||||||
|
@ -158,8 +196,8 @@ class Meta:
|
||||||
|
|
||||||
|
|
||||||
class AddSongToPlaylistSerializer(serializers.ModelSerializer):
|
class AddSongToPlaylistSerializer(serializers.ModelSerializer):
|
||||||
song = serializers.SlugField()
|
song = serializers.SlugField(write_only=True)
|
||||||
playlist = serializers.SlugField()
|
playlist = serializers.SlugField(write_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Playlist
|
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:
|
class Index:
|
||||||
name = "songs"
|
name = "songs"
|
||||||
settings = {"number_of_shards": 1, "number_of_replicas": 0}
|
settings = {
|
||||||
# settings = {
|
"number_of_shards": 1,
|
||||||
# "number_of_shards": 1,
|
"number_of_replicas": 0,
|
||||||
# "number_of_replicas": 0,
|
"analysis": {
|
||||||
# "analysis": {
|
"filter": {
|
||||||
# "analyzer": {
|
"russian_stop": {
|
||||||
# "russian_icu": {
|
"type": "stop",
|
||||||
# "type": "custom",
|
"stopwords": "_russian_",
|
||||||
# "tokenizer": "icu_tokenizer",
|
},
|
||||||
# "filter": ["icu_folding","icu_normalizer"]
|
"russian_keywords": {
|
||||||
# }
|
"type": "keyword_marker",
|
||||||
# }
|
"keywords": ["пример"],
|
||||||
# }
|
},
|
||||||
# } TODO
|
"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:
|
class Django:
|
||||||
model = Song
|
model = Song
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import requests
|
||||||
from deep_translator import GoogleTranslator
|
from deep_translator import GoogleTranslator
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
|
from django.db import transaction
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from mutagen import File as MutagenFile
|
from mutagen import File as MutagenFile
|
||||||
from mutagen.id3 import APIC, ID3, TCON, TORY, TextFrame
|
from mutagen.id3 import APIC, ID3, TCON, TORY, TextFrame
|
||||||
|
@ -10,9 +13,32 @@
|
||||||
from pydub import AudioSegment
|
from pydub import AudioSegment
|
||||||
|
|
||||||
from akarpov.music.models import Album, Author, Song
|
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.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(
|
def load_track(
|
||||||
|
@ -25,14 +51,41 @@ def load_track(
|
||||||
link: str | None = None,
|
link: str | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Song:
|
) -> Song:
|
||||||
p_name = path.split("/")[-1]
|
p_name = process_track_name(
|
||||||
query = f"{name if name else p_name} - {album if album else ''} - {', '.join(authors) if authors else ''}"
|
" ".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)
|
search_info = search_all_platforms(query)
|
||||||
|
orig_name = name if name else p_name
|
||||||
|
|
||||||
if image_path and search_info.get("album_image", None):
|
if image_path and search_info.get("album_image", None):
|
||||||
os.remove(search_info["album_image"])
|
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)
|
album = album or search_info.get("album_name", None)
|
||||||
authors = authors or search_info.get("artists", [])
|
authors = authors or search_info.get("artists", [])
|
||||||
genre = kwargs.get("genre") or search_info.get("genre", None)
|
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("['"):
|
if album and type(album) is str and album.startswith("['"):
|
||||||
album = album.replace("['", "").replace("']", "")
|
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 album:
|
||||||
if type(album) is str:
|
if type(album) is str:
|
||||||
album_name = album
|
album_name = album
|
||||||
|
@ -61,12 +105,16 @@ def load_track(
|
||||||
else:
|
else:
|
||||||
album_name = None
|
album_name = None
|
||||||
if album_name:
|
if album_name:
|
||||||
try:
|
album, created = Album.objects.get_or_create(
|
||||||
album = Album.objects.get(name=album_name)
|
name__iexact=album_name, defaults={"name": album_name}
|
||||||
except Album.DoesNotExist:
|
)
|
||||||
album = Album.objects.create(name=album_name)
|
|
||||||
if not album_name:
|
processed_authors = []
|
||||||
album = None
|
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(
|
if sng := Song.objects.filter(
|
||||||
name=name if name else p_name,
|
name=name if name else p_name,
|
||||||
|
@ -82,6 +130,14 @@ def load_track(
|
||||||
path = mp3_path
|
path = mp3_path
|
||||||
|
|
||||||
tag = MP3(path, ID3=ID3)
|
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 image_path:
|
||||||
if not image_path.endswith(".png"):
|
if not image_path.endswith(".png"):
|
||||||
nm = image_path
|
nm = image_path
|
||||||
|
@ -173,19 +229,7 @@ def load_track(
|
||||||
if os.path.exists(image_path):
|
if os.path.exists(image_path):
|
||||||
os.remove(image_path)
|
os.remove(image_path)
|
||||||
|
|
||||||
if generated_name and not Song.objects.filter(slug=generated_name).exists():
|
song.slug = generate_readable_slug(song.name, Song)
|
||||||
if len(generated_name) > 20:
|
song.save()
|
||||||
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.save()
|
|
||||||
|
|
||||||
return song
|
return song
|
||||||
|
|
|
@ -65,7 +65,7 @@ def process_mp3_file(path: str, user_id: int) -> None:
|
||||||
|
|
||||||
def analyze_music_loudness(mp3_file):
|
def analyze_music_loudness(mp3_file):
|
||||||
y, sr = librosa.load(mp3_file, sr=None)
|
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))
|
stft = np.abs(librosa.stft(y, n_fft=frame_length, hop_length=frame_length))
|
||||||
rms_energy = librosa.feature.rms(
|
rms_energy = librosa.feature.rms(
|
||||||
S=stft, frame_length=frame_length, hop_length=frame_length
|
S=stft, frame_length=frame_length, hop_length=frame_length
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
from deep_translator import GoogleTranslator
|
from deep_translator import GoogleTranslator
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
|
from django.db import transaction
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from spotipy import SpotifyClientCredentials
|
from spotipy import SpotifyClientCredentials
|
||||||
from yandex_music import Client, Cover
|
from yandex_music import Client, Cover
|
||||||
|
@ -16,6 +17,34 @@
|
||||||
from akarpov.utils.text import is_similar_artist, normalize_text
|
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:
|
def create_spotify_session() -> spotipy.Spotify:
|
||||||
if not settings.MUSIC_SPOTIFY_ID or not settings.MUSIC_SPOTIFY_SECRET:
|
if not settings.MUSIC_SPOTIFY_ID or not settings.MUSIC_SPOTIFY_SECRET:
|
||||||
raise ConnectionError("No spotify credentials provided")
|
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
|
# Combine and prioritize Spotify data
|
||||||
album_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:
|
if spotify_album_info:
|
||||||
album_data = {
|
album_data = {
|
||||||
|
@ -215,6 +235,15 @@ def update_album_info(album: AlbumModel, author_name: str = None) -> None:
|
||||||
"link": spotify_album_info["external_urls"]["spotify"],
|
"link": spotify_album_info["external_urls"]["spotify"],
|
||||||
"genre": spotify_album_info.get("genres", []),
|
"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.meta = album_data
|
||||||
album.save()
|
album.save()
|
||||||
|
@ -262,20 +291,8 @@ def update_album_info(album: AlbumModel, author_name: str = None) -> None:
|
||||||
album_authors.append(author)
|
album_authors.append(author)
|
||||||
album.authors.set(album_authors)
|
album.authors.set(album_authors)
|
||||||
|
|
||||||
if generated_name and not AlbumModel.objects.filter(slug=generated_name).exists():
|
album.slug = generate_readable_slug(album.name, AlbumModel)
|
||||||
if len(generated_name) > 20:
|
album.save()
|
||||||
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.save()
|
|
||||||
|
|
||||||
|
|
||||||
def update_author_info(author: Author) -> None:
|
def update_author_info(author: Author) -> None:
|
||||||
|
@ -288,6 +305,13 @@ def update_author_info(author: Author) -> None:
|
||||||
|
|
||||||
# Combine and prioritize Spotify data
|
# Combine and prioritize Spotify data
|
||||||
author_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:
|
if yandex_artist_info:
|
||||||
author_data.update(
|
author_data.update(
|
||||||
{
|
{
|
||||||
|
@ -297,16 +321,9 @@ 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
|
author.meta = author_data
|
||||||
author.save()
|
with transaction.atomic():
|
||||||
|
author.save()
|
||||||
|
|
||||||
# Handle Author Image - Prefer Spotify, fallback to Yandex
|
# Handle Author Image - Prefer Spotify, fallback to Yandex
|
||||||
image_path = None
|
image_path = None
|
||||||
|
@ -337,23 +354,13 @@ def update_author_info(author: Author) -> None:
|
||||||
os.remove(image_path)
|
os.remove(image_path)
|
||||||
author.save()
|
author.save()
|
||||||
|
|
||||||
if generated_name and not Author.objects.filter(slug=generated_name).exists():
|
author.slug = generate_readable_slug(author.name, Author)
|
||||||
if len(generated_name) > 20:
|
with transaction.atomic():
|
||||||
generated_name = generated_name.split("-")[0]
|
author.save()
|
||||||
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.save()
|
|
||||||
|
|
||||||
|
|
||||||
def search_all_platforms(track_name: str) -> dict:
|
def search_all_platforms(track_name: str) -> dict:
|
||||||
|
print(track_name)
|
||||||
session = spotipy.Spotify(
|
session = spotipy.Spotify(
|
||||||
auth_manager=spotipy.SpotifyClientCredentials(
|
auth_manager=spotipy.SpotifyClientCredentials(
|
||||||
client_id=settings.MUSIC_SPOTIFY_ID,
|
client_id=settings.MUSIC_SPOTIFY_ID,
|
||||||
|
@ -373,7 +380,6 @@ def search_all_platforms(track_name: str) -> dict:
|
||||||
for existing_artist in combined_artists
|
for existing_artist in combined_artists
|
||||||
):
|
):
|
||||||
combined_artists.add(normalized_artist)
|
combined_artists.add(normalized_artist)
|
||||||
|
|
||||||
genre = spotify_info.get("genre") or yandex_info.get("genre")
|
genre = spotify_info.get("genre") or yandex_info.get("genre")
|
||||||
if type(genre) is list:
|
if type(genre) is list:
|
||||||
genre = sorted(genre, key=lambda x: len(x))
|
genre = sorted(genre, key=lambda x: len(x))
|
||||||
|
|
|
@ -13,9 +13,9 @@ def search_song(query):
|
||||||
ES_Q(
|
ES_Q(
|
||||||
"multi_match",
|
"multi_match",
|
||||||
query=query,
|
query=query,
|
||||||
fields=["name^3", "authors.name^2", "album.name"],
|
fields=["name^5", "authors.name^3", "album.name^3"],
|
||||||
fuzziness="AUTO",
|
fuzziness="AUTO",
|
||||||
), # Change here
|
),
|
||||||
ES_Q("wildcard", name__raw=f"*{query.lower()}*"),
|
ES_Q("wildcard", name__raw=f"*{query.lower()}*"),
|
||||||
ES_Q(
|
ES_Q(
|
||||||
"nested",
|
"nested",
|
||||||
|
@ -27,6 +27,7 @@ def search_song(query):
|
||||||
path="album",
|
path="album",
|
||||||
query=ES_Q("wildcard", album__name__raw=f"*{query.lower()}*"),
|
query=ES_Q("wildcard", album__name__raw=f"*{query.lower()}*"),
|
||||||
),
|
),
|
||||||
|
ES_Q("wildcard", meta__raw=f"*{query.lower()}*"),
|
||||||
],
|
],
|
||||||
minimum_should_match=1,
|
minimum_should_match=1,
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
|
import threading
|
||||||
|
|
||||||
import spotipy
|
import spotipy
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from spotdl import Song, Spotdl
|
||||||
from spotipy.oauth2 import SpotifyClientCredentials
|
from spotipy.oauth2 import SpotifyClientCredentials
|
||||||
|
|
||||||
|
from akarpov.music.services.db import load_track
|
||||||
|
|
||||||
|
|
||||||
def create_session() -> spotipy.Spotify:
|
def create_session() -> spotipy.Spotify:
|
||||||
if not settings.MUSIC_SPOTIFY_ID or not settings.MUSIC_SPOTIFY_SECRET:
|
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"):
|
def search(name: str, session: spotipy.Spotify, search_type="track"):
|
||||||
res = session.search(name, type=search_type)
|
res = session.search(name, type=search_type)
|
||||||
return res
|
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 requests
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.utils.text import slugify
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from pydub import AudioSegment
|
from pydub import AudioSegment
|
||||||
from pytube import Search, YouTube
|
from pytube import Search, YouTube
|
||||||
|
from spotdl.providers.audio import YouTubeMusic
|
||||||
|
|
||||||
from akarpov.music.models import Song
|
from akarpov.music.models import Song
|
||||||
from akarpov.music.services.db import load_track
|
from akarpov.music.services.db import load_track
|
||||||
|
@ -17,22 +19,28 @@
|
||||||
final_filename = None
|
final_filename = None
|
||||||
|
|
||||||
|
|
||||||
ydl_opts = {
|
ytmusic = YouTubeMusic()
|
||||||
"format": "m4a/bestaudio/best",
|
|
||||||
"postprocessors": [
|
|
||||||
{ # Extract audio using ffmpeg
|
|
||||||
"key": "FFmpegExtractAudio",
|
|
||||||
"preferredcodec": "m4a",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"outtmpl": f"{settings.MEDIA_ROOT}/%(uploader)s_%(title)s.%(ext)s",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def download_file(url):
|
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:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(url)
|
info = ydl.extract_info(url, download=True)
|
||||||
return info["requested_downloads"][0]["_filename"]
|
filename = ydl.prepare_filename(info)
|
||||||
|
return os.path.splitext(filename)[0] + ".mp3"
|
||||||
|
|
||||||
|
|
||||||
def parse_description(description: str) -> list:
|
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:
|
def download_from_youtube_link(link: str, user_id: int) -> Song:
|
||||||
song = None
|
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)
|
info_dict = ydl.extract_info(link, download=False)
|
||||||
title = info_dict.get("title", None)
|
title = info_dict.get("title", None)
|
||||||
description = info_dict.get("description", None)
|
description = info_dict.get("description", None)
|
||||||
|
@ -75,9 +83,18 @@ def download_from_youtube_link(link: str, user_id: int) -> Song:
|
||||||
|
|
||||||
# convert to mp3
|
# convert to mp3
|
||||||
print(f"[processing] {title} converting to mp3")
|
print(f"[processing] {title} converting to mp3")
|
||||||
path = orig_path.replace(orig_path.split(".")[-1], "mp3")
|
path = (
|
||||||
AudioSegment.from_file(orig_path).export(path)
|
"/".join(orig_path.split("/")[:-1])
|
||||||
os.remove(orig_path)
|
+ "/"
|
||||||
|
+ 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")
|
print(f"[processing] {title} converting to mp3: done")
|
||||||
|
|
||||||
# split in chapters
|
# split in chapters
|
||||||
|
@ -140,22 +157,25 @@ def download_from_youtube_link(link: str, user_id: int) -> Song:
|
||||||
print(f"[processing] loading {title}")
|
print(f"[processing] loading {title}")
|
||||||
|
|
||||||
info = search_all_platforms(title)
|
info = search_all_platforms(title)
|
||||||
if not info["album_image"].startswith("/"):
|
if "album_image" in info and info["album_image"]:
|
||||||
r = requests.get(info["album_image"])
|
if not info["album_image"].startswith("/"):
|
||||||
img_pth = str(
|
r = requests.get(info["album_image"])
|
||||||
settings.MEDIA_ROOT
|
img_pth = str(
|
||||||
+ f"/{info['album_image'].split('/')[-1]}_{str(randint(100, 999))}"
|
settings.MEDIA_ROOT
|
||||||
)
|
+ f"/{info['album_image'].split('/')[-1]}_{str(randint(100, 999))}"
|
||||||
with open(img_pth, "wb") as f:
|
)
|
||||||
f.write(r.content)
|
with open(img_pth, "wb") as f:
|
||||||
|
f.write(r.content)
|
||||||
|
|
||||||
im = Image.open(img_pth)
|
im = Image.open(img_pth)
|
||||||
im.save(str(f"{img_pth}.png"))
|
im.save(str(f"{img_pth}.png"))
|
||||||
|
|
||||||
os.remove(img_pth)
|
os.remove(img_pth)
|
||||||
img_pth = f"{img_pth}.png"
|
img_pth = f"{img_pth}.png"
|
||||||
|
else:
|
||||||
|
img_pth = info["album_image"]
|
||||||
else:
|
else:
|
||||||
img_pth = info["album_image"]
|
img_pth = None
|
||||||
if "genre" in info:
|
if "genre" in info:
|
||||||
song = load_track(
|
song = load_track(
|
||||||
path,
|
path,
|
||||||
|
@ -175,7 +195,8 @@ def download_from_youtube_link(link: str, user_id: int) -> Song:
|
||||||
info["album_name"],
|
info["album_name"],
|
||||||
title,
|
title,
|
||||||
)
|
)
|
||||||
os.remove(path)
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
return song
|
return song
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ def auto_delete_file_on_delete(sender, instance, **kwargs):
|
||||||
|
|
||||||
@receiver(post_save, sender=Song)
|
@receiver(post_save, sender=Song)
|
||||||
def song_create(sender, instance: Song, created, **kwargs):
|
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)
|
set_song_volume(instance)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import pylast
|
import pylast
|
||||||
|
import spotipy
|
||||||
import structlog
|
import structlog
|
||||||
|
import ytmusicapi
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.timezone import now
|
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.api.serializers import SongSerializer
|
||||||
from akarpov.music.models import (
|
from akarpov.music.models import (
|
||||||
|
@ -19,27 +22,75 @@
|
||||||
UserListenHistory,
|
UserListenHistory,
|
||||||
UserMusicProfile,
|
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.music.services.file import load_dir, load_file
|
||||||
from akarpov.utils.celery import get_scheduled_tasks_name
|
from akarpov.utils.celery import get_scheduled_tasks_name
|
||||||
|
|
||||||
logger = structlog.get_logger(__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):
|
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)
|
yandex.load_playlist(url, user_id)
|
||||||
elif "channel" in url or "/c/" in url:
|
if "youtube.com" in url:
|
||||||
p = Channel(url)
|
if "channel" in url or "/c/" in url:
|
||||||
for video in p.video_urls:
|
ytmusic = ytmusicapi.YTMusic()
|
||||||
process_yb.apply_async(kwargs={"url": video, "user_id": user_id})
|
channel_id = url.split("/")[-1]
|
||||||
elif "playlist" in url or "&list=" in url:
|
channel_songs = ytmusic.get_artist(channel_id)["songs"]["results"]
|
||||||
p = Playlist(url)
|
|
||||||
for video in p.video_urls:
|
for song in channel_songs:
|
||||||
process_yb.apply_async(kwargs={"url": video, "user_id": user_id})
|
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:
|
||||||
|
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:
|
else:
|
||||||
process_yb.apply_async(kwargs={"url": url, "user_id": user_id})
|
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
|
return url
|
||||||
|
|
||||||
|
@ -87,8 +138,6 @@ def start_next_song(previous_ids: list):
|
||||||
async_to_sync(channel_layer.group_send)(
|
async_to_sync(channel_layer.group_send)(
|
||||||
"radio_main", {"type": "song", "data": data}
|
"radio_main", {"type": "song", "data": data}
|
||||||
)
|
)
|
||||||
song.played += 1
|
|
||||||
song.save(update_fields=["played"])
|
|
||||||
if RadioSong.objects.filter(slug="").exists():
|
if RadioSong.objects.filter(slug="").exists():
|
||||||
r = RadioSong.objects.get(slug="")
|
r = RadioSong.objects.get(slug="")
|
||||||
r.song = song
|
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 'tools:promocodes:activate' %}" href="{% url 'tools:promocodes:activate' %}">Activate promocode</a></li>
|
||||||
<li><a class="dropdown-item {% active_link 'users:history' %}" href="{% url 'users:history' %}">History</a></li>
|
<li><a class="dropdown-item {% active_link 'users:history' %}" href="{% url 'users:history' %}">History</a></li>
|
||||||
<li><a class="dropdown-item {% active_link 'users:enable_2fa' %}" href="{% url 'users:enable_2fa' %}">2FA</a></li>
|
<li><a class="dropdown-item {% active_link 'users:enable_2fa' %}" href="{% url 'users:enable_2fa' %}">2FA</a></li>
|
||||||
|
<li><a class="dropdown-item {% active_link 'users:enable_2fa' %}" href="{% url 'users:list_tokens' %}">API Tokens</a></li>
|
||||||
<li>
|
<li>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider">
|
||||||
</li>
|
</li>
|
||||||
|
@ -139,7 +140,7 @@
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer class="row bg-light py-1 mt-auto text-center">
|
<footer class="row bg-light py-1 mt-auto text-center">
|
||||||
<div class="col"> Writen by <a href="https://akarpov.ru/about">sanspie</a>, find source code <a href="https://github.com/Alexander-D-Karpov/akarpov">here</a>. Copyleft akarpov 2023</div>
|
<div class="col"> Writen by <a href="https://akarpov.ru/about">sanspie</a>, find source code <a href="https://github.com/Alexander-D-Karpov/akarpov">here</a>. Copyleft akarpov 2022</div>
|
||||||
</footer>
|
</footer>
|
||||||
<div id="toastContainer" class="toast-container position-fixed bottom-0 end-0 p-3">
|
<div id="toastContainer" class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||||
|
|
||||||
|
|
|
@ -8,13 +8,9 @@
|
||||||
<form class="pt-2" enctype="multipart/form-data" method="POST" id="designer-form">
|
<form class="pt-2" enctype="multipart/form-data" method="POST" id="designer-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.media }}
|
{{ form.media }}
|
||||||
{% for field in form %}
|
{{ form|crispy }}
|
||||||
{{ field|as_crispy_field }}
|
|
||||||
{% endfor %}
|
|
||||||
<div class="mt-4 flex justify-end space-x-4">
|
<div class="mt-4 flex justify-end space-x-4">
|
||||||
<button class="btn btn-secondary" type="submit" id="submit">
|
<input class="btn btn-secondary" type="submit" id="submit" value="Save Changes" />
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
12
akarpov/templates/users/confirm_delete_token.html
Normal file
12
akarpov/templates/users/confirm_delete_token.html
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2>Confirm Deletion</h2>
|
||||||
|
<p>Are you sure you want to delete this token?</p>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-danger">Yes, delete it</button>
|
||||||
|
<a href="{% url 'users:list_tokens' %}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
21
akarpov/templates/users/create_token.html
Normal file
21
akarpov/templates/users/create_token.html
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<h2>Create API Token</h2>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for field in form %}
|
||||||
|
{% if field.name|slice:":12" == 'permissions_' %}
|
||||||
|
<fieldset>
|
||||||
|
{{ field|as_crispy_field }}
|
||||||
|
</fieldset>
|
||||||
|
{% else %}
|
||||||
|
{{ field|as_crispy_field }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit" class="btn btn-primary mt-2">Create Token</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -1,6 +1,17 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load crispy_forms_tags %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
@ -13,8 +24,14 @@
|
||||||
<br>
|
<br>
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% crispy form %}
|
<div class="input-group gap-4">
|
||||||
<button type="submit" class="btn btn-primary btn-block" value="Disable 2FA">
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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,17 +1,41 @@
|
||||||
{% extends 'base.html' %}
|
{% 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 %}
|
{% 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="col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header bg-primary text-white">Enter OTP</div>
|
<div class="card-header bg-primary text-white">Enter OTP</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post">
|
<form method="POST" class="form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form|crispy }}
|
<div class="input-group gap-4">
|
||||||
<button type="submit" class="btn btn-primary mt-2">Submit OTP</button>
|
<input name="{{ form.otp_token.name }}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" maxlength="6" size="6" minlength="6"
|
||||||
</form>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</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 abc import abstractmethod
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models, transaction
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from model_utils.models import TimeStampedModel
|
from model_utils.models import TimeStampedModel
|
||||||
|
|
||||||
|
@ -79,7 +79,8 @@ def create_model_link(sender, instance, created, **kwargs):
|
||||||
|
|
||||||
link.save()
|
link.save()
|
||||||
instance.short_link = link
|
instance.short_link = link
|
||||||
instance.save()
|
with transaction.atomic():
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
|
||||||
def update_model_link(sender, instance, **kwargs):
|
def update_model_link(sender, instance, **kwargs):
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .forms import UserAdminChangeForm, UserAdminCreationForm
|
from .forms import UserAdminChangeForm, UserAdminCreationForm
|
||||||
|
from .models import UserAPIToken
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
@ -33,3 +34,19 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||||
)
|
)
|
||||||
list_display = ["username", "is_superuser"]
|
list_display = ["username", "is_superuser"]
|
||||||
search_fields = ["username", "email"]
|
search_fields = ["username", "email"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserAPIToken)
|
||||||
|
class UserAPITokenAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["user", "active_until", "last_used"]
|
||||||
|
search_fields = ["user__username", "token"]
|
||||||
|
list_filter = ["active_until", "last_used"]
|
||||||
|
date_hierarchy = "active_until"
|
||||||
|
raw_id_fields = ["user"]
|
||||||
|
actions = ["deactivate_tokens"]
|
||||||
|
|
||||||
|
def deactivate_tokens(self, request, queryset):
|
||||||
|
queryset.update(active_until=None)
|
||||||
|
self.message_user(request, _("Tokens deactivated"))
|
||||||
|
|
||||||
|
deactivate_tokens.short_description = _("Deactivate selected tokens")
|
||||||
|
|
22
akarpov/users/api/authentification.py
Normal file
22
akarpov/users/api/authentification.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
from rest_framework.authentication import BaseAuthentication
|
||||||
|
|
||||||
|
from akarpov.users.models import UserAPIToken
|
||||||
|
from akarpov.users.tasks import set_last_active_token
|
||||||
|
|
||||||
|
|
||||||
|
class UserTokenAuthentication(BaseAuthentication):
|
||||||
|
def authenticate(self, request):
|
||||||
|
if "Authorization" not in request.headers:
|
||||||
|
return None
|
||||||
|
token = request.headers["Authorization"]
|
||||||
|
if " " in token:
|
||||||
|
token = token.split(" ")[1]
|
||||||
|
try:
|
||||||
|
token = UserAPIToken.objects.cache().get(token=token)
|
||||||
|
except UserAPIToken.DoesNotExist:
|
||||||
|
return None
|
||||||
|
if not token.is_active:
|
||||||
|
return None
|
||||||
|
set_last_active_token.delay(token.token)
|
||||||
|
|
||||||
|
return token.user, token
|
|
@ -78,3 +78,7 @@ def update(self, instance, validated_data):
|
||||||
instance.set_password(validated_data["password"])
|
instance.set_password(validated_data["password"])
|
||||||
instance.save(update_fields=["password"])
|
instance.save(update_fields=["password"])
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class OTPSerializer(serializers.Serializer):
|
||||||
|
token = serializers.CharField(required=True)
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
UserRetrieveAPIViewSet,
|
UserRetrieveAPIViewSet,
|
||||||
UserRetrieveIdAPIAPIView,
|
UserRetrieveIdAPIAPIView,
|
||||||
UserUpdatePasswordAPIView,
|
UserUpdatePasswordAPIView,
|
||||||
|
VerifyOTPView,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = "users_api"
|
app_name = "users_api"
|
||||||
|
@ -18,6 +19,11 @@
|
||||||
UserRetireUpdateSelfAPIViewSet.as_view(),
|
UserRetireUpdateSelfAPIViewSet.as_view(),
|
||||||
name="self",
|
name="self",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"self/otp/verify/",
|
||||||
|
VerifyOTPView.as_view(),
|
||||||
|
name="otp_verify",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"self/password",
|
"self/password",
|
||||||
UserUpdatePasswordAPIView.as_view(),
|
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.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from rest_framework import generics, permissions, status, views
|
from rest_framework import generics, permissions, status, views
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from akarpov.common.api.pagination import SmallResultsSetPagination
|
from akarpov.common.api.pagination import SmallResultsSetPagination
|
||||||
from akarpov.common.jwt import sign_jwt
|
from akarpov.common.jwt import sign_jwt
|
||||||
from akarpov.users.api.serializers import (
|
from akarpov.users.api.serializers import (
|
||||||
|
OTPSerializer,
|
||||||
UserEmailVerification,
|
UserEmailVerification,
|
||||||
UserFullPublicInfoSerializer,
|
UserFullPublicInfoSerializer,
|
||||||
UserFullSerializer,
|
UserFullSerializer,
|
||||||
|
@ -105,3 +108,24 @@ class UserUpdatePasswordAPIView(generics.UpdateAPIView):
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
return self.request.user
|
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.account.forms import SignupForm
|
||||||
from allauth.socialaccount.forms import SignupForm as SocialSignupForm
|
from allauth.socialaccount.forms import SignupForm as SocialSignupForm
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth import forms as admin_forms
|
from django.contrib.auth import forms as admin_forms
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.forms import DateInput, TextInput
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from akarpov.users.models import UserAPIToken
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
@ -45,3 +50,80 @@ class UserSocialSignupForm(SocialSignupForm):
|
||||||
|
|
||||||
class OTPForm(forms.Form):
|
class OTPForm(forms.Form):
|
||||||
otp_token = forms.CharField()
|
otp_token = forms.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class TokenCreationForm(forms.ModelForm):
|
||||||
|
permissions = forms.MultipleChoiceField(
|
||||||
|
choices=[], # To be set in __init__
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserAPIToken
|
||||||
|
fields = ["name", "active_until", "permissions"]
|
||||||
|
widgets = {
|
||||||
|
"name": TextInput(attrs={"placeholder": "Token Name (Optional)"}),
|
||||||
|
"active_until": DateInput(attrs={"type": "date"}, format="%d.%m.%Y"),
|
||||||
|
}
|
||||||
|
# Make active_until not required
|
||||||
|
required = {
|
||||||
|
"active_until": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
permissions_context = kwargs.pop("permissions_context", None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if permissions_context:
|
||||||
|
for app, actions in permissions_context.items():
|
||||||
|
field_name = f"permissions_{app}"
|
||||||
|
self.fields[field_name] = forms.MultipleChoiceField(
|
||||||
|
choices=[(action, action) for action in actions.keys()],
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
required=False,
|
||||||
|
label=app.capitalize(),
|
||||||
|
initial=[
|
||||||
|
item
|
||||||
|
for sublist in kwargs.get("initial", {}).get(field_name, [])
|
||||||
|
for item in sublist
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.fields["active_until"].required = False
|
||||||
|
|
||||||
|
def get_permissions_choices(self):
|
||||||
|
permissions_choices = [
|
||||||
|
(f"{app}.{action}", description)
|
||||||
|
for app, actions in UserAPIToken.permission_template.items()
|
||||||
|
for action, description in actions.items()
|
||||||
|
]
|
||||||
|
return permissions_choices
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
structured_permissions = {
|
||||||
|
category: {perm: False for perm in permissions.keys()}
|
||||||
|
for category, permissions in UserAPIToken.permission_template.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
for category in structured_permissions.keys():
|
||||||
|
input_field_name = f"permissions_{category}"
|
||||||
|
if input_field_name in self.data:
|
||||||
|
selected_perms = self.data.getlist(input_field_name)
|
||||||
|
for perm in selected_perms:
|
||||||
|
if perm in structured_permissions[category]:
|
||||||
|
structured_permissions[category][perm] = True
|
||||||
|
|
||||||
|
cleaned_data["permissions"] = json.dumps(structured_permissions)
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
permissions_json = self.cleaned_data.get("permissions", "{}")
|
||||||
|
instance.permissions = json.loads(permissions_json)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
from cacheops import cached_as
|
from cacheops import cached_as
|
||||||
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.urls import resolve
|
from django.urls import resolve, reverse
|
||||||
from django.utils.deprecation import MiddlewareMixin
|
from django.utils.deprecation import MiddlewareMixin
|
||||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
|
from akarpov.users.models import UserAPIToken
|
||||||
|
|
||||||
|
|
||||||
class EmailVerificationMiddleware(MiddlewareMixin):
|
class EmailVerificationMiddleware(MiddlewareMixin):
|
||||||
def process_request(self, request):
|
def process_request(self, request):
|
||||||
|
@ -20,11 +23,27 @@ def __init__(self, get_response):
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
response = self.get_response(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
|
# Check user is authenticated and OTP token input is not completed
|
||||||
is_authenticated = request.user.is_authenticated
|
is_authenticated = request.user.is_authenticated
|
||||||
otp_not_verified = not request.session.get("otp_verified", False)
|
otp_not_verified = not request.session.get("otp_verified", False)
|
||||||
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
|
# Caches the checker for has_otp_device
|
||||||
@cached_as(
|
@cached_as(
|
||||||
|
@ -33,7 +52,6 @@ def __call__(self, request):
|
||||||
def has_otp_device(user):
|
def has_otp_device(user):
|
||||||
return TOTPDevice.objects.devices_for_user(user, confirmed=True).exists()
|
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 (
|
if (
|
||||||
is_authenticated
|
is_authenticated
|
||||||
and has_otp_device(request.user)
|
and has_otp_device(request.user)
|
||||||
|
@ -41,6 +59,17 @@ def has_otp_device(user):
|
||||||
and not on_2fa_page
|
and not on_2fa_page
|
||||||
):
|
):
|
||||||
request.session["next"] = request.get_full_path()
|
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 redirect("users:enforce_otp_login")
|
||||||
|
|
||||||
return response
|
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.auth.models import AbstractUser
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from akarpov.common.models import BaseImageModel
|
from akarpov.common.models import BaseImageModel
|
||||||
|
@ -78,6 +81,46 @@ def __str__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class UserNotification:
|
class UserAPIToken(models.Model):
|
||||||
# TODO: add notification system
|
name = models.CharField(max_length=255, blank=True, null=True)
|
||||||
...
|
user = models.ForeignKey(
|
||||||
|
"User", related_name="api_tokens", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
token = models.CharField(max_length=255, unique=True, db_index=True)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
active_until = models.DateTimeField(null=True)
|
||||||
|
permissions = models.JSONField(default=dict)
|
||||||
|
last_used = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
permission_template = {
|
||||||
|
"music": {
|
||||||
|
"listen": "Listen to music",
|
||||||
|
"upload": "Upload music",
|
||||||
|
"playlist": "Manage playlists",
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"edit": "Edit user profile",
|
||||||
|
"delete": "Delete user profile",
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"shorten": "Shorten links",
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"upload": "Upload files",
|
||||||
|
"download": "Download files",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.token
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
return self.active_until is None or self.active_until > timezone.now()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_token():
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
while UserAPIToken.objects.filter(token=token).exists():
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
return token
|
||||||
|
|
11
akarpov/users/tasks.py
Normal file
11
akarpov/users/tasks.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
from celery import shared_task
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from akarpov.users.models import UserAPIToken
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def set_last_active_token(token: str):
|
||||||
|
token = UserAPIToken.objects.get(token=token)
|
||||||
|
token.last_used = timezone.now()
|
||||||
|
token.save()
|
|
@ -1,13 +1,17 @@
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
from akarpov.users.views import (
|
from akarpov.users.views import (
|
||||||
|
create_token,
|
||||||
|
delete_token,
|
||||||
enable_2fa_view,
|
enable_2fa_view,
|
||||||
enforce_otp_login,
|
enforce_otp_login,
|
||||||
|
list_tokens,
|
||||||
user_detail_view,
|
user_detail_view,
|
||||||
user_history_delete_view,
|
user_history_delete_view,
|
||||||
user_history_view,
|
user_history_view,
|
||||||
user_redirect_view,
|
user_redirect_view,
|
||||||
user_update_view,
|
user_update_view,
|
||||||
|
view_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = "users"
|
app_name = "users"
|
||||||
|
@ -17,7 +21,11 @@
|
||||||
path("update/", view=user_update_view, name="update"),
|
path("update/", view=user_update_view, name="update"),
|
||||||
path("history/", view=user_history_view, name="history"),
|
path("history/", view=user_history_view, name="history"),
|
||||||
path("history/delete", view=user_history_delete_view, name="history_delete"),
|
path("history/delete", view=user_history_delete_view, name="history_delete"),
|
||||||
path("<str:username>/", view=user_detail_view, name="detail"),
|
path("<str:username>", view=user_detail_view, name="detail"),
|
||||||
path("2fa/login", enforce_otp_login, name="enforce_otp_login"),
|
path("2fa/login", enforce_otp_login, name="enforce_otp_login"),
|
||||||
path("2fa/enable", enable_2fa_view, name="enable_2fa"),
|
path("2fa/enable", enable_2fa_view, name="enable_2fa"),
|
||||||
|
path("tokens/", list_tokens, name="list_tokens"),
|
||||||
|
path("tokens/create/", create_token, name="create_token"),
|
||||||
|
path("tokens/<int:token_id>/", view_token, name="view_token"),
|
||||||
|
path("tokens/<int:token_id>/delete/", delete_token, name="delete_token"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,16 +5,16 @@
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect, QueryDict
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView, RedirectView, UpdateView
|
from django.views.generic import DetailView, ListView, RedirectView, UpdateView
|
||||||
from django_otp import user_has_device
|
from django_otp import user_has_device
|
||||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
|
|
||||||
from akarpov.users.forms import OTPForm
|
from akarpov.users.forms import OTPForm, TokenCreationForm
|
||||||
from akarpov.users.models import UserHistory
|
from akarpov.users.models import UserAPIToken, UserHistory
|
||||||
from akarpov.users.services.history import create_history_warning_note
|
from akarpov.users.services.history import create_history_warning_note
|
||||||
from akarpov.users.services.two_factor import generate_qr_code
|
from akarpov.users.services.two_factor import generate_qr_code
|
||||||
from akarpov.users.themes.models import Theme
|
from akarpov.users.themes.models import Theme
|
||||||
|
@ -103,6 +103,8 @@ def get_redirect_url(self):
|
||||||
def enable_2fa_view(request):
|
def enable_2fa_view(request):
|
||||||
user = request.user
|
user = request.user
|
||||||
devices = TOTPDevice.objects.filter(user=user, confirmed=True)
|
devices = TOTPDevice.objects.filter(user=user, confirmed=True)
|
||||||
|
qr_code_svg = None
|
||||||
|
totp_key = None
|
||||||
|
|
||||||
if devices.exists():
|
if devices.exists():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
@ -184,7 +186,11 @@ def form_valid(self, form):
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def enforce_otp_login(request):
|
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":
|
if request.method == "POST":
|
||||||
form = OTPForm(request.POST)
|
form = OTPForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
@ -192,8 +198,75 @@ def enforce_otp_login(request):
|
||||||
device = TOTPDevice.objects.filter(user=request.user).first()
|
device = TOTPDevice.objects.filter(user=request.user).first()
|
||||||
if device.verify_token(otp_token):
|
if device.verify_token(otp_token):
|
||||||
request.session["otp_verified"] = True
|
request.session["otp_verified"] = True
|
||||||
success_url = request.session.pop("next", None) or reverse_lazy("home")
|
request.session.pop("next", None)
|
||||||
return HttpResponseRedirect(success_url)
|
return redirect(next_url)
|
||||||
else:
|
else:
|
||||||
form = OTPForm()
|
form = OTPForm()
|
||||||
return render(request, "users/otp_verify.html", {"form": form})
|
return render(request, "users/otp_verify.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def list_tokens(request):
|
||||||
|
tokens = UserAPIToken.objects.filter(user=request.user).order_by("last_used")
|
||||||
|
return render(request, "users/list_tokens.html", {"tokens": tokens})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def create_token(request):
|
||||||
|
initial_data = {}
|
||||||
|
|
||||||
|
# Обработка параметров 'name' и 'active_until'
|
||||||
|
if "name" in request.GET:
|
||||||
|
initial_data["name"] = request.GET["name"]
|
||||||
|
if "active_until" in request.GET:
|
||||||
|
initial_data["active_until"] = request.GET["active_until"]
|
||||||
|
|
||||||
|
# Создаем QueryDict для разрешений, чтобы правильно обработать повторяющиеся ключи
|
||||||
|
permissions_query_dict = QueryDict("", mutable=True)
|
||||||
|
|
||||||
|
# Разбор параметров разрешений
|
||||||
|
permissions = request.GET.getlist("permissions")
|
||||||
|
for perm in permissions:
|
||||||
|
category, permission = perm.split(".")
|
||||||
|
permissions_query_dict.update({f"permissions_{category}": [permission]})
|
||||||
|
|
||||||
|
# Переводим QueryDict в обычный словарь для использования в initial
|
||||||
|
permissions_data = {key: value for key, value in permissions_query_dict.lists()}
|
||||||
|
|
||||||
|
initial_data.update(permissions_data)
|
||||||
|
|
||||||
|
for key, value_list in permissions_data.items():
|
||||||
|
initial_data[key] = [item for sublist in value_list for item in sublist]
|
||||||
|
|
||||||
|
form = TokenCreationForm(
|
||||||
|
initial=initial_data, permissions_context=UserAPIToken.permission_template
|
||||||
|
)
|
||||||
|
if request.method == "POST":
|
||||||
|
print(request.POST)
|
||||||
|
form = TokenCreationForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
new_token = form.save(commit=False)
|
||||||
|
new_token.user = request.user
|
||||||
|
new_token.token = UserAPIToken.generate_token()
|
||||||
|
new_token.save()
|
||||||
|
token_created = new_token.token
|
||||||
|
return render(
|
||||||
|
request, "users/token_created.html", {"new_token": token_created}
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(request, "users/create_token.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def view_token(request, token_id):
|
||||||
|
token = get_object_or_404(UserAPIToken, id=token_id, user=request.user)
|
||||||
|
return render(request, "users/view_token.html", {"token": token})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def delete_token(request, token_id):
|
||||||
|
token = get_object_or_404(UserAPIToken, id=token_id, user=request.user)
|
||||||
|
if request.method == "POST":
|
||||||
|
token.delete()
|
||||||
|
return redirect("users:list_tokens")
|
||||||
|
return render(request, "users/confirm_delete_token.html", {"token": token})
|
||||||
|
|
|
@ -33,6 +33,13 @@ RUN apt-get update && \
|
||||||
apt-get purge -y --auto-remove -o APT:AutoRemove:RecommendsImportant=false && \
|
apt-get purge -y --auto-remove -o APT:AutoRemove:RecommendsImportant=false && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
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 pip install "poetry==$POETRY_VERSION"
|
||||||
RUN python -m venv /venv
|
RUN python -m venv /venv
|
||||||
|
|
||||||
|
|
|
@ -5,4 +5,4 @@ set -o nounset
|
||||||
|
|
||||||
/install_preview_dependencies
|
/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.
|
Base settings to build other settings files upon.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import environ
|
import environ
|
||||||
|
@ -78,6 +79,7 @@
|
||||||
"auth.permission": {"ops": "all", "timeout": 60 * 15},
|
"auth.permission": {"ops": "all", "timeout": 60 * 15},
|
||||||
"music.*": {"ops": ("fetch", "get", "list"), "timeout": 60 * 15},
|
"music.*": {"ops": ("fetch", "get", "list"), "timeout": 60 * 15},
|
||||||
"otp_totp.totpdevice": {"ops": "all", "timeout": 15 * 60},
|
"otp_totp.totpdevice": {"ops": "all", "timeout": 15 * 60},
|
||||||
|
"users.userapitoken": {"ops": "all", "timeout": 20 * 60},
|
||||||
}
|
}
|
||||||
CACHEOPS_REDIS = env.str("REDIS_URL")
|
CACHEOPS_REDIS = env.str("REDIS_URL")
|
||||||
|
|
||||||
|
@ -131,8 +133,7 @@
|
||||||
"rest_framework.authtoken",
|
"rest_framework.authtoken",
|
||||||
"corsheaders",
|
"corsheaders",
|
||||||
"drf_spectacular",
|
"drf_spectacular",
|
||||||
"ckeditor",
|
"django_ckeditor_5",
|
||||||
"ckeditor_uploader",
|
|
||||||
"colorfield",
|
"colorfield",
|
||||||
"polymorphic",
|
"polymorphic",
|
||||||
"cacheops",
|
"cacheops",
|
||||||
|
@ -510,6 +511,7 @@
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
"rest_framework.authentication.SessionAuthentication",
|
"rest_framework.authentication.SessionAuthentication",
|
||||||
"rest_framework.authentication.TokenAuthentication",
|
"rest_framework.authentication.TokenAuthentication",
|
||||||
|
"akarpov.users.api.authentification.UserTokenAuthentication",
|
||||||
),
|
),
|
||||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||||
|
@ -530,24 +532,128 @@
|
||||||
|
|
||||||
# CKEDITOR
|
# CKEDITOR
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
CKEDITOR_UPLOAD_PATH = "uploads/"
|
CKEDITOR_5_UPLOAD_PATH = "uploads/"
|
||||||
CKEDITOR_CONFIGS = {
|
CKEDITOR_5_CONFIGS = {
|
||||||
"default": {
|
"default": {
|
||||||
"width": "full",
|
"toolbar": [
|
||||||
"extra_plugins": [
|
"heading",
|
||||||
"autosave",
|
"|",
|
||||||
"autogrow",
|
"bold",
|
||||||
"autolink",
|
"italic",
|
||||||
"autoembed",
|
"link",
|
||||||
"clipboard",
|
"bulletedList",
|
||||||
"dialog",
|
"numberedList",
|
||||||
"dialogui",
|
"blockQuote",
|
||||||
|
"imageUpload",
|
||||||
],
|
],
|
||||||
"autosave": {
|
},
|
||||||
"autoLoad": True,
|
"extends": {
|
||||||
"delay": 60,
|
"blockToolbar": [
|
||||||
"NotOlderThen": 20,
|
"paragraph",
|
||||||
|
"heading1",
|
||||||
|
"heading2",
|
||||||
|
"heading3",
|
||||||
|
"|",
|
||||||
|
"bulletedList",
|
||||||
|
"numberedList",
|
||||||
|
"|",
|
||||||
|
"blockQuote",
|
||||||
|
],
|
||||||
|
"toolbar": [
|
||||||
|
"heading",
|
||||||
|
"|",
|
||||||
|
"outdent",
|
||||||
|
"indent",
|
||||||
|
"|",
|
||||||
|
"bold",
|
||||||
|
"italic",
|
||||||
|
"link",
|
||||||
|
"underline",
|
||||||
|
"strikethrough",
|
||||||
|
"code",
|
||||||
|
"subscript",
|
||||||
|
"superscript",
|
||||||
|
"highlight",
|
||||||
|
"|",
|
||||||
|
"codeBlock",
|
||||||
|
"sourceEditing",
|
||||||
|
"insertImage",
|
||||||
|
"bulletedList",
|
||||||
|
"numberedList",
|
||||||
|
"todoList",
|
||||||
|
"|",
|
||||||
|
"blockQuote",
|
||||||
|
"imageUpload",
|
||||||
|
"|",
|
||||||
|
"fontSize",
|
||||||
|
"fontFamily",
|
||||||
|
"fontColor",
|
||||||
|
"fontBackgroundColor",
|
||||||
|
"mediaEmbed",
|
||||||
|
"removeFormat",
|
||||||
|
"insertTable",
|
||||||
|
],
|
||||||
|
"image": {
|
||||||
|
"toolbar": [
|
||||||
|
"imageTextAlternative",
|
||||||
|
"|",
|
||||||
|
"imageStyle:alignLeft",
|
||||||
|
"imageStyle:alignRight",
|
||||||
|
"imageStyle:alignCenter",
|
||||||
|
"imageStyle:side",
|
||||||
|
"|",
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"full",
|
||||||
|
"side",
|
||||||
|
"alignLeft",
|
||||||
|
"alignRight",
|
||||||
|
"alignCenter",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
"table": {
|
||||||
|
"contentToolbar": [
|
||||||
|
"tableColumn",
|
||||||
|
"tableRow",
|
||||||
|
"mergeTableCells",
|
||||||
|
"tableProperties",
|
||||||
|
"tableCellProperties",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"heading": {
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"model": "paragraph",
|
||||||
|
"title": "Paragraph",
|
||||||
|
"class": "ck-heading_paragraph",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "heading1",
|
||||||
|
"view": "h1",
|
||||||
|
"title": "Heading 1",
|
||||||
|
"class": "ck-heading_heading1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "heading2",
|
||||||
|
"view": "h2",
|
||||||
|
"title": "Heading 2",
|
||||||
|
"class": "ck-heading_heading2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "heading3",
|
||||||
|
"view": "h3",
|
||||||
|
"title": "Heading 3",
|
||||||
|
"class": "ck-heading_heading3",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"properties": {
|
||||||
|
"styles": "true",
|
||||||
|
"startIndex": "true",
|
||||||
|
"reversed": "true",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -43,7 +43,9 @@
|
||||||
path("forms/", include("akarpov.test_platform.urls", namespace="forms")),
|
path("forms/", include("akarpov.test_platform.urls", namespace="forms")),
|
||||||
path("tools/", include("akarpov.tools.urls", namespace="tools")),
|
path("tools/", include("akarpov.tools.urls", namespace="tools")),
|
||||||
path("gallery/", include("akarpov.gallery.urls", namespace="gallery")),
|
path("gallery/", include("akarpov.gallery.urls", namespace="gallery")),
|
||||||
path("ckeditor/", include("ckeditor_uploader.urls")),
|
path(
|
||||||
|
"ckeditor5/", include("django_ckeditor_5.urls"), name="ck_editor_5_upload_file"
|
||||||
|
),
|
||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
path("accounts/login/", OTPLoginView.as_view(), name="account_login"),
|
path("accounts/login/", OTPLoginView.as_view(), name="account_login"),
|
||||||
path("", include("akarpov.blog.urls", namespace="blog")),
|
path("", include("akarpov.blog.urls", namespace="blog")),
|
||||||
|
|
|
@ -101,7 +101,9 @@ services:
|
||||||
command: /start-flower
|
command: /start-flower
|
||||||
|
|
||||||
elasticsearch:
|
elasticsearch:
|
||||||
image: elasticsearch:8.11.1
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./compose/production/elasticsearch/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "9200:9200"
|
- "9200:9200"
|
||||||
- "9300:9300"
|
- "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"
|
django-crispy-forms = "^2.1"
|
||||||
crispy-bootstrap5 = "^0.7"
|
crispy-bootstrap5 = "^0.7"
|
||||||
django-redis = "^5.2.0"
|
django-redis = "^5.2.0"
|
||||||
django-ckeditor = "^6.5.1"
|
|
||||||
django-colorfield = "^0.11.0"
|
django-colorfield = "^0.11.0"
|
||||||
djangorestframework = "^3.14.0"
|
djangorestframework = "^3.14.0"
|
||||||
django-rest-auth = "^0.9.5"
|
django-rest-auth = "^0.9.5"
|
||||||
|
@ -121,6 +120,7 @@ python-levenshtein = "^0.23.0"
|
||||||
pylast = "^5.2.0"
|
pylast = "^5.2.0"
|
||||||
textract = {git = "https://github.com/Alexander-D-Karpov/textract.git", branch = "master"}
|
textract = {git = "https://github.com/Alexander-D-Karpov/textract.git", branch = "master"}
|
||||||
librosa = "^0.10.1"
|
librosa = "^0.10.1"
|
||||||
|
django-ckeditor-5 = "^0.2.12"
|
||||||
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|
Loading…
Reference in New Issue
Block a user