Compare commits

...

24 Commits

Author SHA1 Message Date
dependabot[bot]
9e505ef135
Merge 7c8ae20191 into 2b2c16db2d 2024-03-31 02:46:13 +05:00
2b2c16db2d updated base template 2024-03-30 01:19:38 +03:00
ca199383d4 bug fixes 2024-03-30 01:03:46 +03:00
4794664e6a added User API Integration token, major updates 2024-03-30 01:02:07 +03:00
5088ab308a made volume more precise 2024-03-08 12:59:48 +03:00
5e0fafd3b7 updated search, improved music tasks 2024-03-05 23:34:16 +03:00
a2da7e724f updated elastic build for ru ip 2024-03-02 02:34:44 +03:00
7c9890975b fixed youtube track download, improved search 2024-03-02 02:29:49 +03:00
3acd858598 added distinct images to playlist, authors to album list, minor bug fixes 2024-02-22 16:02:41 +03:00
87fc3a906f fixing git 2024-02-03 02:50:46 +03:00
3e26cb24a6 Major 2fa updates 2024-02-03 02:41:50 +03:00
6a7e7d5ade Fixing git error 2024-02-01 17:13:01 +03:00
1524791779 Fixing git error 2024-02-01 17:06:07 +03:00
6d9edbf95d Fixing git error 2024-02-01 17:04:11 +03:00
6a21158a62 Update textract dependency URL in pyproject.toml 2024-02-01 16:55:42 +03:00
ffa1e9c69f Refine YouTube and Spotify music download process 2024-02-01 16:46:45 +03:00
a87385db78 Implemented URL parsing and music identification improvements 2024-02-01 15:41:12 +03:00
aa49e4afc3 update author save 2024-02-01 03:51:44 +03:00
a309d5653d updated cache root 2024-02-01 03:15:22 +03:00
c81b387689 updated cache root 2024-02-01 03:00:27 +03:00
db72084d64 Refactor music service and add Spotify support 2024-02-01 02:47:29 +03:00
0189377aeb fixed track name 2024-01-19 11:11:27 +03:00
d3b1fe5fa1 fixed track name 2024-01-18 23:10:12 +03:00
b76a40aa02 fixed track processing, youtube handling 2024-01-18 22:15:17 +03:00
51 changed files with 2586 additions and 1841 deletions

View File

@ -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",
)
}

View File

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

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.10 on 2024-03-29 15:35
from django.db import migrations
import django_ckeditor_5.fields
class Migration(migrations.Migration):
dependencies = [
("blog", "0010_alter_tag_color"),
]
operations = [
migrations.AlterField(
model_name="post",
name="body",
field=django_ckeditor_5.fields.CKEditor5Field(),
),
]

View File

@ -1,8 +1,8 @@
from ckeditor_uploader.fields import RichTextUploadingField
from colorfield.fields import ColorField from 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")

View File

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

View File

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

View File

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

View File

@ -0,0 +1,56 @@
from rest_framework import permissions
from akarpov.users.models import User, UserAPIToken
class GetBaseMusicPermission(permissions.BasePermission):
def get_token_data(self, request) -> (dict, User | None):
try:
token = request.headers["Authorization"]
if " " in token:
token = token.split(" ")[1]
except (KeyError, IndexError):
return {
"listen": False,
"upload": False,
"playlist": False,
}, None
try:
token = UserAPIToken.objects.cache().get(token=token)
except UserAPIToken.DoesNotExist:
return {
"listen": False,
"upload": False,
"playlist": False,
}, None
if "music" not in token.permissions:
return {
"listen": False,
"upload": False,
"playlist": False,
}, token.user
return token.permissions["music"], token.user
class CanListenToMusic(GetBaseMusicPermission):
def has_permission(self, request, view):
token_data = self.get_token_data(request)
if "listen" in token_data:
return token_data["listen"]
return False
class CanUploadMusic(GetBaseMusicPermission):
def has_permission(self, request, view):
token_data = self.get_token_data(request)
if "upload" in token_data:
return token_data["upload"]
return False
class CanManagePlaylists(GetBaseMusicPermission):
def has_permission(self, request, view):
token_data = self.get_token_data(request)
if "playlist" in token_data:
return token_data["playlist"]
return False

View File

@ -22,9 +22,17 @@ class Meta:
class ListAlbumSerializer(serializers.ModelSerializer): 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

View File

@ -39,24 +39,96 @@ class SongDocument(Document):
}, },
) )
meta = fields.ObjectField(dynamic=True) # Added meta field here as dynamic object meta = fields.ObjectField(dynamic=True)
class Index: 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -100,6 +100,7 @@
<li><a class="dropdown-item {% active_link 'tools:promocodes:activate' %}" href="{% url 'tools:promocodes:activate' %}">Activate promocode</a></li> <li><a class="dropdown-item {% active_link '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">

View File

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

