mirror of
https://github.com/Alexander-D-Karpov/akarpov
synced 2024-11-27 20:33:44 +03:00
Compare commits
1 Commits
b5c0ceb340
...
e90c6addf3
Author | SHA1 | Date | |
---|---|---|---|
|
e90c6addf3 |
|
@ -1,12 +1,11 @@
|
||||||
|
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 = CKEditor5Field(config_name="extends")
|
body = RichTextFormField(label="")
|
||||||
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
|
||||||
|
@ -15,9 +14,3 @@ 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,5 +1,6 @@
|
||||||
# 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
|
||||||
|
@ -55,7 +56,7 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("title", models.CharField(max_length=100)),
|
("title", models.CharField(max_length=100)),
|
||||||
("body", models.TextField()),
|
("body", ckeditor_uploader.fields.RichTextUploadingField()),
|
||||||
("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)),
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
# 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 = CKEditor5Field(blank=False, config_name="extends")
|
body = RichTextUploadingField(blank=False)
|
||||||
|
|
||||||
creator = models.ForeignKey(User, on_delete=models.CASCADE, related_name="posts")
|
creator = models.ForeignKey(User, on_delete=models.CASCADE, related_name="posts")
|
||||||
|
|
||||||
|
|
|
@ -1,56 +0,0 @@
|
||||||
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,17 +22,9 @@ 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", "authors"]
|
fields = ["name", "slug", "image_cropped"]
|
||||||
|
|
||||||
|
|
||||||
class SongSerializer(serializers.ModelSerializer):
|
class SongSerializer(serializers.ModelSerializer):
|
||||||
|
@ -141,32 +133,10 @@ 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", "images", "private", "creator"]
|
fields = ["name", "length", "slug", "private", "creator"]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"slug": {"read_only": True},
|
"slug": {"read_only": True},
|
||||||
"creator": {"read_only": True},
|
"creator": {"read_only": True},
|
||||||
|
@ -174,21 +144,13 @@ class Meta:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class FullPlaylistSerializer(PlaylistSerializer):
|
class FullPlaylistSerializer(serializers.ModelSerializer):
|
||||||
songs = serializers.SerializerMethodField(method_name="get_songs")
|
songs = ListSongSerializer(many=True, read_only=True)
|
||||||
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", "images", "songs"]
|
fields = ["name", "private", "creator", "songs"]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"slug": {"read_only": True},
|
"slug": {"read_only": True},
|
||||||
"creator": {"read_only": True},
|
"creator": {"read_only": True},
|
||||||
|
@ -196,8 +158,8 @@ class Meta:
|
||||||
|
|
||||||
|
|
||||||
class AddSongToPlaylistSerializer(serializers.ModelSerializer):
|
class AddSongToPlaylistSerializer(serializers.ModelSerializer):
|
||||||
song = serializers.SlugField(write_only=True)
|
song = serializers.SlugField()
|
||||||
playlist = serializers.SlugField(write_only=True)
|
playlist = serializers.SlugField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Playlist
|
model = Playlist
|
||||||
|
|
|
@ -39,96 +39,24 @@ class SongDocument(Document):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
meta = fields.ObjectField(dynamic=True)
|
meta = fields.ObjectField(dynamic=True) # Added meta field here as dynamic object
|
||||||
|
|
||||||
class Index:
|
class Index:
|
||||||
name = "songs"
|
name = "songs"
|
||||||
settings = {
|
settings = {"number_of_shards": 1, "number_of_replicas": 0}
|
||||||
"number_of_shards": 1,
|
# settings = {
|
||||||
"number_of_replicas": 0,
|
# "number_of_shards": 1,
|
||||||
"analysis": {
|
# "number_of_replicas": 0,
|
||||||
"filter": {
|
# "analysis": {
|
||||||
"russian_stop": {
|
# "analyzer": {
|
||||||
"type": "stop",
|
# "russian_icu": {
|
||||||
"stopwords": "_russian_",
|
# "type": "custom",
|
||||||
},
|
# "tokenizer": "icu_tokenizer",
|
||||||
"russian_keywords": {
|
# "filter": ["icu_folding","icu_normalizer"]
|
||||||
"type": "keyword_marker",
|
# }
|
||||||
"keywords": ["пример"],
|
# }
|
||||||
},
|
# }
|
||||||
"russian_stemmer": {
|
# } TODO
|
||||||
"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
|
||||||
|
|
|
@ -72,16 +72,6 @@ def load_track(
|
||||||
name = search_info["title"]
|
name = search_info["title"]
|
||||||
elif not name:
|
elif not name:
|
||||||
name = process_track_name(" ".join(p_name.strip().split("-")))
|
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:
|
if not name:
|
||||||
name = orig_name
|
name = orig_name
|
||||||
|
|
|
@ -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.1 * sr)
|
frame_length = int(0.5 * 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
|
||||||
|
|
|
@ -360,7 +360,6 @@ def update_author_info(author: Author) -> None:
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
|
|
|
@ -13,9 +13,9 @@ def search_song(query):
|
||||||
ES_Q(
|
ES_Q(
|
||||||
"multi_match",
|
"multi_match",
|
||||||
query=query,
|
query=query,
|
||||||
fields=["name^5", "authors.name^3", "album.name^3"],
|
fields=["name^3", "authors.name^2", "album.name"],
|
||||||
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,7 +27,6 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -157,25 +157,22 @@ 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 "album_image" in info and info["album_image"]:
|
if not info["album_image"].startswith("/"):
|
||||||
if not info["album_image"].startswith("/"):
|
r = requests.get(info["album_image"])
|
||||||
r = requests.get(info["album_image"])
|
img_pth = str(
|
||||||
img_pth = str(
|
settings.MEDIA_ROOT
|
||||||
settings.MEDIA_ROOT
|
+ f"/{info['album_image'].split('/')[-1]}_{str(randint(100, 999))}"
|
||||||
+ f"/{info['album_image'].split('/')[-1]}_{str(randint(100, 999))}"
|
)
|
||||||
)
|
with open(img_pth, "wb") as f:
|
||||||
with open(img_pth, "wb") as f:
|
f.write(r.content)
|
||||||
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 = None
|
img_pth = info["album_image"]
|
||||||
if "genre" in info:
|
if "genre" in info:
|
||||||
song = load_track(
|
song = load_track(
|
||||||
path,
|
path,
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
import pylast
|
import pylast
|
||||||
import spotipy
|
import spotipy
|
||||||
|
@ -29,7 +28,7 @@
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@shared_task(soft_time_limit=60 * 20, time_limit=60 * 30)
|
@shared_task
|
||||||
def list_tracks(url, user_id):
|
def list_tracks(url, user_id):
|
||||||
if "music.youtube.com" in url or "youtu.be" in url:
|
if "music.youtube.com" in url or "youtu.be" in url:
|
||||||
url = url.replace("music.youtube.com", "youtube.com")
|
url = url.replace("music.youtube.com", "youtube.com")
|
||||||
|
@ -43,6 +42,7 @@ def list_tracks(url, user_id):
|
||||||
ytmusic = ytmusicapi.YTMusic()
|
ytmusic = ytmusicapi.YTMusic()
|
||||||
channel_id = url.split("/")[-1]
|
channel_id = url.split("/")[-1]
|
||||||
channel_songs = ytmusic.get_artist(channel_id)["songs"]["results"]
|
channel_songs = ytmusic.get_artist(channel_id)["songs"]["results"]
|
||||||
|
print(channel_songs)
|
||||||
|
|
||||||
for song in channel_songs:
|
for song in channel_songs:
|
||||||
process_yb.apply_async(
|
process_yb.apply_async(
|
||||||
|
@ -54,19 +54,9 @@ def list_tracks(url, user_id):
|
||||||
|
|
||||||
elif "playlist" in url or "&list=" in url:
|
elif "playlist" in url or "&list=" in url:
|
||||||
ytmusic = ytmusicapi.YTMusic()
|
ytmusic = ytmusicapi.YTMusic()
|
||||||
|
playlist_id = url.split("=")[-1]
|
||||||
|
playlist_songs = ytmusic.get_playlist(playlist_id)["tracks"]["results"]
|
||||||
|
|
||||||
# Parse the URL and the query string
|
|
||||||
parsed_url = urlparse(url)
|
|
||||||
parsed_qs = parse_qs(parsed_url.query)
|
|
||||||
|
|
||||||
# Get the playlist ID from the parsed query string
|
|
||||||
playlist_id = parsed_qs.get("list", [None])[0]
|
|
||||||
|
|
||||||
if playlist_id:
|
|
||||||
playlist_songs = ytmusic.get_playlist(playlist_id)["tracks"]
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise ValueError("No playlist ID found in the URL.")
|
|
||||||
for song in playlist_songs:
|
for song in playlist_songs:
|
||||||
process_yb.apply_async(
|
process_yb.apply_async(
|
||||||
kwargs={
|
kwargs={
|
||||||
|
|
|
@ -100,7 +100,6 @@
|
||||||
<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>
|
||||||
|
@ -140,7 +139,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 2022</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 2023</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,9 +8,13 @@
|
||||||
<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 }}
|
||||||
{{ form|crispy }}
|
{% for field in form %}
|
||||||
|
{{ 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">
|
||||||
<input class="btn btn-secondary" type="submit" id="submit" value="Save Changes" />
|
<button class="btn btn-secondary" type="submit" id="submit">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,21 +0,0 @@
|
||||||
{% 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,27 +0,0 @@
|
||||||
{% 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 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,26 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mt-4">
|
|
||||||
<h2>Token Details</h2>
|
|
||||||
{% if token.name %}
|
|
||||||
<p><strong>Name:</strong> {{ token.name }}</p>
|
|
||||||
{% endif %}
|
|
||||||
<p><strong>Token:</strong> {{ token.token|slice:":5" }}***{{ token.token|slice:"-5:" }}</p>
|
|
||||||
<p><strong>Last Used:</strong> {{ token.last_used|date:"Y-m-d H:i:s" }} ({{ token.last_used|timesince }} ago)</p>
|
|
||||||
<p><strong>Active Until:</strong> {{ token.active_until|date:"Y-m-d" }}</p>
|
|
||||||
<p><strong>Permissions:</strong></p>
|
|
||||||
<ul>
|
|
||||||
{% for app, actions in token.permissions.items %}
|
|
||||||
<li><strong>{{ app|title }}:</strong>
|
|
||||||
<ul>
|
|
||||||
{% for action, value in actions.items %}
|
|
||||||
<li>{{ action|title }}: {{ value|yesno:"✅,❌" }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{# <a href="{% url 'edit_token' token.id %}" class="btn btn-primary">Edit</a> TODO #}
|
|
||||||
<a href="{% url 'users:delete_token' token.id %}" class="btn btn-danger">Delete</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
|
@ -4,7 +4,6 @@
|
||||||
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()
|
||||||
|
|
||||||
|
@ -34,19 +33,3 @@ 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")
|
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
from rest_framework.authentication import BaseAuthentication
|
|
||||||
|
|
||||||
from akarpov.users.models import UserAPIToken
|
|
||||||
from akarpov.users.tasks import set_last_active_token
|
|
||||||
|
|
||||||
|
|
||||||
class UserTokenAuthentication(BaseAuthentication):
|
|
||||||
def authenticate(self, request):
|
|
||||||
if "Authorization" not in request.headers:
|
|
||||||
return None
|
|
||||||
token = request.headers["Authorization"]
|
|
||||||
if " " in token:
|
|
||||||
token = token.split(" ")[1]
|
|
||||||
try:
|
|
||||||
token = UserAPIToken.objects.cache().get(token=token)
|
|
||||||
except UserAPIToken.DoesNotExist:
|
|
||||||
return None
|
|
||||||
if not token.is_active:
|
|
||||||
return None
|
|
||||||
set_last_active_token.delay(token.token)
|
|
||||||
|
|
||||||
return token.user, token
|
|
|
@ -1,15 +1,10 @@
|
||||||
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()
|
||||||
|
|
||||||
|
|
||||||
|
@ -50,80 +45,3 @@ class UserSocialSignupForm(SocialSignupForm):
|
||||||
|
|
||||||
class OTPForm(forms.Form):
|
class OTPForm(forms.Form):
|
||||||
otp_token = forms.CharField()
|
otp_token = forms.CharField()
|
||||||
|
|
||||||
|
|
||||||
class TokenCreationForm(forms.ModelForm):
|
|
||||||
permissions = forms.MultipleChoiceField(
|
|
||||||
choices=[], # To be set in __init__
|
|
||||||
widget=forms.CheckboxSelectMultiple,
|
|
||||||
required=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = UserAPIToken
|
|
||||||
fields = ["name", "active_until", "permissions"]
|
|
||||||
widgets = {
|
|
||||||
"name": TextInput(attrs={"placeholder": "Token Name (Optional)"}),
|
|
||||||
"active_until": DateInput(attrs={"type": "date"}, format="%d.%m.%Y"),
|
|
||||||
}
|
|
||||||
# Make active_until not required
|
|
||||||
required = {
|
|
||||||
"active_until": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
permissions_context = kwargs.pop("permissions_context", None)
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
if permissions_context:
|
|
||||||
for app, actions in permissions_context.items():
|
|
||||||
field_name = f"permissions_{app}"
|
|
||||||
self.fields[field_name] = forms.MultipleChoiceField(
|
|
||||||
choices=[(action, action) for action in actions.keys()],
|
|
||||||
widget=forms.CheckboxSelectMultiple,
|
|
||||||
required=False,
|
|
||||||
label=app.capitalize(),
|
|
||||||
initial=[
|
|
||||||
item
|
|
||||||
for sublist in kwargs.get("initial", {}).get(field_name, [])
|
|
||||||
for item in sublist
|
|
||||||
],
|
|
||||||
)
|
|
||||||
self.fields["active_until"].required = False
|
|
||||||
|
|
||||||
def get_permissions_choices(self):
|
|
||||||
permissions_choices = [
|
|
||||||
(f"{app}.{action}", description)
|
|
||||||
for app, actions in UserAPIToken.permission_template.items()
|
|
||||||
for action, description in actions.items()
|
|
||||||
]
|
|
||||||
return permissions_choices
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
cleaned_data = super().clean()
|
|
||||||
structured_permissions = {
|
|
||||||
category: {perm: False for perm in permissions.keys()}
|
|
||||||
for category, permissions in UserAPIToken.permission_template.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
for category in structured_permissions.keys():
|
|
||||||
input_field_name = f"permissions_{category}"
|
|
||||||
if input_field_name in self.data:
|
|
||||||
selected_perms = self.data.getlist(input_field_name)
|
|
||||||
for perm in selected_perms:
|
|
||||||
if perm in structured_permissions[category]:
|
|
||||||
structured_permissions[category][perm] = True
|
|
||||||
|
|
||||||
cleaned_data["permissions"] = json.dumps(structured_permissions)
|
|
||||||
|
|
||||||
return cleaned_data
|
|
||||||
|
|
||||||
def save(self, commit=True):
|
|
||||||
instance = super().save(commit=False)
|
|
||||||
permissions_json = self.cleaned_data.get("permissions", "{}")
|
|
||||||
instance.permissions = json.loads(permissions_json)
|
|
||||||
|
|
||||||
if commit:
|
|
||||||
instance.save()
|
|
||||||
|
|
||||||
return instance
|
|
||||||
|
|
|
@ -6,8 +6,6 @@
|
||||||
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):
|
||||||
|
@ -23,20 +21,12 @@ 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/":
|
if (
|
||||||
|
request.path_info.startswith("/api/v1/music/")
|
||||||
|
or request.path_info == "/api/v1/auth/token/"
|
||||||
|
):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
if "Authorization" in request.headers:
|
|
||||||
try:
|
|
||||||
token = request.headers["Authorization"]
|
|
||||||
if " " in token:
|
|
||||||
token = token.split(" ")[1]
|
|
||||||
token = UserAPIToken.objects.cache().get(token=token)
|
|
||||||
request.token_permissions = token.permissions
|
|
||||||
return response
|
|
||||||
except (KeyError, AttributeError, UserAPIToken.DoesNotExist):
|
|
||||||
...
|
|
||||||
|
|
||||||
# Check user is authenticated and OTP token input is not completed
|
# Check user is authenticated and OTP token input is not completed
|
||||||
is_authenticated = request.user.is_authenticated
|
is_authenticated = request.user.is_authenticated
|
||||||
otp_not_verified = not request.session.get("otp_verified", False)
|
otp_not_verified = not request.session.get("otp_verified", False)
|
||||||
|
|
|
@ -1,40 +0,0 @@
|
||||||
# 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,17 +0,0 @@
|
||||||
# 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),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,17 +0,0 @@
|
||||||
# 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,12 +1,9 @@
|
||||||
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
|
||||||
|
@ -81,46 +78,6 @@ def __str__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class UserAPIToken(models.Model):
|
class UserNotification:
|
||||||
name = models.CharField(max_length=255, blank=True, null=True)
|
# TODO: add notification system
|
||||||
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
|
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
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,17 +1,13 @@
|
||||||
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"
|
||||||
|
@ -21,11 +17,7 @@
|
||||||
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, QueryDict
|
from django.http import HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import 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, TokenCreationForm
|
from akarpov.users.forms import OTPForm
|
||||||
from akarpov.users.models import UserAPIToken, UserHistory
|
from akarpov.users.models import UserHistory
|
||||||
from akarpov.users.services.history import create_history_warning_note
|
from akarpov.users.services.history import create_history_warning_note
|
||||||
from akarpov.users.services.two_factor import generate_qr_code
|
from akarpov.users.services.two_factor import generate_qr_code
|
||||||
from akarpov.users.themes.models import Theme
|
from akarpov.users.themes.models import Theme
|
||||||
|
@ -203,70 +203,3 @@ def enforce_otp_login(request):
|
||||||
else:
|
else:
|
||||||
form = OTPForm()
|
form = OTPForm()
|
||||||
return render(request, "users/otp_verify.html", {"form": form})
|
return render(request, "users/otp_verify.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def list_tokens(request):
|
|
||||||
tokens = UserAPIToken.objects.filter(user=request.user).order_by("last_used")
|
|
||||||
return render(request, "users/list_tokens.html", {"tokens": tokens})
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def create_token(request):
|
|
||||||
initial_data = {}
|
|
||||||
|
|
||||||
# Обработка параметров 'name' и 'active_until'
|
|
||||||
if "name" in request.GET:
|
|
||||||
initial_data["name"] = request.GET["name"]
|
|
||||||
if "active_until" in request.GET:
|
|
||||||
initial_data["active_until"] = request.GET["active_until"]
|
|
||||||
|
|
||||||
# Создаем QueryDict для разрешений, чтобы правильно обработать повторяющиеся ключи
|
|
||||||
permissions_query_dict = QueryDict("", mutable=True)
|
|
||||||
|
|
||||||
# Разбор параметров разрешений
|
|
||||||
permissions = request.GET.getlist("permissions")
|
|
||||||
for perm in permissions:
|
|
||||||
category, permission = perm.split(".")
|
|
||||||
permissions_query_dict.update({f"permissions_{category}": [permission]})
|
|
||||||
|
|
||||||
# Переводим QueryDict в обычный словарь для использования в initial
|
|
||||||
permissions_data = {key: value for key, value in permissions_query_dict.lists()}
|
|
||||||
|
|
||||||
initial_data.update(permissions_data)
|
|
||||||
|
|
||||||
for key, value_list in permissions_data.items():
|
|
||||||
initial_data[key] = [item for sublist in value_list for item in sublist]
|
|
||||||
|
|
||||||
form = TokenCreationForm(
|
|
||||||
initial=initial_data, permissions_context=UserAPIToken.permission_template
|
|
||||||
)
|
|
||||||
if request.method == "POST":
|
|
||||||
print(request.POST)
|
|
||||||
form = TokenCreationForm(request.POST)
|
|
||||||
if form.is_valid():
|
|
||||||
new_token = form.save(commit=False)
|
|
||||||
new_token.user = request.user
|
|
||||||
new_token.token = UserAPIToken.generate_token()
|
|
||||||
new_token.save()
|
|
||||||
token_created = new_token.token
|
|
||||||
return render(
|
|
||||||
request, "users/token_created.html", {"new_token": token_created}
|
|
||||||
)
|
|
||||||
|
|
||||||
return render(request, "users/create_token.html", {"form": form})
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def view_token(request, token_id):
|
|
||||||
token = get_object_or_404(UserAPIToken, id=token_id, user=request.user)
|
|
||||||
return render(request, "users/view_token.html", {"token": token})
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def delete_token(request, token_id):
|
|
||||||
token = get_object_or_404(UserAPIToken, id=token_id, user=request.user)
|
|
||||||
if request.method == "POST":
|
|
||||||
token.delete()
|
|
||||||
return redirect("users:list_tokens")
|
|
||||||
return render(request, "users/confirm_delete_token.html", {"token": token})
|
|
||||||
|
|
|
@ -5,4 +5,4 @@ set -o nounset
|
||||||
|
|
||||||
/install_preview_dependencies
|
/install_preview_dependencies
|
||||||
|
|
||||||
celery -A config.celery_app worker --autoscale 20 -l INFO
|
celery -A config.celery_app worker --loglevel=info -c 5
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
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,7 +1,6 @@
|
||||||
"""
|
"""
|
||||||
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
|
||||||
|
@ -79,7 +78,6 @@
|
||||||
"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")
|
||||||
|
|
||||||
|
@ -133,7 +131,8 @@
|
||||||
"rest_framework.authtoken",
|
"rest_framework.authtoken",
|
||||||
"corsheaders",
|
"corsheaders",
|
||||||
"drf_spectacular",
|
"drf_spectacular",
|
||||||
"django_ckeditor_5",
|
"ckeditor",
|
||||||
|
"ckeditor_uploader",
|
||||||
"colorfield",
|
"colorfield",
|
||||||
"polymorphic",
|
"polymorphic",
|
||||||
"cacheops",
|
"cacheops",
|
||||||
|
@ -511,7 +510,6 @@
|
||||||
"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",
|
||||||
|
@ -532,128 +530,24 @@
|
||||||
|
|
||||||
# CKEDITOR
|
# CKEDITOR
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
CKEDITOR_5_UPLOAD_PATH = "uploads/"
|
CKEDITOR_UPLOAD_PATH = "uploads/"
|
||||||
CKEDITOR_5_CONFIGS = {
|
CKEDITOR_CONFIGS = {
|
||||||
"default": {
|
"default": {
|
||||||
"toolbar": [
|
"width": "full",
|
||||||
"heading",
|
"extra_plugins": [
|
||||||
"|",
|
"autosave",
|
||||||
"bold",
|
"autogrow",
|
||||||
"italic",
|
"autolink",
|
||||||
"link",
|
"autoembed",
|
||||||
"bulletedList",
|
"clipboard",
|
||||||
"numberedList",
|
"dialog",
|
||||||
"blockQuote",
|
"dialogui",
|
||||||
"imageUpload",
|
|
||||||
],
|
],
|
||||||
},
|
"autosave": {
|
||||||
"extends": {
|
"autoLoad": True,
|
||||||
"blockToolbar": [
|
"delay": 60,
|
||||||
"paragraph",
|
"NotOlderThen": 20,
|
||||||
"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,9 +43,7 @@
|
||||||
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(
|
path("ckeditor/", include("ckeditor_uploader.urls")),
|
||||||
"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,9 +101,7 @@ services:
|
||||||
command: /start-flower
|
command: /start-flower
|
||||||
|
|
||||||
elasticsearch:
|
elasticsearch:
|
||||||
build:
|
image: elasticsearch:8.11.1
|
||||||
context: .
|
|
||||||
dockerfile: ./compose/production/elasticsearch/Dockerfile
|
|
||||||
ports:
|
ports:
|
||||||
- "9200:9200"
|
- "9200:9200"
|
||||||
- "9300:9300"
|
- "9300:9300"
|
||||||
|
|
2915
poetry.lock
generated
2915
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
|
@ -27,6 +27,7 @@ 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"
|
||||||
|
@ -120,7 +121,6 @@ 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