View File

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>Confirm Deletion</h2>
<p>Are you sure you want to delete this token?</p>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Yes, delete it</button>
<a href="{% url 'users:list_tokens' %}" class="btn btn-secondary">Cancel</a>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container mt-4">
<h2>Create API Token</h2>
<form method="post">
{% csrf_token %}
{% for field in form %}
{% if field.name|slice:":12" == 'permissions_' %}
<fieldset>
{{ field|as_crispy_field }}
</fieldset>
{% else %}
{{ field|as_crispy_field }}
{% endif %}
{% endfor %}
<button type="submit" class="btn btn-primary mt-2">Create Token</button>
</form>
</div>
{% endblock %}

View File

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

View File

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% load humanize %}
{% block content %}
<div class="container mt-4">
<h2>My API Tokens</h2>
<div class="mb-3">
<a href="{% url 'users:create_token' %}" class="btn btn-primary">Create New Token</a>
</div>
<ul class="list-group">
{% for token in tokens %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
{% if token.name %}{{ token.name }}{% else %}<em>Unnamed Token</em>{% endif %}
<br>
<small>{{ token.token|slice:":5" }}***{{ token.token|slice:"-5:" }}</small>
</div>
<div>
<a href="{% url 'users:view_token' token.id %}" class="btn btn-sm btn-outline-primary">Details</a>
<a href="{% url 'users:delete_token' token.id %}" class="btn btn-sm btn-outline-danger">Delete</a>
</div>
</li>
{% empty %}
<li class="list-group-item">No tokens found.</li>
{% endfor %}
</ul>
</div>
{% endblock %}

View File

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

View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container mt-4">
<h2>Token Created Successfully</h2>
<p>Your new API token is:</p>
<p><code>{{ new_token }}</code><button class="btn" data-clipboard-text="{{ new_token }}">
<i style="font-size: 0.8em" class="bi bi-clipboard ml-2"></i>
</button></p>
<p>Please note it down. You won't be able to see it again.</p>
<a href="{% url 'users:list_tokens' %}" class="btn btn-primary mt-4">Back to Tokens</a>
</div>
<script src="{% static 'js/clipboard.min.js' %}"></script>
<script>
new ClipboardJS('.btn');
</script>
{% endblock %}

View File

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>Token Details</h2>
{% if token.name %}
<p><strong>Name:</strong> {{ token.name }}</p>
{% endif %}
<p><strong>Token:</strong> {{ token.token|slice:":5" }}***{{ token.token|slice:"-5:" }}</p>
<p><strong>Last Used:</strong> {{ token.last_used|date:"Y-m-d H:i:s" }} ({{ token.last_used|timesince }} ago)</p>
<p><strong>Active Until:</strong> {{ token.active_until|date:"Y-m-d" }}</p>
<p><strong>Permissions:</strong></p>
<ul>
{% for app, actions in token.permissions.items %}
<li><strong>{{ app|title }}:</strong>
<ul>
{% for action, value in actions.items %}
<li>{{ action|title }}: {{ value|yesno:"✅,❌" }}</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
{# <a href="{% url 'edit_token' token.id %}" class="btn btn-primary">Edit</a> TODO #}
<a href="{% url 'users:delete_token' token.id %}" class="btn btn-danger">Delete</a>
</div>
{% endblock %}

View File

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

View File

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

View File

@ -0,0 +1,22 @@
from rest_framework.authentication import BaseAuthentication
from akarpov.users.models import UserAPIToken
from akarpov.users.tasks import set_last_active_token
class UserTokenAuthentication(BaseAuthentication):
def authenticate(self, request):
if "Authorization" not in request.headers:
return None
token = request.headers["Authorization"]
if " " in token:
token = token.split(" ")[1]
try:
token = UserAPIToken.objects.cache().get(token=token)
except UserAPIToken.DoesNotExist:
return None
if not token.is_active:
return None
set_last_active_token.delay(token.token)
return token.user, token

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,40 @@
# Generated by Django 4.2.10 on 2024-03-29 15:18
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("users", "0014_alter_user_agree_data_to_be_sold"),
]
operations = [
migrations.CreateModel(
name="UserAPIToken",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("token", models.CharField(db_index=True, max_length=255, unique=True)),
("created", models.DateTimeField(auto_now_add=True)),
("active_until", models.DateTimeField(null=True)),
("permissions", models.JSONField(default=dict)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="api_tokens",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.10 on 2024-03-29 18:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0015_userapitoken"),
]
operations = [
migrations.AddField(
model_name="userapitoken",
name="last_used",
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.10 on 2024-03-29 19:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0016_userapitoken_last_used"),
]
operations = [
migrations.AddField(
model_name="userapitoken",
name="name",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -1,9 +1,12 @@
import secrets
from django.contrib.auth.models import AbstractUser from django.contrib.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
View File

@ -0,0 +1,11 @@
from celery import shared_task
from django.utils import timezone
from akarpov.users.models import UserAPIToken
@shared_task
def set_last_active_token(token: str):
token = UserAPIToken.objects.get(token=token)
token.last_used = timezone.now()
token.save()

View File

@ -1,13 +1,17 @@
from django.urls import include, path from 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"),
] ]

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
""" """
Base settings to build other settings files upon. 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",
}
}, },
} }

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,6 @@ django-allauth = "^0.54.0"
django-crispy-forms = "^2.1" 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]