mirror of
https://github.com/Alexander-D-Karpov/akarpov
synced 2025-02-21 18:20:36 +03:00
Compare commits
15 Commits
efb3979f4b
...
890c8a19d6
Author | SHA1 | Date | |
---|---|---|---|
|
890c8a19d6 | ||
|
29f78393f4 | ||
e4bfd5ca07 | |||
59fc828097 | |||
403fb8ffa5 | |||
f6f15d3979 | |||
f59df63dd4 | |||
3405b76897 | |||
b02a77ec5e | |||
45cd860803 | |||
513de19a16 | |||
08198e0535 | |||
8583885960 | |||
|
0a0714f969 | ||
3ef20b5eb9 |
|
@ -6,3 +6,6 @@ USE_DOCKER=no
|
||||||
EMAIL_HOST=127.0.0.1
|
EMAIL_HOST=127.0.0.1
|
||||||
EMAIL_PORT=1025
|
EMAIL_PORT=1025
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
|
EMAIL_PASSWORD=
|
||||||
|
EMAIL_USER=
|
||||||
|
EMAIL_USE_SSL=false
|
||||||
|
|
|
@ -4,6 +4,9 @@ My collection of apps and tools
|
||||||
|
|
||||||
Writen in Python 3.11 and Django 4.2
|
Writen in Python 3.11 and Django 4.2
|
||||||
|
|
||||||
|
Local upstream mirror:
|
||||||
|
https://git.akarpov.ru/sanspie/akarpov
|
||||||
|
|
||||||
## Start up
|
## Start up
|
||||||
|
|
||||||
### installation
|
### installation
|
||||||
|
@ -50,3 +53,4 @@ $ mypy --config-file setup.cfg akarpov
|
||||||
- short link generator
|
- short link generator
|
||||||
- about me app
|
- about me app
|
||||||
- gallery
|
- gallery
|
||||||
|
- notifications
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||||
|
|
||||||
|
from akarpov.utils.models import get_object_user
|
||||||
|
|
||||||
|
|
||||||
class SmallResultsSetPagination(PageNumberPagination):
|
class SmallResultsSetPagination(PageNumberPagination):
|
||||||
|
@ -24,3 +27,19 @@ class RecursiveField(serializers.Serializer):
|
||||||
def to_representation(self, value):
|
def to_representation(self, value):
|
||||||
serializer = self.parent.parent.__class__(value, context=self.context)
|
serializer = self.parent.parent.__class__(value, context=self.context)
|
||||||
return serializer.data
|
return serializer.data
|
||||||
|
|
||||||
|
|
||||||
|
class IsCreatorOrReadOnly(BasePermission):
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
return bool(
|
||||||
|
request.method in SAFE_METHODS
|
||||||
|
or request.user
|
||||||
|
and get_object_user(view.get_object()) == request.user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SetUserModelSerializer(serializers.ModelSerializer):
|
||||||
|
def create(self, validated_data):
|
||||||
|
creator = self.context["request"].user
|
||||||
|
obj = self.Meta.model.objects.create(creator=creator, **validated_data)
|
||||||
|
return obj
|
||||||
|
|
|
@ -1,20 +1,46 @@
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
from channels.db import database_sync_to_async
|
from channels.db import database_sync_to_async
|
||||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer, JsonWebsocketConsumer
|
from channels.generic.websocket import AsyncJsonWebsocketConsumer, JsonWebsocketConsumer
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.sessions.models import Session
|
||||||
|
|
||||||
from akarpov.common.jwt import read_jwt
|
from akarpov.common.jwt import read_jwt
|
||||||
|
from akarpov.users.models import User
|
||||||
|
|
||||||
|
engine = import_module(settings.SESSION_ENGINE)
|
||||||
|
sessionstore = engine.SessionStore
|
||||||
|
|
||||||
|
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
def get_user(headers):
|
def get_user(headers):
|
||||||
# WARNING headers type is bytes
|
# WARNING headers type is bytes
|
||||||
if b"authorization" not in headers or not headers[b"authorization"]:
|
if (b"authorization" not in headers or not headers[b"authorization"]) and (
|
||||||
return False
|
b"cookie" not in headers or not headers[b"cookie"]
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
if b"authorization" in headers:
|
||||||
|
jwt = headers[b"authorization"].decode()
|
||||||
|
data = read_jwt(jwt)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
payload = data
|
||||||
|
elif b"cookie" in headers:
|
||||||
|
cookies = dict([x.split("=") for x in headers[b"cookie"].decode().split("; ")])
|
||||||
|
if "sessionid" not in cookies:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
session = sessionstore(cookies["sessionid"])
|
||||||
|
user_id = session["_auth_user_id"]
|
||||||
|
except (Session.DoesNotExist, User.DoesNotExist, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
jwt = headers[b"authorization"].decode()
|
payload = {"id": user_id}
|
||||||
payload = read_jwt(jwt)
|
else:
|
||||||
|
payload = {}
|
||||||
|
|
||||||
if not payload or "id" not in payload:
|
if not payload or "id" not in payload:
|
||||||
return False
|
return None
|
||||||
|
|
||||||
return payload["id"]
|
return payload["id"]
|
||||||
|
|
||||||
|
@ -27,7 +53,7 @@ def __init__(self, app):
|
||||||
self.app = app
|
self.app = app
|
||||||
|
|
||||||
async def __call__(self, scope, receive, send):
|
async def __call__(self, scope, receive, send):
|
||||||
scope["user"] = await get_user(dict(scope["headers"]))
|
scope["user_id"] = await get_user(dict(scope["headers"]))
|
||||||
try:
|
try:
|
||||||
return await self.app(scope, receive, send)
|
return await self.app(scope, receive, send)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
import jwt
|
import jwt
|
||||||
import pytz
|
import pytz
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from jwt import ExpiredSignatureError, InvalidSignatureError
|
from jwt import DecodeError, ExpiredSignatureError, InvalidSignatureError
|
||||||
|
|
||||||
TIMEZONE = pytz.timezone("Europe/Moscow")
|
TIMEZONE = pytz.timezone("Europe/Moscow")
|
||||||
|
|
||||||
|
@ -24,7 +24,10 @@ def sign_jwt(data: dict, t_life: None | int = None) -> str:
|
||||||
|
|
||||||
def read_jwt(token: str) -> dict | bool:
|
def read_jwt(token: str) -> dict | bool:
|
||||||
"""reads jwt, validates it and return payload if correct"""
|
"""reads jwt, validates it and return payload if correct"""
|
||||||
header_data = jwt.get_unverified_header(token)
|
try:
|
||||||
|
header_data = jwt.get_unverified_header(token)
|
||||||
|
except DecodeError:
|
||||||
|
return False
|
||||||
secret = settings.SECRET_KEY
|
secret = settings.SECRET_KEY
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, key=secret, algorithms=[header_data["alg"]])
|
payload = jwt.decode(token, key=secret, algorithms=[header_data["alg"]])
|
||||||
|
|
0
akarpov/common/ml/__init__.py
Normal file
0
akarpov/common/ml/__init__.py
Normal file
51
akarpov/common/ml/text.py
Normal file
51
akarpov/common/ml/text.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import pycld2 as cld2
|
||||||
|
import spacy
|
||||||
|
import torch
|
||||||
|
from transformers import AutoModel, AutoTokenizer
|
||||||
|
|
||||||
|
# load ml classes and models on first request
|
||||||
|
# TODO: move to outer server/service
|
||||||
|
nlp = None
|
||||||
|
ru_nlp = None
|
||||||
|
|
||||||
|
ru_model = None
|
||||||
|
ru_tokenizer = None
|
||||||
|
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||||
|
|
||||||
|
|
||||||
|
def get_text_embedding(text: str):
|
||||||
|
global nlp, ru_nlp, ru_model, ru_tokenizer
|
||||||
|
|
||||||
|
is_reliable, text_bytes_found, details = cld2.detect(text)
|
||||||
|
if is_reliable:
|
||||||
|
lang = details[0]
|
||||||
|
if lang[1] in ["ru", "en"]:
|
||||||
|
lang = lang[1]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if lang == "ru":
|
||||||
|
if not ru_nlp:
|
||||||
|
ru_nlp = spacy.load("ru_core_news_md", disable=["parser", "ner"])
|
||||||
|
lema = " ".join([token.lemma_ for token in ru_nlp(text)])
|
||||||
|
if not ru_model:
|
||||||
|
ru_model = AutoModel.from_pretrained("DeepPavlov/rubert-base-cased")
|
||||||
|
if not ru_tokenizer:
|
||||||
|
ru_tokenizer = AutoTokenizer.from_pretrained("DeepPavlov/rubert-base-cased")
|
||||||
|
encodings = ru_tokenizer(
|
||||||
|
lema, # the texts to be tokenized
|
||||||
|
padding=True, # pad the texts to the maximum length (so that all outputs have the same length)
|
||||||
|
return_tensors="pt", # return the tensors (not lists)
|
||||||
|
)
|
||||||
|
with torch.no_grad():
|
||||||
|
# get the model embeddings
|
||||||
|
embeds = ru_model(**encodings)
|
||||||
|
embeds = embeds[0]
|
||||||
|
elif lang == "en":
|
||||||
|
embeds = None
|
||||||
|
else:
|
||||||
|
embeds = None
|
||||||
|
|
||||||
|
return embeds
|
|
@ -6,7 +6,7 @@
|
||||||
class FileForm(forms.ModelForm):
|
class FileForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = File
|
model = File
|
||||||
fields = ["name", "private", "description"]
|
fields = ["name", "private", "notify_user_on_view", "description"]
|
||||||
|
|
||||||
|
|
||||||
class FolderForm(forms.ModelForm):
|
class FolderForm(forms.ModelForm):
|
||||||
|
|
10
akarpov/files/migrations/0025_create_vector_ps.py
Normal file
10
akarpov/files/migrations/0025_create_vector_ps.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
from django.db import migrations
|
||||||
|
from pgvector.django import VectorExtension
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("files", "0024_alter_file_options_alter_filereport_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [VectorExtension()]
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Generated by Django 4.2.5 on 2023-09-24 16:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("files", "0024_alter_file_options_alter_filereport_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="file",
|
||||||
|
name="notify_user_on_view",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False, verbose_name="Receive notifications on file view"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="basefileitem",
|
||||||
|
name="parent",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="children",
|
||||||
|
to="files.folder",
|
||||||
|
verbose_name="Folder",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
18
akarpov/files/migrations/0026_file_embeddings.py
Normal file
18
akarpov/files/migrations/0026_file_embeddings.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 4.2.5 on 2023-09-16 18:33
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
import pgvector.django
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("files", "0025_create_vector_ps"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="file",
|
||||||
|
name="embeddings",
|
||||||
|
field=pgvector.django.VectorField(dimensions=768, null=True),
|
||||||
|
),
|
||||||
|
]
|
12
akarpov/files/migrations/0027_merge_20230925_2023.py
Normal file
12
akarpov/files/migrations/0027_merge_20230925_2023.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# Generated by Django 4.2.5 on 2023-09-25 17:23
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("files", "0025_file_notify_user_on_view_alter_basefileitem_parent"),
|
||||||
|
("files", "0026_file_embeddings"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
26
akarpov/files/migrations/0028_file_content_file_lang.py
Normal file
26
akarpov/files/migrations/0028_file_content_file_lang.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
# Generated by Django 4.2.5 on 2023-09-26 09:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("files", "0027_merge_20230925_2023"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="file",
|
||||||
|
name="content",
|
||||||
|
field=models.TextField(default="", max_length=10000),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="file",
|
||||||
|
name="lang",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("ru", "ru"), ("en", "en")], default="en", max_length=2
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
|
@ -17,6 +17,7 @@
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from model_utils.fields import AutoCreatedField, AutoLastModifiedField
|
from model_utils.fields import AutoCreatedField, AutoLastModifiedField
|
||||||
from model_utils.models import TimeStampedModel
|
from model_utils.models import TimeStampedModel
|
||||||
|
from pgvector.django import VectorField
|
||||||
from polymorphic.models import PolymorphicModel
|
from polymorphic.models import PolymorphicModel
|
||||||
|
|
||||||
from akarpov.files.services.files import trash_file_upload, user_unique_file_upload
|
from akarpov.files.services.files import trash_file_upload, user_unique_file_upload
|
||||||
|
@ -26,6 +27,7 @@
|
||||||
|
|
||||||
class BaseFileItem(PolymorphicModel):
|
class BaseFileItem(PolymorphicModel):
|
||||||
parent = ForeignKey(
|
parent = ForeignKey(
|
||||||
|
verbose_name="Folder",
|
||||||
to="files.Folder",
|
to="files.Folder",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
@ -68,12 +70,20 @@ class File(BaseFileItem, TimeStampedModel, ShortLinkModel, UserHistoryModel):
|
||||||
|
|
||||||
preview = FileField(blank=True, upload_to="file/previews/")
|
preview = FileField(blank=True, upload_to="file/previews/")
|
||||||
file_obj = FileField(blank=False, upload_to=user_unique_file_upload)
|
file_obj = FileField(blank=False, upload_to=user_unique_file_upload)
|
||||||
|
embeddings = VectorField(dimensions=768, null=True)
|
||||||
|
content = TextField(max_length=10000)
|
||||||
|
lang = CharField(max_length=2, choices=[("ru", "ru"), ("en", "en")])
|
||||||
|
|
||||||
# meta
|
# meta
|
||||||
name = CharField(max_length=255, null=True, blank=True)
|
name = CharField(max_length=255, null=True, blank=True)
|
||||||
description = TextField(blank=True, null=True)
|
description = TextField(blank=True, null=True)
|
||||||
file_type = CharField(max_length=255, null=True, blank=True)
|
file_type = CharField(max_length=255, null=True, blank=True)
|
||||||
|
|
||||||
|
# extra settings
|
||||||
|
notify_user_on_view = BooleanField(
|
||||||
|
"Receive notifications on file view", default=False
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def file_name(self):
|
def file_name(self):
|
||||||
return self.file.path.split("/")[-1]
|
return self.file.path.split("/")[-1]
|
||||||
|
|
|
@ -1 +1,3 @@
|
||||||
from . import doc, docx, json, odt, pdf, zip # noqa
|
from . import doc, docx, json, odt, pdf, zip # noqa
|
||||||
|
|
||||||
|
# TODO: add gzip, xlsx
|
||||||
|
|
7
akarpov/files/services/text.py
Normal file
7
akarpov/files/services/text.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import textract
|
||||||
|
|
||||||
|
|
||||||
|
def extract_file_text(file: str) -> str:
|
||||||
|
text = textract.process(file)
|
||||||
|
|
||||||
|
return text
|
|
@ -34,6 +34,7 @@
|
||||||
from akarpov.files.services.folders import delete_folder
|
from akarpov.files.services.folders import delete_folder
|
||||||
from akarpov.files.services.preview import get_base_meta
|
from akarpov.files.services.preview import get_base_meta
|
||||||
from akarpov.files.tables import FileTable
|
from akarpov.files.tables import FileTable
|
||||||
|
from akarpov.notifications.services import send_notification
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
@ -148,6 +149,16 @@ def dispatch(self, request, *args, **kwargs):
|
||||||
if "bot" in useragent:
|
if "bot" in useragent:
|
||||||
if file.file_type and file.file_type.split("/")[0] == "image":
|
if file.file_type and file.file_type.split("/")[0] == "image":
|
||||||
return HttpResponseRedirect(file.file.url)
|
return HttpResponseRedirect(file.file.url)
|
||||||
|
else:
|
||||||
|
if file.notify_user_on_view:
|
||||||
|
if self.request.user != file.user:
|
||||||
|
send_notification(
|
||||||
|
"File view",
|
||||||
|
f"File {file.name} was opened",
|
||||||
|
"site",
|
||||||
|
user_id=file.user.id,
|
||||||
|
conformation=True,
|
||||||
|
)
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from akarpov.music.models import Album, Author, Song
|
from akarpov.common.api import SetUserModelSerializer
|
||||||
|
from akarpov.music.models import Album, Author, Playlist, Song
|
||||||
|
from akarpov.users.api.serializers import UserPublicInfoSerializer
|
||||||
|
|
||||||
|
|
||||||
class AuthorSerializer(serializers.ModelSerializer):
|
class AuthorSerializer(serializers.ModelSerializer):
|
||||||
url = serializers.SerializerMethodField(method_name="get_url")
|
url = serializers.SerializerMethodField(method_name="get_url")
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.URLField)
|
||||||
def get_url(self, obj):
|
def get_url(self, obj):
|
||||||
return obj.get_absolute_url()
|
return obj.get_absolute_url()
|
||||||
|
|
||||||
|
@ -17,6 +21,7 @@ class Meta:
|
||||||
class AlbumSerializer(serializers.ModelSerializer):
|
class AlbumSerializer(serializers.ModelSerializer):
|
||||||
url = serializers.SerializerMethodField(method_name="get_url")
|
url = serializers.SerializerMethodField(method_name="get_url")
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.URLField)
|
||||||
def get_url(self, obj):
|
def get_url(self, obj):
|
||||||
return obj.get_absolute_url()
|
return obj.get_absolute_url()
|
||||||
|
|
||||||
|
@ -32,7 +37,6 @@ class SongSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Song
|
model = Song
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
|
||||||
"image",
|
"image",
|
||||||
"link",
|
"link",
|
||||||
"length",
|
"length",
|
||||||
|
@ -42,3 +46,48 @@ class Meta:
|
||||||
"authors",
|
"authors",
|
||||||
"album",
|
"album",
|
||||||
]
|
]
|
||||||
|
extra_kwargs = {
|
||||||
|
"slug": {"read_only": True},
|
||||||
|
"creator": {"read_only": True},
|
||||||
|
"length": {"read_only": True},
|
||||||
|
"played": {"read_only": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ListSongSerializer(SetUserModelSerializer):
|
||||||
|
album = serializers.CharField(source="album.name", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Song
|
||||||
|
fields = ["name", "slug", "file", "image_cropped", "length", "album"]
|
||||||
|
extra_kwargs = {
|
||||||
|
"slug": {"read_only": True},
|
||||||
|
"image_cropped": {"read_only": True},
|
||||||
|
"length": {"read_only": True},
|
||||||
|
"album": {"read_only": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistSerializer(SetUserModelSerializer):
|
||||||
|
creator = UserPublicInfoSerializer()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Playlist
|
||||||
|
fields = ["name", "slug", "private", "creator"]
|
||||||
|
extra_kwargs = {
|
||||||
|
"slug": {"read_only": True},
|
||||||
|
"creator": {"read_only": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FullPlaylistSerializer(serializers.ModelSerializer):
|
||||||
|
songs = ListSongSerializer(many=True, read_only=True)
|
||||||
|
creator = UserPublicInfoSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Playlist
|
||||||
|
fields = ["name", "private", "creator", "songs"]
|
||||||
|
extra_kwargs = {
|
||||||
|
"slug": {"read_only": True},
|
||||||
|
"creator": {"read_only": True},
|
||||||
|
}
|
||||||
|
|
27
akarpov/music/api/urls.py
Normal file
27
akarpov/music/api/urls.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from akarpov.music.api.views import (
|
||||||
|
ListCreatePlaylistAPIView,
|
||||||
|
ListCreateSongAPIView,
|
||||||
|
RetrieveUpdateDestroyPlaylistAPIView,
|
||||||
|
RetrieveUpdateDestroySongAPIView,
|
||||||
|
)
|
||||||
|
|
||||||
|
app_name = "music"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"playlists/", ListCreatePlaylistAPIView.as_view(), name="list_create_playlist"
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"playlists/<str:slug>",
|
||||||
|
RetrieveUpdateDestroyPlaylistAPIView.as_view(),
|
||||||
|
name="retrieve_update_delete_playlist",
|
||||||
|
),
|
||||||
|
path("song/", ListCreateSongAPIView.as_view(), name="list_create_song"),
|
||||||
|
path(
|
||||||
|
"song/<str:slug>",
|
||||||
|
RetrieveUpdateDestroySongAPIView.as_view(),
|
||||||
|
name="retrieve_update_delete_song",
|
||||||
|
),
|
||||||
|
]
|
58
akarpov/music/api/views.py
Normal file
58
akarpov/music/api/views.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
from rest_framework import generics, permissions
|
||||||
|
|
||||||
|
from akarpov.common.api import IsCreatorOrReadOnly
|
||||||
|
from akarpov.music.api.serializers import (
|
||||||
|
FullPlaylistSerializer,
|
||||||
|
ListSongSerializer,
|
||||||
|
PlaylistSerializer,
|
||||||
|
SongSerializer,
|
||||||
|
)
|
||||||
|
from akarpov.music.models import Playlist, Song
|
||||||
|
|
||||||
|
|
||||||
|
class ListCreatePlaylistAPIView(generics.ListCreateAPIView):
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
serializer_class = PlaylistSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Playlist.objects.filter(creator=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class RetrieveUpdateDestroyPlaylistAPIView(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
lookup_field = "slug"
|
||||||
|
lookup_url_kwarg = "slug"
|
||||||
|
permission_classes = [IsCreatorOrReadOnly]
|
||||||
|
serializer_class = FullPlaylistSerializer
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.object = None
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
if not self.object:
|
||||||
|
self.object = super().get_object()
|
||||||
|
return self.object
|
||||||
|
|
||||||
|
|
||||||
|
class ListCreateSongAPIView(generics.ListCreateAPIView):
|
||||||
|
serializer_class = ListSongSerializer
|
||||||
|
permission_classes = [IsCreatorOrReadOnly]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Song.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class RetrieveUpdateDestroySongAPIView(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
lookup_field = "slug"
|
||||||
|
lookup_url_kwarg = "slug"
|
||||||
|
permission_classes = [IsCreatorOrReadOnly]
|
||||||
|
serializer_class = SongSerializer
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.object = None
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
if not self.object:
|
||||||
|
self.object = super().get_object()
|
||||||
|
return self.object
|
25
akarpov/music/migrations/0007_song_creator.py
Normal file
25
akarpov/music/migrations/0007_song_creator.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# Generated by Django 4.2.5 on 2023-09-27 08:06
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("music", "0006_tempfileupload"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="song",
|
||||||
|
name="creator",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="songs",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
17
akarpov/music/migrations/0008_song_meta.py
Normal file
17
akarpov/music/migrations/0008_song_meta.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 4.2.5 on 2023-09-29 15:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("music", "0007_song_creator"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="song",
|
||||||
|
name="meta",
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 4.2.5 on 2023-09-30 11:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("music", "0008_song_meta"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="songinque",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(blank=True, max_length=500),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="songinque",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(blank=True, max_length=500, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -4,6 +4,7 @@
|
||||||
from akarpov.common.models import BaseImageModel
|
from akarpov.common.models import BaseImageModel
|
||||||
from akarpov.tools.shortener.models import ShortLinkModel
|
from akarpov.tools.shortener.models import ShortLinkModel
|
||||||
from akarpov.users.services.history import UserHistoryModel
|
from akarpov.users.services.history import UserHistoryModel
|
||||||
|
from akarpov.utils.cache import cache_model_property
|
||||||
|
|
||||||
|
|
||||||
class Author(BaseImageModel, ShortLinkModel):
|
class Author(BaseImageModel, ShortLinkModel):
|
||||||
|
@ -38,10 +39,44 @@ class Song(BaseImageModel, ShortLinkModel):
|
||||||
album = models.ForeignKey(
|
album = models.ForeignKey(
|
||||||
Album, null=True, related_name="songs", on_delete=models.SET_NULL
|
Album, null=True, related_name="songs", on_delete=models.SET_NULL
|
||||||
)
|
)
|
||||||
|
creator = models.ForeignKey(
|
||||||
|
"users.User", related_name="songs", on_delete=models.SET_NULL, null=True
|
||||||
|
)
|
||||||
|
meta = models.JSONField(blank=True, null=True)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("music:song", kwargs={"slug": self.slug})
|
return reverse("music:song", kwargs={"slug": self.slug})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_props(self):
|
||||||
|
if self.album_name and self.artists_names:
|
||||||
|
return f"{self.album_name} - {self.artists_names}"
|
||||||
|
elif self.album_name:
|
||||||
|
return self.album_name
|
||||||
|
elif self.artists_names:
|
||||||
|
return self.artists_names
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _album_name(self):
|
||||||
|
if self.album and self.album.name:
|
||||||
|
return self.album.name
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _authors_names(self):
|
||||||
|
if self.authors:
|
||||||
|
return ", ".join(self.authors.values_list("name", flat=True))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def album_name(self):
|
||||||
|
return cache_model_property(self, "_album_name")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def artists_names(self):
|
||||||
|
return cache_model_property(self, "_authors_names")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
@ -80,8 +115,8 @@ class Meta:
|
||||||
|
|
||||||
|
|
||||||
class SongInQue(models.Model):
|
class SongInQue(models.Model):
|
||||||
name = models.CharField(blank=True, max_length=250)
|
name = models.CharField(blank=True, max_length=500)
|
||||||
status = models.CharField(null=True, blank=True, max_length=250)
|
status = models.CharField(null=True, blank=True, max_length=500)
|
||||||
error = models.BooleanField(default=False)
|
error = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
from akarpov.music.tasks import list_tracks, process_dir, process_file
|
from akarpov.music.tasks import list_tracks, process_dir, process_file
|
||||||
|
|
||||||
|
|
||||||
def load_tracks(address: str):
|
def load_tracks(address: str, user_id: int):
|
||||||
if address.startswith("/"):
|
if address.startswith("/"):
|
||||||
process_dir.apply_async(kwargs={"path": address})
|
process_dir.apply_async(kwargs={"path": address, "user_id": user_id})
|
||||||
list_tracks.apply_async(kwargs={"url": address})
|
list_tracks.apply_async(kwargs={"url": address, "user_id": user_id})
|
||||||
|
|
||||||
|
|
||||||
def load_track_file(file):
|
def load_track_file(file, user_id: int):
|
||||||
process_file.apply_async(kwargs={"path": file})
|
process_file.apply_async(kwargs={"path": file, "user_id": user_id})
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
def load_track(
|
def load_track(
|
||||||
path: str,
|
path: str,
|
||||||
image_path: str | None = None,
|
image_path: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
authors: list[str] | str | None = None,
|
authors: list[str] | str | None = None,
|
||||||
album: str | None = None,
|
album: str | None = None,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
@ -20,12 +21,19 @@ def load_track(
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Song:
|
) -> Song:
|
||||||
p_name = path.split("/")[-1]
|
p_name = path.split("/")[-1]
|
||||||
|
|
||||||
|
if album and type(album) is str and album.startswith("['"):
|
||||||
|
album = album.replace("['", "").replace("']", "")
|
||||||
|
|
||||||
if authors:
|
if authors:
|
||||||
authors = [Author.objects.get_or_create(name=x)[0] for x in authors if authors]
|
authors = [Author.objects.get_or_create(name=x)[0] for x in authors if authors]
|
||||||
else:
|
else:
|
||||||
authors = []
|
authors = []
|
||||||
if album:
|
if album:
|
||||||
album = Album.objects.get_or_create(name=album)[0]
|
if type(album) is str:
|
||||||
|
album = Album.objects.get_or_create(name=album)[0]
|
||||||
|
elif type(album) is list:
|
||||||
|
album = Album.objects.get_or_create(name=album[0])[0]
|
||||||
else:
|
else:
|
||||||
album = None
|
album = None
|
||||||
|
|
||||||
|
@ -45,9 +53,11 @@ def load_track(
|
||||||
tag = MP3(path, ID3=ID3)
|
tag = MP3(path, ID3=ID3)
|
||||||
if image_path:
|
if image_path:
|
||||||
if not image_path.endswith(".png"):
|
if not image_path.endswith(".png"):
|
||||||
|
nm = image_path
|
||||||
im = Image.open(image_path)
|
im = Image.open(image_path)
|
||||||
image_path = image_path.replace(image_path.split(".")[-1], "png")
|
image_path = image_path.replace(image_path.split(".")[-1], "png")
|
||||||
im.save(image_path)
|
im.save(image_path)
|
||||||
|
os.remove(nm)
|
||||||
|
|
||||||
song = Song(
|
song = Song(
|
||||||
link=link if link else "",
|
link=link if link else "",
|
||||||
|
@ -56,6 +66,12 @@ def load_track(
|
||||||
album=album,
|
album=album,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
song.user_id = user_id
|
||||||
|
|
||||||
|
if kwargs:
|
||||||
|
song.meta = kwargs
|
||||||
|
|
||||||
if image_path:
|
if image_path:
|
||||||
with open(path, "rb") as file, open(image_path, "rb") as image:
|
with open(path, "rb") as file, open(image_path, "rb") as image:
|
||||||
song.image = File(image, name=image_path.split("/")[-1])
|
song.image = File(image, name=image_path.split("/")[-1])
|
||||||
|
@ -96,4 +112,10 @@ def load_track(
|
||||||
tag.tags.add(TCON(text=kwargs["genre"]))
|
tag.tags.add(TCON(text=kwargs["genre"]))
|
||||||
tag.save()
|
tag.save()
|
||||||
|
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
if os.path.exists(image_path):
|
||||||
|
os.remove(image_path)
|
||||||
|
|
||||||
return song
|
return song
|
||||||
|
|
|
@ -11,18 +11,18 @@
|
||||||
from akarpov.music.services.db import load_track
|
from akarpov.music.services.db import load_track
|
||||||
|
|
||||||
|
|
||||||
def load_dir(path: str):
|
def load_dir(path: str, user_id: int):
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
|
|
||||||
for f in list(path.glob("**/*.mp3")):
|
for f in list(path.glob("**/*.mp3")):
|
||||||
process_mp3_file(str(f))
|
process_mp3_file(str(f), user_id=user_id)
|
||||||
|
|
||||||
|
|
||||||
def load_file(path: str):
|
def load_file(path: str, user_id: int):
|
||||||
process_mp3_file(path)
|
process_mp3_file(path, user_id)
|
||||||
|
|
||||||
|
|
||||||
def process_mp3_file(path: str) -> None:
|
def process_mp3_file(path: str, user_id: int) -> None:
|
||||||
tag = mutagen.File(path, easy=True)
|
tag = mutagen.File(path, easy=True)
|
||||||
if "artist" in tag:
|
if "artist" in tag:
|
||||||
author = tag["artist"]
|
author = tag["artist"]
|
||||||
|
@ -55,6 +55,6 @@ def process_mp3_file(path: str) -> None:
|
||||||
im.save(image_pth)
|
im.save(image_pth)
|
||||||
except UnidentifiedImageError:
|
except UnidentifiedImageError:
|
||||||
pass
|
pass
|
||||||
load_track(path, image_pth, author, album, name)
|
load_track(path, image_pth, user_id, author, album, name)
|
||||||
if image_pth and os.path.exists(image_pth):
|
if image_pth and os.path.exists(image_pth):
|
||||||
os.remove(image_pth)
|
os.remove(image_pth)
|
||||||
|
|
|
@ -1,18 +1,13 @@
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
from random import randint
|
from random import randint
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files import File
|
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from mutagen.easyid3 import EasyID3
|
|
||||||
from mutagen.id3 import APIC, ID3, TCON, TORY
|
|
||||||
from mutagen.mp3 import MP3
|
|
||||||
from pydub import AudioSegment
|
|
||||||
from yandex_music import Client, Playlist, Search, Track
|
from yandex_music import Client, Playlist, Search, Track
|
||||||
|
|
||||||
from akarpov.music import tasks
|
from akarpov.music import tasks
|
||||||
from akarpov.music.models import Album, Author, Song, SongInQue
|
from akarpov.music.models import Song, SongInQue
|
||||||
|
from akarpov.music.services.db import load_track
|
||||||
|
|
||||||
|
|
||||||
def login() -> Client:
|
def login() -> Client:
|
||||||
|
@ -48,88 +43,56 @@ def search_ym(name: str):
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
def load_file_meta(track: int):
|
def load_file_meta(track: int, user_id: int):
|
||||||
que = SongInQue.objects.create()
|
que = SongInQue.objects.create()
|
||||||
|
client = login()
|
||||||
|
track = client.tracks(track)[0] # type: Track
|
||||||
|
que.name = track.title
|
||||||
|
que.save()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = login()
|
if sng := Song.objects.filter(
|
||||||
track = client.tracks(track)[0] # type: Track
|
name=track.title, album__name=track.albums[0].title
|
||||||
que.name = track.title
|
):
|
||||||
que.save()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if sng := Song.objects.filter(
|
|
||||||
name=track.title, album__name=track.albums[0].title
|
|
||||||
):
|
|
||||||
que.delete()
|
|
||||||
return sng.first()
|
|
||||||
except IndexError:
|
|
||||||
que.delete()
|
que.delete()
|
||||||
return
|
return sng.first()
|
||||||
|
except IndexError:
|
||||||
filename = slugify(f"{track.artists[0].name} - {track.title}")
|
|
||||||
orig_path = f"{settings.MEDIA_ROOT}/{filename}"
|
|
||||||
|
|
||||||
track.download(filename=orig_path, codec="mp3")
|
|
||||||
|
|
||||||
path = orig_path + ".mp3"
|
|
||||||
AudioSegment.from_file(orig_path).export(path)
|
|
||||||
os.remove(orig_path)
|
|
||||||
|
|
||||||
# load album image
|
|
||||||
img_pth = str(settings.MEDIA_ROOT + f"/_{str(randint(10000, 99999))}.png")
|
|
||||||
|
|
||||||
track.download_cover(filename=img_pth)
|
|
||||||
|
|
||||||
album = track.albums[0]
|
|
||||||
|
|
||||||
# set music meta
|
|
||||||
tag = MP3(path, ID3=ID3)
|
|
||||||
tag.tags.add(
|
|
||||||
APIC(
|
|
||||||
encoding=3, # 3 is for utf-8
|
|
||||||
mime="image/png", # image/jpeg or image/png
|
|
||||||
type=3, # 3 is for the cover image
|
|
||||||
desc="Cover",
|
|
||||||
data=open(img_pth, "rb").read(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
tag.tags.add(TORY(text=str(album.year)))
|
|
||||||
tag.tags.add(TCON(text=album.genre))
|
|
||||||
tag.save()
|
|
||||||
|
|
||||||
os.remove(img_pth)
|
|
||||||
tag = EasyID3(path)
|
|
||||||
|
|
||||||
tag["title"] = track.title
|
|
||||||
tag["album"] = album.title
|
|
||||||
tag["artist"] = track.artists[0].name
|
|
||||||
|
|
||||||
tag.save()
|
|
||||||
|
|
||||||
# save track
|
|
||||||
ms_path = Path(path)
|
|
||||||
song = Song(
|
|
||||||
name=track.title,
|
|
||||||
author=Author.objects.get_or_create(name=track.artists[0].name)[0],
|
|
||||||
album=Album.objects.get_or_create(name=album.title)[0],
|
|
||||||
)
|
|
||||||
with ms_path.open(mode="rb") as f:
|
|
||||||
song.file = File(f, name=ms_path.name)
|
|
||||||
song.save()
|
|
||||||
os.remove(path)
|
|
||||||
que.delete()
|
que.delete()
|
||||||
return song
|
return
|
||||||
except Exception as e:
|
|
||||||
que.name = e
|
filename = slugify(f"{track.artists[0].name} - {track.title}")
|
||||||
que.error = True
|
orig_path = f"{settings.MEDIA_ROOT}/{filename}.mp3"
|
||||||
que.save()
|
album = track.albums[0]
|
||||||
|
|
||||||
|
track.download(filename=orig_path, codec="mp3")
|
||||||
|
img_pth = str(settings.MEDIA_ROOT + f"/_{str(randint(10000, 99999))}.png")
|
||||||
|
|
||||||
|
track.download_cover(filename=img_pth)
|
||||||
|
song = load_track(
|
||||||
|
orig_path,
|
||||||
|
img_pth,
|
||||||
|
user_id,
|
||||||
|
[x.name for x in track.artists],
|
||||||
|
album.title,
|
||||||
|
track.title,
|
||||||
|
release=album.release_date,
|
||||||
|
genre=album.genre,
|
||||||
|
)
|
||||||
|
if os.path.exists(orig_path):
|
||||||
|
os.remove(orig_path)
|
||||||
|
if os.path.exists(img_pth):
|
||||||
|
os.remove(img_pth)
|
||||||
|
|
||||||
|
return str(song)
|
||||||
|
|
||||||
|
|
||||||
def load_playlist(link: str):
|
def load_playlist(link: str, user_id: int):
|
||||||
author = link.split("/")[4]
|
author = link.split("/")[4]
|
||||||
playlist_id = link.split("/")[-1]
|
playlist_id = link.split("/")[-1]
|
||||||
|
|
||||||
client = login()
|
client = login()
|
||||||
playlist = client.users_playlists(int(playlist_id), author) # type: Playlist
|
playlist = client.users_playlists(int(playlist_id), author) # type: Playlist
|
||||||
for track in playlist.fetch_tracks():
|
for track in playlist.fetch_tracks():
|
||||||
tasks.load_ym_file_meta.apply_async(kwargs={"track": track.track.id})
|
tasks.load_ym_file_meta.apply_async(
|
||||||
|
kwargs={"track": track.track.id, "user_id": user_id}
|
||||||
|
)
|
||||||
|
|
|
@ -64,7 +64,7 @@ def parse_description(description: str) -> list:
|
||||||
return list_of_chapters
|
return list_of_chapters
|
||||||
|
|
||||||
|
|
||||||
def download_from_youtube_link(link: str) -> Song:
|
def download_from_youtube_link(link: str, user_id: int) -> Song:
|
||||||
song = None
|
song = None
|
||||||
|
|
||||||
with YoutubeDL(ydl_opts) as ydl:
|
with YoutubeDL(ydl_opts) as ydl:
|
||||||
|
@ -118,6 +118,7 @@ def download_from_youtube_link(link: str) -> Song:
|
||||||
song = load_track(
|
song = load_track(
|
||||||
chapter_path,
|
chapter_path,
|
||||||
f"{img_pth}.png",
|
f"{img_pth}.png",
|
||||||
|
user_id,
|
||||||
info["artists"],
|
info["artists"],
|
||||||
info["album_name"],
|
info["album_name"],
|
||||||
chapters[i][2],
|
chapters[i][2],
|
||||||
|
@ -127,6 +128,7 @@ def download_from_youtube_link(link: str) -> Song:
|
||||||
song = load_track(
|
song = load_track(
|
||||||
chapter_path,
|
chapter_path,
|
||||||
f"{img_pth}.png",
|
f"{img_pth}.png",
|
||||||
|
user_id,
|
||||||
info["artists"],
|
info["artists"],
|
||||||
info["album_name"],
|
info["album_name"],
|
||||||
chapters[i][2],
|
chapters[i][2],
|
||||||
|
@ -152,6 +154,7 @@ def download_from_youtube_link(link: str) -> Song:
|
||||||
song = load_track(
|
song = load_track(
|
||||||
path,
|
path,
|
||||||
f"{img_pth}.png",
|
f"{img_pth}.png",
|
||||||
|
user_id,
|
||||||
info["artists"],
|
info["artists"],
|
||||||
info["album_name"],
|
info["album_name"],
|
||||||
title,
|
title,
|
||||||
|
@ -161,6 +164,7 @@ def download_from_youtube_link(link: str) -> Song:
|
||||||
song = load_track(
|
song = load_track(
|
||||||
path,
|
path,
|
||||||
f"{img_pth}.png",
|
f"{img_pth}.png",
|
||||||
|
user_id,
|
||||||
info["artists"],
|
info["artists"],
|
||||||
info["album_name"],
|
info["album_name"],
|
||||||
title,
|
title,
|
||||||
|
|
|
@ -11,44 +11,44 @@
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def list_tracks(url):
|
def list_tracks(url, user_id):
|
||||||
if "music.yandex.ru" in url:
|
if "music.yandex.ru" in url:
|
||||||
yandex.load_playlist(url)
|
yandex.load_playlist(url, user_id)
|
||||||
elif "channel" in url or "/c/" in url:
|
elif "channel" in url or "/c/" in url:
|
||||||
p = Channel(url)
|
p = Channel(url)
|
||||||
for video in p.video_urls:
|
for video in p.video_urls:
|
||||||
process_yb.apply_async(kwargs={"url": video})
|
process_yb.apply_async(kwargs={"url": video, "user_id": user_id})
|
||||||
elif "playlist" in url or "&list=" in url:
|
elif "playlist" in url or "&list=" in url:
|
||||||
p = Playlist(url)
|
p = Playlist(url)
|
||||||
for video in p.video_urls:
|
for video in p.video_urls:
|
||||||
process_yb.apply_async(kwargs={"url": video})
|
process_yb.apply_async(kwargs={"url": video, "user_id": user_id})
|
||||||
else:
|
else:
|
||||||
process_yb.apply_async(kwargs={"url": url})
|
process_yb.apply_async(kwargs={"url": url, "user_id": user_id})
|
||||||
|
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
@shared_task(max_retries=5)
|
@shared_task(max_retries=5)
|
||||||
def process_yb(url):
|
def process_yb(url, user_id):
|
||||||
youtube.download_from_youtube_link(url)
|
youtube.download_from_youtube_link(url, user_id)
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def process_dir(path):
|
def process_dir(path, user_id):
|
||||||
load_dir(path)
|
load_dir(path, user_id)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def process_file(path):
|
def process_file(path, user_id):
|
||||||
load_file(path)
|
load_file(path, user_id)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def load_ym_file_meta(track):
|
def load_ym_file_meta(track, user_id):
|
||||||
return yandex.load_file_meta(track)
|
return yandex.load_file_meta(track, user_id)
|
||||||
|
|
||||||
|
|
||||||
@shared_task()
|
@shared_task()
|
||||||
|
|
|
@ -12,4 +12,5 @@
|
||||||
path("author/<str:slug>", views.author_view, name="author"),
|
path("author/<str:slug>", views.author_view, name="author"),
|
||||||
path("playlist/<str:slug>", views.playlist_view, name="playlist"),
|
path("playlist/<str:slug>", views.playlist_view, name="playlist"),
|
||||||
path("radio/", views.radio_main_view, name="radio"),
|
path("radio/", views.radio_main_view, name="radio"),
|
||||||
|
path("player/", views.music_player_view, name="player"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -55,7 +55,7 @@ def get_success_url(self):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
load_tracks(form.data["address"])
|
load_tracks(form.data["address"], user_id=self.request.user.id)
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
@ -73,7 +73,7 @@ def get_success_url(self):
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
for file in form.cleaned_data["file"]:
|
for file in form.cleaned_data["file"]:
|
||||||
t = TempFileUpload.objects.create(file=file)
|
t = TempFileUpload.objects.create(file=file)
|
||||||
load_track_file(t.file.path)
|
load_track_file(t.file.path, user_id=self.request.user.id)
|
||||||
|
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
@ -86,3 +86,14 @@ class MainRadioView(generic.TemplateView):
|
||||||
|
|
||||||
|
|
||||||
radio_main_view = MainRadioView.as_view()
|
radio_main_view = MainRadioView.as_view()
|
||||||
|
|
||||||
|
|
||||||
|
class MusicPlayerView(generic.ListView):
|
||||||
|
template_name = "music/player.html"
|
||||||
|
model = Song
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Song.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
music_player_view = MusicPlayerView.as_view()
|
||||||
|
|
0
akarpov/notifications/__init__.py
Normal file
0
akarpov/notifications/__init__.py
Normal file
8
akarpov/notifications/admin.py
Normal file
8
akarpov/notifications/admin.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from akarpov.notifications.models import Notification
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Notification)
|
||||||
|
class NotificationAdmin(admin.ModelAdmin):
|
||||||
|
list_filter = ["provider"]
|
12
akarpov/notifications/apps.py
Normal file
12
akarpov/notifications/apps.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsConfig(AppConfig):
|
||||||
|
name = "akarpov.notifications"
|
||||||
|
verbose_name = "Notifications"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
try:
|
||||||
|
import akarpov.notifications.signals # noqa F401
|
||||||
|
except ImportError:
|
||||||
|
pass
|
56
akarpov/notifications/migrations/0001_initial.py
Normal file
56
akarpov/notifications/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
# Generated by Django 4.2.5 on 2023-09-20 10:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django_extensions.db.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Notification",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
django_extensions.db.fields.CreationDateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"modified",
|
||||||
|
django_extensions.db.fields.ModificationDateTimeField(
|
||||||
|
auto_now=True, verbose_name="modified"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("title", models.CharField(max_length=255)),
|
||||||
|
("body", models.TextField(blank=True, max_length=5000, null=True)),
|
||||||
|
(
|
||||||
|
"provider",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("akarpov.notifications.providers.site", "site"),
|
||||||
|
("akarpov.notifications.providers.email", "email"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("meta", models.JSONField(null=True)),
|
||||||
|
("delivered", models.BooleanField(default=False)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"get_latest_by": "modified",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
0
akarpov/notifications/migrations/__init__.py
Normal file
0
akarpov/notifications/migrations/__init__.py
Normal file
17
akarpov/notifications/models.py
Normal file
17
akarpov/notifications/models.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
from django.db import models
|
||||||
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(TimeStampedModel):
|
||||||
|
class NotificationProviders(models.TextChoices):
|
||||||
|
site = "akarpov.notifications.providers.site", "site"
|
||||||
|
email = "akarpov.notifications.providers.email", "email"
|
||||||
|
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
body = models.TextField(max_length=5000, null=True, blank=True)
|
||||||
|
provider = models.CharField(choices=NotificationProviders.choices)
|
||||||
|
meta = models.JSONField(null=True)
|
||||||
|
delivered = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
0
akarpov/notifications/providers/__init__.py
Normal file
0
akarpov/notifications/providers/__init__.py
Normal file
1
akarpov/notifications/providers/email/__init__.py
Normal file
1
akarpov/notifications/providers/email/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from .send import send_notification # noqa
|
34
akarpov/notifications/providers/email/send.py
Normal file
34
akarpov/notifications/providers/email/send.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
from akarpov.notifications.models import Notification
|
||||||
|
from akarpov.users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def send_notification(notification: Notification) -> bool:
|
||||||
|
if not notification.meta or all(
|
||||||
|
["email" not in notification.meta, "user_id" not in notification.meta]
|
||||||
|
):
|
||||||
|
raise KeyError(
|
||||||
|
f"can't send notification {notification.id}, email/user_id is not found"
|
||||||
|
)
|
||||||
|
if "email" in notification.meta:
|
||||||
|
email = notification.meta["email"]
|
||||||
|
username = ""
|
||||||
|
else:
|
||||||
|
user = User.objects.get(id=notification.meta["user_id"])
|
||||||
|
email = user.email
|
||||||
|
username = user.username
|
||||||
|
message = render_to_string(
|
||||||
|
"email/notification.html", {"username": username, "body": notification.body}
|
||||||
|
)
|
||||||
|
send_mail(
|
||||||
|
notification.title,
|
||||||
|
notification.body,
|
||||||
|
settings.EMAIL_FROM,
|
||||||
|
[email],
|
||||||
|
fail_silently=False,
|
||||||
|
html_message=message,
|
||||||
|
)
|
||||||
|
return True
|
8
akarpov/notifications/providers/site/__init__.py
Normal file
8
akarpov/notifications/providers/site/__init__.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
"""
|
||||||
|
Notifications site provider
|
||||||
|
meta params:
|
||||||
|
- user_id: bool, required
|
||||||
|
- conformation: bool, optional
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .send import send_notification # noqa
|
9
akarpov/notifications/providers/site/api/serializers.py
Normal file
9
akarpov/notifications/providers/site/api/serializers.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from akarpov.notifications.models import Notification
|
||||||
|
|
||||||
|
|
||||||
|
class SiteNotificationSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Notification
|
||||||
|
fields = ["title", "body", "created", "delivered"]
|
19
akarpov/notifications/providers/site/api/views.py
Normal file
19
akarpov/notifications/providers/site/api/views.py
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
from rest_framework import generics, permissions
|
||||||
|
|
||||||
|
from akarpov.common.api import StandardResultsSetPagination
|
||||||
|
from akarpov.notifications.models import Notification
|
||||||
|
from akarpov.notifications.providers.site.api.serializers import (
|
||||||
|
SiteNotificationSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ListNotificationsAPIView(generics.ListAPIView):
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
serializer_class = SiteNotificationSerializer
|
||||||
|
pagination_class = StandardResultsSetPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Notification.objects.filter(meta__user_id=self.request.user.id)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: add read notification url here
|
28
akarpov/notifications/providers/site/consumers.py
Normal file
28
akarpov/notifications/providers/site/consumers.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from akarpov.common.channels import BaseConsumer
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsConsumer(BaseConsumer):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(args, kwargs)
|
||||||
|
self.room_group_name = None
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
self.room_group_name = f"notifications_{self.scope['user_id']}"
|
||||||
|
|
||||||
|
await self.accept()
|
||||||
|
await self.channel_layer.group_add(self.room_group_name, self.channel_name)
|
||||||
|
if not self.scope["user_id"]:
|
||||||
|
await self.send_error("Authorization is required")
|
||||||
|
await self.disconnect(close_code=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
async def receive_json(self, content: dict, **kwargs):
|
||||||
|
return content
|
||||||
|
|
||||||
|
async def notification(self, event):
|
||||||
|
data = event["data"]
|
||||||
|
await self.send_json(data)
|
30
akarpov/notifications/providers/site/send.py
Normal file
30
akarpov/notifications/providers/site/send.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
|
||||||
|
from akarpov.notifications.models import Notification
|
||||||
|
from akarpov.notifications.providers.site.api.serializers import (
|
||||||
|
SiteNotificationSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_notification(notification: Notification) -> bool:
|
||||||
|
if (
|
||||||
|
not notification.meta
|
||||||
|
or "user_id" not in notification.meta
|
||||||
|
or not notification.meta["user_id"]
|
||||||
|
):
|
||||||
|
raise KeyError(
|
||||||
|
f"can't send notification {notification.id}, user_id is not found"
|
||||||
|
)
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
async_to_sync(channel_layer.group_send)(
|
||||||
|
f"notifications_{notification.meta['user_id']}",
|
||||||
|
{
|
||||||
|
"type": "notification",
|
||||||
|
"data": SiteNotificationSerializer().to_representation(notification),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if "conformation" in notification.meta and notification.meta["conformation"]:
|
||||||
|
# no view conformation required, only pop up on site
|
||||||
|
return False
|
||||||
|
return True
|
8
akarpov/notifications/providers/site/urls.py
Normal file
8
akarpov/notifications/providers/site/urls.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from akarpov.notifications.providers.site.api.views import ListNotificationsAPIView
|
||||||
|
|
||||||
|
app_name = "notifications:site"
|
||||||
|
urlpatterns = [
|
||||||
|
path("", ListNotificationsAPIView.as_view(), name="list"),
|
||||||
|
]
|
8
akarpov/notifications/providers/urls.py
Normal file
8
akarpov/notifications/providers/urls.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
app_name = "notifications"
|
||||||
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"site/", include("akarpov.notifications.providers.site.urls", namespace="site")
|
||||||
|
),
|
||||||
|
]
|
7
akarpov/notifications/services.py
Normal file
7
akarpov/notifications/services.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
from akarpov.notifications.tasks import run_create_send_notification
|
||||||
|
|
||||||
|
|
||||||
|
def send_notification(title: str, body: str, provider: str, **kwargs):
|
||||||
|
run_create_send_notification.apply_async(
|
||||||
|
kwargs={"title": title, "body": body, "provider": provider} | kwargs
|
||||||
|
)
|
11
akarpov/notifications/signals.py
Normal file
11
akarpov/notifications/signals.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from akarpov.notifications.models import Notification
|
||||||
|
from akarpov.notifications.tasks import run_send_notification
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Notification)
|
||||||
|
def notification_create(sender, instance: Notification, created, **kwargs):
|
||||||
|
if created:
|
||||||
|
run_send_notification.apply_async(kwargs={"pk": instance.pk}, countdown=2)
|
32
akarpov/notifications/tasks.py
Normal file
32
akarpov/notifications/tasks.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
from akarpov.notifications.models import Notification
|
||||||
|
|
||||||
|
providers = {x[1]: x[0] for x in Notification.NotificationProviders.choices}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def run_send_notification(pk):
|
||||||
|
instance = Notification.objects.get(pk=pk)
|
||||||
|
provider = import_module(instance.provider)
|
||||||
|
instance.delivered = provider.send_notification(instance)
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def run_create_send_notification(title: str, body: str, provider: str, **kwargs):
|
||||||
|
if provider != "*" and provider not in providers:
|
||||||
|
raise ValueError(f"no such provider: {provider}")
|
||||||
|
if provider == "*":
|
||||||
|
for provider in providers:
|
||||||
|
Notification.objects.create(
|
||||||
|
title=title, body=body, provider=providers[provider], meta=kwargs
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
Notification.objects.create(
|
||||||
|
title=title, body=body, provider=providers[provider], meta=kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
0
akarpov/notifications/tests.py
Normal file
0
akarpov/notifications/tests.py
Normal file
0
akarpov/notifications/views.py
Normal file
0
akarpov/notifications/views.py
Normal file
67
akarpov/static/css/music-player.css
Normal file
67
akarpov/static/css/music-player.css
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
@import url('https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css');
|
||||||
|
|
||||||
|
*,*:before,*:after{outline:0;-webkit-box-sizing:border-box;box-sizing:border-box;}
|
||||||
|
input,button{outline:none;}
|
||||||
|
a,a:hover,a:visited{color:#ddd;text-decoration:none;}
|
||||||
|
.flex{display:-webkit-flex;display:flex;}
|
||||||
|
.flex-wrap{display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;}
|
||||||
|
.flex-align{-webkit-align-items:center;align-items:center;}
|
||||||
|
.w-full{width:100%;}
|
||||||
|
|
||||||
|
/* HTML5 Audio Player with Playlist, source: https://codepen.io/sekedus/pen/ExxjZEz */
|
||||||
|
#simp button,#simp input,#simp img{border:0;}
|
||||||
|
#simp{max-width:600px;font-size:14px;font-family:"Segoe UI", Tahoma, sans-serif;text-align:initial;line-height:initial;background:#17212b;color:#ddd;margin:0 auto;border-radius:6px;overflow:hidden;}
|
||||||
|
#simp .simp-album{padding:20px 25px 5px;}
|
||||||
|
#simp .simp-album .simp-cover{margin-right:20px;}
|
||||||
|
#simp .simp-album .simp-cover img{max-width:80px;width:100%;margin:0;padding:0;display:block;}
|
||||||
|
#simp .simp-album .simp-title{font-size:120%;font-weight:bold;}
|
||||||
|
#simp .simp-album .simp-artist{font-size:90%;color:#6c7883;}
|
||||||
|
#simp .simp-controls{padding:15px;}
|
||||||
|
#simp .simp-controls button{font-size:130%;width:32px;height:32px;background:none;color:#ddd;padding:7px;cursor:pointer;border:0;border-radius:3px;}
|
||||||
|
#simp .simp-controls button[disabled]{color:#636469;cursor:initial;}
|
||||||
|
#simp .simp-controls button:not([disabled]):hover{background:#4082bc;color:#fff;}
|
||||||
|
#simp .simp-controls .simp-prev,#simp .simp-controls .simp-next{font-size:100%;}
|
||||||
|
#simp .simp-controls .simp-tracker,#simp .simp-controls .simp-volume{flex:1;margin-left:10px;position:relative;}
|
||||||
|
#simp .simp-controls .simp-buffer {position:absolute;top:50%;right:0;left:0;height:5px;margin-top:-2.5px;border-radius:100px;}
|
||||||
|
#simp .simp-controls .simp-loading .simp-buffer {-webkit-animation:audio-progress 1s linear infinite;animation:audio-progress 1s linear infinite;background-image: linear-gradient(-45deg, #000 25%, transparent 25%, transparent 50%, #000 50%, #000 75%, transparent 75%, transparent);background-repeat:repeat-x;background-size:25px 25px;color:transparent;}
|
||||||
|
#simp .simp-controls .simp-time,#simp .simp-controls .simp-others{margin-left:10px;}
|
||||||
|
#simp .simp-controls .simp-volume{max-width:110px;}
|
||||||
|
#simp .simp-controls .simp-volume .simp-mute{margin-right:5px;}
|
||||||
|
#simp .simp-controls .simp-others .simp-active{background:#242f3d;}
|
||||||
|
#simp .simp-controls .simp-others .simp-shide button{font-size:100%;padding:0;width:24px;height:14px;display:block;}
|
||||||
|
#simp .simp-controls input[type=range]{-webkit-appearance:none;background:transparent;height:19px;margin:0;width:100%;display:block;position:relative;z-index:2;}
|
||||||
|
#simp .simp-controls input[type=range]::-webkit-slider-runnable-track{background:rgba(183,197,205,.66);height:5px;border-radius:2.5px;transition:box-shadow .3s ease;position:relative;}
|
||||||
|
#simp .simp-controls input[type=range]::-moz-range-track{background:rgba(183,197,205,.66);height:5px;border-radius:2.5px;transition:box-shadow .3s ease;position:relative;}
|
||||||
|
#simp .simp-controls .simp-load .simp-progress::-webkit-slider-runnable-track{background:#2f3841;}
|
||||||
|
#simp .simp-controls .simp-load .simp-progress::-moz-range-track{background:#2f3841;}
|
||||||
|
#simp .simp-controls .simp-loading .simp-progress::-webkit-slider-runnable-track{background:rgba(255,255,255,.25);}
|
||||||
|
#simp .simp-controls .simp-loading .simp-progress::-moz-range-track{background:rgba(255,255,255,.25);}
|
||||||
|
#simp .simp-controls input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;background:#fff;height:13px;width:13px;margin-top:-4px;cursor:pointer;border-radius:50%;box-shadow:0 1px 1px rgba(0,0,0,.15), 0 0 0 1px rgba(47,52,61,.2);}
|
||||||
|
#simp .simp-controls input[type=range]::-moz-range-thumb{-webkit-appearance:none;background:#fff;height:13px;width:13px;cursor:pointer;border-radius:50%;box-shadow:0 1px 1px rgba(0,0,0,.15), 0 0 0 1px rgba(47,52,61,.2);}
|
||||||
|
#simp .simp-footer{padding:10px 10px 12px;font-size:90%;text-align:center;opacity:.7;}
|
||||||
|
#simp .simp-display{overflow:hidden;max-height:650px;transition:max-height .5s ease-in-out;}
|
||||||
|
#simp .simp-hide{max-height:0;}
|
||||||
|
/* playlist */
|
||||||
|
#simp ul{margin:5px 0 0;padding:0;list-style:none;max-height:245px;}
|
||||||
|
#simp ul li{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block;margin:0;padding:8px 20px;cursor:pointer;}
|
||||||
|
#simp ul li:last-child{padding-bottom:13px;}
|
||||||
|
#simp ul li:nth-child(odd){background:#0e1621;}
|
||||||
|
#simp ul li:hover{background:#242f3d;}
|
||||||
|
#simp ul li.simp-active{background:#4082bc;color:#fff;}
|
||||||
|
#simp ul li .simp-desc{font-size:90%;opacity:.5;margin-left:5px;}
|
||||||
|
/* playlist scrollbar */
|
||||||
|
#simp ul{overflow-y:auto;overflow-x:hidden;scrollbar-color:#73797f #2f3841;}
|
||||||
|
#simp ul::-webkit-scrollbar-track{background-color:#2f3841;}
|
||||||
|
#simp ul::-webkit-scrollbar{width:6px;background-color:#2f3841;}
|
||||||
|
#simp ul::-webkit-scrollbar-thumb{background-color:#73797f;}
|
||||||
|
/* progress animation */
|
||||||
|
@-webkit-keyframes audio-progress{to{background-position:25px 0;}}
|
||||||
|
@keyframes audio-progress{to{background-position:25px 0;}}
|
||||||
|
/* mobile */
|
||||||
|
@media screen and (max-width:480px) {
|
||||||
|
#simp .simp-controls .simp-volume,#simp .simp-controls .simp-others{display:none;}
|
||||||
|
#simp .simp-controls .simp-time{margin-right:10px;}
|
||||||
|
}
|
||||||
|
@media screen and (max-width:370px) {
|
||||||
|
#simp .simp-time .simp-slash,#simp .simp-time .end-time{display:none;}
|
||||||
|
}
|
415
akarpov/static/js/music-player.js
Normal file
415
akarpov/static/js/music-player.js
Normal file
|
@ -0,0 +1,415 @@
|
||||||
|
function addEventListener_multi(element, eventNames, handler) {
|
||||||
|
const events = eventNames.split(' ');
|
||||||
|
events.forEach(e => element.addEventListener(e, handler, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random numbers in a specific range
|
||||||
|
function getRandom(min, max) {
|
||||||
|
min = Math.ceil(min);
|
||||||
|
max = Math.floor(max);
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position element inside element
|
||||||
|
function getRelativePos(elm) {
|
||||||
|
const pPos = elm.parentNode.getBoundingClientRect(); // parent pos
|
||||||
|
const cPos = elm.getBoundingClientRect(); // target pos
|
||||||
|
const pos = {};
|
||||||
|
|
||||||
|
pos.top = cPos.top - pPos.top + elm.parentNode.scrollTop,
|
||||||
|
pos.right = cPos.right - pPos.right,
|
||||||
|
pos.bottom = cPos.bottom - pPos.bottom,
|
||||||
|
pos.left = cPos.left - pPos.left;
|
||||||
|
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(val) {
|
||||||
|
let h = 0, m = 0, s;
|
||||||
|
val = parseInt(val, 10);
|
||||||
|
if (val > 60 * 60) {
|
||||||
|
h = parseInt(val / (60 * 60), 10);
|
||||||
|
val -= h * 60 * 60;
|
||||||
|
}
|
||||||
|
if (val > 60) {
|
||||||
|
m = parseInt(val / 60, 10);
|
||||||
|
val -= m * 60;
|
||||||
|
}
|
||||||
|
s = val;
|
||||||
|
val = (h > 0)? h + ':' : '';
|
||||||
|
val += (m > 0)? ((m < 10 && h > 0)? '0' : '') + m + ':' : '0:';
|
||||||
|
val += ((s < 10)? '0' : '') + s;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simp_initTime() {
|
||||||
|
simp_controls.querySelector('.start-time').innerHTML = formatTime(simp_audio.currentTime); //calculate current value time
|
||||||
|
if (!simp_isStream) {
|
||||||
|
simp_controls.querySelector('.end-time').innerHTML = formatTime(simp_audio.duration); //calculate total value time
|
||||||
|
simp_progress.value = simp_audio.currentTime / simp_audio.duration * 100; //progress bar
|
||||||
|
}
|
||||||
|
|
||||||
|
// ended of the audio
|
||||||
|
if (simp_audio.currentTime == simp_audio.duration) {
|
||||||
|
simp_controls.querySelector('.simp-plause').classList.remove('fa-pause');
|
||||||
|
simp_controls.querySelector('.simp-plause').classList.add('fa-play');
|
||||||
|
simp_audio.removeEventListener('timeupdate', simp_initTime);
|
||||||
|
|
||||||
|
if (simp_isNext) { //auto load next audio
|
||||||
|
let elem;
|
||||||
|
simp_a_index++;
|
||||||
|
if (simp_a_index == simp_a_url.length) { //repeat all audio
|
||||||
|
simp_a_index = 0;
|
||||||
|
elem = simp_a_url[0];
|
||||||
|
} else {
|
||||||
|
elem = simp_a_url[simp_a_index];
|
||||||
|
}
|
||||||
|
simp_changeAudio(elem);
|
||||||
|
simp_setAlbum(simp_a_index);
|
||||||
|
} else {
|
||||||
|
simp_isPlaying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simp_initAudio() {
|
||||||
|
// if readyState more than 2, audio file has loaded
|
||||||
|
simp_isLoaded = simp_audio.readyState == 4 ? true : false;
|
||||||
|
simp_isStream = simp_audio.duration == 'Infinity' ? true : false;
|
||||||
|
simp_controls.querySelector('.simp-plause').disabled = false;
|
||||||
|
simp_progress.disabled = simp_isStream ? true : false;
|
||||||
|
if (!simp_isStream) {
|
||||||
|
simp_progress.parentNode.classList.remove('simp-load','simp-loading');
|
||||||
|
simp_controls.querySelector('.end-time').innerHTML = formatTime(simp_audio.duration);
|
||||||
|
}
|
||||||
|
simp_audio.addEventListener('timeupdate', simp_initTime); //tracking load progress
|
||||||
|
if (simp_isLoaded && simp_isPlaying) simp_audio.play();
|
||||||
|
|
||||||
|
// progress bar click event
|
||||||
|
addEventListener_multi(simp_progress, 'touchstart mousedown', function(e) {
|
||||||
|
if (simp_isStream) {
|
||||||
|
e.stopPropagation();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (simp_audio.readyState == 4) {
|
||||||
|
simp_audio.removeEventListener('timeupdate', simp_initTime);
|
||||||
|
simp_audio.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addEventListener_multi(simp_progress, 'touchend mouseup', function(e) {
|
||||||
|
if (simp_isStream) {
|
||||||
|
e.stopPropagation();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (simp_audio.readyState == 4) {
|
||||||
|
simp_audio.currentTime = simp_progress.value * simp_audio.duration / 100;
|
||||||
|
simp_audio.addEventListener('timeupdate', simp_initTime);
|
||||||
|
if (simp_isPlaying) simp_audio.play();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function simp_loadAudio(elem) {
|
||||||
|
simp_progress.parentNode.classList.add('simp-loading');
|
||||||
|
simp_controls.querySelector('.simp-plause').disabled = true;
|
||||||
|
simp_audio.querySelector('source').src = elem.dataset.src;
|
||||||
|
simp_audio.load();
|
||||||
|
|
||||||
|
simp_audio.volume = parseFloat(simp_v_num / 100); //based on valume input value
|
||||||
|
simp_audio.addEventListener('canplaythrough', simp_initAudio); //play audio without stop for buffering
|
||||||
|
|
||||||
|
// if audio fails to load, only IE/Edge 9.0 or above
|
||||||
|
simp_audio.addEventListener('error', function() {
|
||||||
|
alert('Please reload the page.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function simp_setAlbum(index) {
|
||||||
|
simp_cover.innerHTML = simp_a_url[index].dataset.cover ? '<div style="background:url(' + simp_a_url[index].dataset.cover + ') no-repeat;background-size:cover;width:80px;height:80px;"></div>' : '<i class="fa fa-music fa-5x"></i>';
|
||||||
|
simp_title.innerHTML = simp_source[index].querySelector('.simp-source').innerHTML;
|
||||||
|
simp_artist.innerHTML = simp_source[index].querySelector('.simp-desc') ? simp_source[index].querySelector('.simp-desc').innerHTML : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function simp_changeAudio(elem) {
|
||||||
|
simp_isLoaded = false;
|
||||||
|
simp_controls.querySelector('.simp-prev').disabled = simp_a_index == 0 ? true : false;
|
||||||
|
simp_controls.querySelector('.simp-plause').disabled = simp_auto_load ? true : false;
|
||||||
|
simp_controls.querySelector('.simp-next').disabled = simp_a_index == simp_a_url.length-1 ? true : false;
|
||||||
|
simp_progress.parentNode.classList.add('simp-load');
|
||||||
|
simp_progress.disabled = true;
|
||||||
|
simp_progress.value = 0;
|
||||||
|
simp_controls.querySelector('.start-time').innerHTML = '00:00';
|
||||||
|
simp_controls.querySelector('.end-time').innerHTML = '00:00';
|
||||||
|
elem = simp_isRandom && simp_isNext ? simp_a_url[getRandom(0, simp_a_url.length-1)] : elem;
|
||||||
|
|
||||||
|
// playlist, audio is running
|
||||||
|
for (let i = 0; i < simp_a_url.length; i++) {
|
||||||
|
simp_a_url[i].parentNode.classList.remove('simp-active');
|
||||||
|
if (simp_a_url[i] == elem) {
|
||||||
|
simp_a_index = i;
|
||||||
|
simp_a_url[i].parentNode.classList.add('simp-active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrolling to element inside element
|
||||||
|
const simp_active = getRelativePos(simp_source[simp_a_index]);
|
||||||
|
simp_source[simp_a_index].parentNode.scrollTop = simp_active.top;
|
||||||
|
|
||||||
|
if (simp_auto_load || simp_isPlaying) simp_loadAudio(elem);
|
||||||
|
|
||||||
|
if (simp_isPlaying) {
|
||||||
|
simp_controls.querySelector('.simp-plause').classList.remove('fa-play');
|
||||||
|
simp_controls.querySelector('.simp-plause').classList.add('fa-pause');
|
||||||
|
}
|
||||||
|
// set native audio properties
|
||||||
|
if('mediaSession' in navigator) {
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: elem.textContent,
|
||||||
|
artist: elem.dataset.artists,
|
||||||
|
album: elem.dataset.album,
|
||||||
|
artwork: [
|
||||||
|
{ src: elem.dataset.cover, sizes: '96x96', type: 'image/png' },
|
||||||
|
{ src: elem.dataset.cover, sizes: '128x128', type: 'image/png' },
|
||||||
|
{ src: elem.dataset.cover, sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: elem.dataset.cover, sizes: '256x256', type: 'image/png' },
|
||||||
|
{ src: elem.dataset.cover, sizes: '384x384', type: 'image/png' },
|
||||||
|
{ src: elem.dataset.cover, sizes: '512x512', type: 'image/png' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
navigator.mediaSession.setActionHandler('play', () => {
|
||||||
|
let eles = document.getElementById("simp-plause").classList
|
||||||
|
if (simp_audio.paused) {
|
||||||
|
if (!simp_isLoaded) simp_loadAudio(simp_a_url[simp_a_index]);
|
||||||
|
simp_audio.play();
|
||||||
|
simp_isPlaying = true;
|
||||||
|
eles.remove('fa-play');
|
||||||
|
eles.add('fa-pause');
|
||||||
|
} else {
|
||||||
|
simp_audio.pause();
|
||||||
|
simp_isPlaying = false;
|
||||||
|
eles.remove('fa-pause');
|
||||||
|
eles.add('fa-play');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
navigator.mediaSession.setActionHandler('pause', () => {
|
||||||
|
let eles = document.getElementById("simp-plause").classList
|
||||||
|
if (simp_audio.paused) {
|
||||||
|
if (!simp_isLoaded) simp_loadAudio(simp_a_url[simp_a_index]);
|
||||||
|
simp_audio.play();
|
||||||
|
simp_isPlaying = true;
|
||||||
|
eles.remove('fa-play');
|
||||||
|
eles.add('fa-pause');
|
||||||
|
} else {
|
||||||
|
simp_audio.pause();
|
||||||
|
simp_isPlaying = false;
|
||||||
|
eles.remove('fa-pause');
|
||||||
|
eles.add('fa-play');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
navigator.mediaSession.setActionHandler("previoustrack", () => {
|
||||||
|
let eles = document.getElementById("simp-previoustrack")
|
||||||
|
if (simp_a_index !== 0) {
|
||||||
|
simp_a_index = simp_a_index-1;
|
||||||
|
eles.disabled = simp_a_index == 0 ? true : false;
|
||||||
|
}
|
||||||
|
simp_audio.removeEventListener('timeupdate', simp_initTime);
|
||||||
|
simp_changeAudio(simp_a_url[simp_a_index]);
|
||||||
|
simp_setAlbum(simp_a_index);
|
||||||
|
});
|
||||||
|
navigator.mediaSession.setActionHandler("nexttrack", () => {
|
||||||
|
let eles = document.getElementById("simp-nexttrack")
|
||||||
|
if (simp_a_index !== simp_a_url.length-1) {
|
||||||
|
simp_a_index = simp_a_index+1;
|
||||||
|
eles.disabled = simp_a_index == simp_a_url.length-1 ? true : false;
|
||||||
|
}
|
||||||
|
simp_audio.removeEventListener('timeupdate', simp_initTime);
|
||||||
|
simp_changeAudio(simp_a_url[simp_a_index]);
|
||||||
|
simp_setAlbum(simp_a_index);
|
||||||
|
});
|
||||||
|
navigator.mediaSession.setActionHandler('seekbackward', (details) => {
|
||||||
|
simp_audio.currentTime = simp_audio.currentTime - (details.seekOffset || 10);
|
||||||
|
});
|
||||||
|
navigator.mediaSession.setActionHandler('seekforward', (details) => {
|
||||||
|
simp_audio.currentTime = simp_audio.currentTime + (details.seekOffset || 10);
|
||||||
|
});
|
||||||
|
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
||||||
|
if (details.fastSeek && 'fastSeek' in simp_audio) {
|
||||||
|
simp_audio.fastSeek(details.seekTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
simp_audio.currentTime = details.seekTime;
|
||||||
|
});
|
||||||
|
navigator.mediaSession.setActionHandler('stop', () => {
|
||||||
|
let eles = document.getElementById("simp-plause").classList
|
||||||
|
simp_audio.currentTime = 0;
|
||||||
|
simp_controls.querySelector('.start-time').innerHTML = '00:00';
|
||||||
|
if (!simp_isLoaded) simp_loadAudio(simp_a_url[simp_a_index]);
|
||||||
|
simp_audio.play();
|
||||||
|
simp_isPlaying = true;
|
||||||
|
eles.remove('fa-play');
|
||||||
|
eles.add('fa-pause');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function simp_startScript() {
|
||||||
|
ap_simp = document.querySelector('#simp');
|
||||||
|
simp_audio = ap_simp.querySelector('#audio');
|
||||||
|
simp_album = ap_simp.querySelector('.simp-album');
|
||||||
|
simp_cover = simp_album.querySelector('.simp-cover');
|
||||||
|
simp_title = simp_album.querySelector('.simp-title');
|
||||||
|
simp_artist = simp_album.querySelector('.simp-artist');
|
||||||
|
simp_controls = ap_simp.querySelector('.simp-controls');
|
||||||
|
simp_progress = simp_controls.querySelector('.simp-progress');
|
||||||
|
simp_volume = simp_controls.querySelector('.simp-volume');
|
||||||
|
simp_v_slider = simp_volume.querySelector('.simp-v-slider');
|
||||||
|
simp_v_num = simp_v_slider.value; //default volume
|
||||||
|
simp_others = simp_controls.querySelector('.simp-others');
|
||||||
|
simp_auto_load = simp_config.auto_load; //auto load audio file
|
||||||
|
|
||||||
|
if (simp_config.shide_top) simp_album.parentNode.classList.toggle('simp-hide');
|
||||||
|
if (simp_config.shide_btm) {
|
||||||
|
simp_playlist.classList.add('simp-display');
|
||||||
|
simp_playlist.classList.toggle('simp-hide');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (simp_a_url.length <= 1) {
|
||||||
|
simp_controls.querySelector('.simp-prev').style.display = 'none';
|
||||||
|
simp_controls.querySelector('.simp-next').style.display = 'none';
|
||||||
|
simp_others.querySelector('.simp-plext').style.display = 'none';
|
||||||
|
simp_others.querySelector('.simp-random').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist listeners
|
||||||
|
simp_source.forEach(function(item, index) {
|
||||||
|
if (item.classList.contains('simp-active')) simp_a_index = index; //playlist contains '.simp-active'
|
||||||
|
item.addEventListener('click', function() {
|
||||||
|
simp_audio.removeEventListener('timeupdate', simp_initTime);
|
||||||
|
simp_a_index = index;
|
||||||
|
simp_changeAudio(this.querySelector('.simp-source'));
|
||||||
|
simp_setAlbum(simp_a_index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// FIRST AUDIO LOAD =======
|
||||||
|
simp_changeAudio(simp_a_url[simp_a_index]);
|
||||||
|
simp_setAlbum(simp_a_index);
|
||||||
|
// FIRST AUDIO LOAD =======
|
||||||
|
|
||||||
|
// Controls listeners
|
||||||
|
simp_controls.querySelector('.simp-plauseward').addEventListener('click', function(e) {
|
||||||
|
const eles = e.target.classList;
|
||||||
|
if (eles.contains('simp-plause')) {
|
||||||
|
if (simp_audio.paused) {
|
||||||
|
if (!simp_isLoaded) simp_loadAudio(simp_a_url[simp_a_index]);
|
||||||
|
simp_audio.play();
|
||||||
|
simp_isPlaying = true;
|
||||||
|
eles.remove('fa-play');
|
||||||
|
eles.add('fa-pause');
|
||||||
|
} else {
|
||||||
|
simp_audio.pause();
|
||||||
|
simp_isPlaying = false;
|
||||||
|
eles.remove('fa-pause');
|
||||||
|
eles.add('fa-play');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (eles.contains('simp-prev') && simp_a_index != 0) {
|
||||||
|
simp_a_index = simp_a_index-1;
|
||||||
|
e.target.disabled = simp_a_index == 0 ? true : false;
|
||||||
|
} else if (eles.contains('simp-next') && simp_a_index != simp_a_url.length-1) {
|
||||||
|
simp_a_index = simp_a_index+1;
|
||||||
|
e.target.disabled = simp_a_index == simp_a_url.length-1 ? true : false;
|
||||||
|
}
|
||||||
|
simp_audio.removeEventListener('timeupdate', simp_initTime);
|
||||||
|
simp_changeAudio(simp_a_url[simp_a_index]);
|
||||||
|
simp_setAlbum(simp_a_index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audio volume
|
||||||
|
simp_volume.addEventListener('click', function(e) {
|
||||||
|
const eles = e.target.classList;
|
||||||
|
if (eles.contains('simp-mute')) {
|
||||||
|
if (eles.contains('fa-volume-up')) {
|
||||||
|
eles.remove('fa-volume-up');
|
||||||
|
eles.add('fa-volume-off');
|
||||||
|
simp_v_slider.value = 0;
|
||||||
|
} else {
|
||||||
|
eles.remove('fa-volume-off');
|
||||||
|
eles.add('fa-volume-up');
|
||||||
|
simp_v_slider.value = simp_v_num;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
simp_v_num = simp_v_slider.value;
|
||||||
|
if (simp_v_num != 0) {
|
||||||
|
simp_controls.querySelector('.simp-mute').classList.remove('fa-volume-off');
|
||||||
|
simp_controls.querySelector('.simp-mute').classList.add('fa-volume-up');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
simp_audio.volume = parseFloat(simp_v_slider.value / 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Others
|
||||||
|
simp_others.addEventListener('click', function(e) {
|
||||||
|
const eles = e.target.classList;
|
||||||
|
if (eles.contains('simp-plext')) {
|
||||||
|
simp_isNext = simp_isNext && !simp_isRandom ? false : true;
|
||||||
|
if (!simp_isRandom) simp_isRanext = simp_isRanext ? false : true;
|
||||||
|
eles.contains('simp-active') && !simp_isRandom ? eles.remove('simp-active') : eles.add('simp-active');
|
||||||
|
} else if (eles.contains('simp-random')) {
|
||||||
|
simp_isRandom = simp_isRandom ? false : true;
|
||||||
|
if (simp_isNext && !simp_isRanext) {
|
||||||
|
simp_isNext = false;
|
||||||
|
simp_others.querySelector('.simp-plext').classList.remove('simp-active');
|
||||||
|
} else {
|
||||||
|
simp_isNext = true;
|
||||||
|
simp_others.querySelector('.simp-plext').classList.add('simp-active');
|
||||||
|
}
|
||||||
|
eles.contains('simp-active') ? eles.remove('simp-active') : eles.add('simp-active');
|
||||||
|
} else if (eles.contains('simp-shide-top')) {
|
||||||
|
simp_album.parentNode.classList.toggle('simp-hide');
|
||||||
|
} else if (eles.contains('simp-shide-bottom')) {
|
||||||
|
simp_playlist.classList.add('simp-display');
|
||||||
|
simp_playlist.classList.toggle('simp-hide');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start simple player
|
||||||
|
if (document.querySelector('#simp')) {
|
||||||
|
var simp_auto_load, simp_audio, simp_album, simp_cover, simp_title, simp_artist, simp_controls, simp_progress, simp_volume, simp_v_slider, simp_v_num, simp_others;
|
||||||
|
var ap_simp = document.querySelector('#simp');
|
||||||
|
var simp_playlist = ap_simp.querySelector('.simp-playlist');
|
||||||
|
var simp_source = simp_playlist.querySelectorAll('li');
|
||||||
|
var simp_a_url = simp_playlist.querySelectorAll('[data-src]');
|
||||||
|
var simp_a_index = 0;
|
||||||
|
var simp_isPlaying = false;
|
||||||
|
var simp_isNext = true; //auto play
|
||||||
|
var simp_isRandom = false; //play random
|
||||||
|
var simp_isRanext = false; //check if before random starts, simp_isNext value is true
|
||||||
|
var simp_isStream = false; //radio streaming
|
||||||
|
var simp_isLoaded = false; //audio file has loaded
|
||||||
|
var simp_config = ap_simp.dataset.config ? JSON.parse(ap_simp.dataset.config) : {
|
||||||
|
shide_top: false, //show/hide album
|
||||||
|
shide_btm: false, //show/hide playlist
|
||||||
|
auto_load: false //auto load audio file
|
||||||
|
};
|
||||||
|
|
||||||
|
let simp_elem = '';
|
||||||
|
simp_elem += '<audio id="audio" preload><source src="" type="audio/mpeg"></audio>';
|
||||||
|
simp_elem += '<div class="simp-display"><div class="simp-album w-full flex-wrap"><div class="simp-cover"><i class="fa fa-music fa-5x"></i></div><div class="simp-info"><div class="simp-title">Title</div><div class="simp-artist">Artist</div></div></div></div>';
|
||||||
|
simp_elem += '<div class="simp-controls flex-wrap flex-align">';
|
||||||
|
simp_elem += '<div class="simp-plauseward flex flex-align"><button type="button" class="simp-prev fa fa-backward" id="simp-previoustrack" disabled></button><button id="simp-plause" type="button" class="simp-plause fa fa-play" disabled></button><button id="simp-nexttrack" type="button" class="simp-next fa fa-forward" disabled></button></div>';
|
||||||
|
simp_elem += '<div class="simp-tracker simp-load"><input class="simp-progress" type="range" min="0" max="100" value="0" disabled/><div class="simp-buffer"></div></div>';
|
||||||
|
simp_elem += '<div class="simp-time flex flex-align"><span class="start-time">00:00</span><span class="simp-slash"> / </span><span class="end-time">00:00</span></div>';
|
||||||
|
simp_elem += '<div class="simp-volume flex flex-align"><button type="button" class="simp-mute fa fa-volume-up"></button><input class="simp-v-slider" type="range" min="0" max="100" value="100"/></div>';
|
||||||
|
simp_elem += '<div class="simp-others flex flex-align"><button type="button" class="simp-plext fa fa-play-circle simp-active" title="Auto Play" ></button><button type="button" class="simp-random fa fa-random" title="Random"></button><div class="simp-shide"><button type="button" class="simp-shide-top fa fa-caret-up" title="Show/Hide Album"></button><button type="button" class="simp-shide-bottom fa fa-caret-down" title="Show/Hide Playlist"></button></div></div>';
|
||||||
|
simp_elem += '</div>'; //simp-controls
|
||||||
|
|
||||||
|
const simp_player = document.createElement('div');
|
||||||
|
simp_player.classList.add('simp-player');
|
||||||
|
simp_player.innerHTML = simp_elem;
|
||||||
|
ap_simp.insertBefore(simp_player, simp_playlist);
|
||||||
|
simp_startScript();
|
||||||
|
}
|
|
@ -21,6 +21,11 @@
|
||||||
|
|
||||||
<!-- Latest compiled and minified Bootstrap CSS -->
|
<!-- Latest compiled and minified Bootstrap CSS -->
|
||||||
<link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet">
|
<link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet">
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
{% if request.user.theme %}
|
||||||
|
<link href="{{ request.user.theme.file.url }}" rel="stylesheet">
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.2/font/bootstrap-icons.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.2/font/bootstrap-icons.css">
|
||||||
<script src="https://kit.fontawesome.com/32fd82c823.js" crossorigin="anonymous"></script>
|
<script src="https://kit.fontawesome.com/32fd82c823.js" crossorigin="anonymous"></script>
|
||||||
<!-- Your stuff: Third-party CSS libraries go here -->
|
<!-- Your stuff: Third-party CSS libraries go here -->
|
||||||
|
@ -132,6 +137,10 @@
|
||||||
<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="/about">sanspie</a>, find source code <a href="https://github.com/Alexander-D-Karpov/akarpov">here</a> </div>
|
<div class="col"> Writen by <a href="/about">sanspie</a>, find source code <a href="https://github.com/Alexander-D-Karpov/akarpov">here</a> </div>
|
||||||
</footer>
|
</footer>
|
||||||
|
<div id="toastContainer" class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -140,5 +149,87 @@
|
||||||
|
|
||||||
{% block inline_javascript %}
|
{% block inline_javascript %}
|
||||||
{% endblock inline_javascript %}
|
{% endblock inline_javascript %}
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<script>
|
||||||
|
{% if request.is_secure %}
|
||||||
|
let socket = new WebSocket(`wss://{{ request.get_host }}/ws/notifications/`);
|
||||||
|
{% else %}
|
||||||
|
let socket = new WebSocket(`ws://{{ request.get_host }}/ws/notifications/`);
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeSince(date) {
|
||||||
|
let seconds = Math.floor((new Date() - date) / 1000);
|
||||||
|
let interval = seconds / 31536000;
|
||||||
|
if (interval > 1) {
|
||||||
|
return Math.floor(interval) + " years";
|
||||||
|
}
|
||||||
|
interval = seconds / 2592000;
|
||||||
|
if (interval > 1) {
|
||||||
|
return Math.floor(interval) + " months";
|
||||||
|
}
|
||||||
|
interval = seconds / 86400;
|
||||||
|
if (interval > 1) {
|
||||||
|
return Math.floor(interval) + " days";
|
||||||
|
}
|
||||||
|
interval = seconds / 3600;
|
||||||
|
if (interval > 1) {
|
||||||
|
return Math.floor(interval) + " hours";
|
||||||
|
}
|
||||||
|
interval = seconds / 60;
|
||||||
|
if (interval > 1) {
|
||||||
|
return Math.floor(interval) + " minutes";
|
||||||
|
}
|
||||||
|
return Math.floor(seconds) + " seconds";
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastContainer = document.getElementById('toastContainer')
|
||||||
|
|
||||||
|
|
||||||
|
let fn = async function(event) {
|
||||||
|
let data = JSON.parse(event.data)
|
||||||
|
const toast = document.createElement("div")
|
||||||
|
toast.id = "liveToast"
|
||||||
|
toast.className = "toast mb-4 ml-2"
|
||||||
|
toast.setAttribute("role", "alert")
|
||||||
|
toast.setAttribute("aria-live", "assertive")
|
||||||
|
toast.setAttribute("aria-atomic", "true")
|
||||||
|
toast.innerHTML = `<div class="toast-header">
|
||||||
|
<strong class="me-auto">${data.title}</strong>
|
||||||
|
<small>${timeSince(Date.parse(data.created))} ago</small>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body">
|
||||||
|
${data.body}
|
||||||
|
</div>`
|
||||||
|
toastContainer.appendChild(toast)
|
||||||
|
const toastBootstrap = bootstrap.Toast.getOrCreateInstance(toast)
|
||||||
|
toastBootstrap.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = fn
|
||||||
|
socket.onclose = async function(event) {
|
||||||
|
console.log("Notifications socket disconnected, reconnecting...")
|
||||||
|
let socketClosed = true;
|
||||||
|
await sleep(5000)
|
||||||
|
while (socketClosed) {
|
||||||
|
{# TODO: reconnect socket here #}
|
||||||
|
try {
|
||||||
|
let cl = socket.onclose
|
||||||
|
socket = new WebSocket(`ws://127.0.0.1:8000/ws/notifications/`);
|
||||||
|
socket.onmessage = fn
|
||||||
|
socket.onclose = cl
|
||||||
|
socketClosed = false
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Can't connect to socket, reconnecting...")
|
||||||
|
await sleep(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
6
akarpov/templates/email/notification.html
Normal file
6
akarpov/templates/email/notification.html
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<h3>Hello, {% if username %}{{ username }}{% endif %}!</h3>
|
||||||
|
<p>You are seeing this message, because you received notification from <a href="https://akarpov.ru">akarpov.ru</a></p>
|
||||||
|
|
||||||
|
<p>{{ body }}</p>
|
||||||
|
|
||||||
|
<p>If you don't want to receive notifications via email, or got it by excitement <a href="{# TODO: add unsubscribe #}">press here</a></p>
|
|
@ -56,7 +56,7 @@
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if request.user.is_authenticated and is_folder_owner %}
|
{% if request.user.is_authenticated and is_folder_owner and not folder_slug %}
|
||||||
<div class="d-flex justify-content-end me-5 col">
|
<div class="d-flex justify-content-end me-5 col">
|
||||||
<a class="me-5" href="{% url 'files:table' %}">table view</a>
|
<a class="me-5" href="{% url 'files:table' %}">table view</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,11 +26,11 @@
|
||||||
|
|
||||||
{% block inline_javascript %}
|
{% block inline_javascript %}
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
var md5 = "",
|
let md5 = "",
|
||||||
csrf = $("input[name='csrfmiddlewaretoken']")[0].value,
|
csrf = $("input[name='csrfmiddlewaretoken']")[0].value,
|
||||||
form_data = [{"name": "csrfmiddlewaretoken", "value": csrf}];
|
form_data = [{"name": "csrfmiddlewaretoken", "value": csrf}];
|
||||||
function calculate_md5(file, chunk_size) {
|
function calculate_md5(file, chunk_size) {
|
||||||
var slice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
|
let slice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
|
||||||
chunks = chunks = Math.ceil(file.size / chunk_size),
|
chunks = chunks = Math.ceil(file.size / chunk_size),
|
||||||
current_chunk = 0,
|
current_chunk = 0,
|
||||||
spark = new SparkMD5.ArrayBuffer();
|
spark = new SparkMD5.ArrayBuffer();
|
||||||
|
@ -44,9 +44,9 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
function read_next_chunk() {
|
function read_next_chunk() {
|
||||||
var reader = new FileReader();
|
let reader = new FileReader();
|
||||||
reader.onload = onload;
|
reader.onload = onload;
|
||||||
var start = current_chunk * chunk_size,
|
let start = current_chunk * chunk_size,
|
||||||
end = Math.min(start + chunk_size, file.size);
|
end = Math.min(start + chunk_size, file.size);
|
||||||
reader.readAsArrayBuffer(slice.call(file, start, end));
|
reader.readAsArrayBuffer(slice.call(file, start, end));
|
||||||
};
|
};
|
||||||
|
@ -71,7 +71,7 @@
|
||||||
{"name": "upload_id", "value": data.result.upload_id}
|
{"name": "upload_id", "value": data.result.upload_id}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
var progress = parseInt(data.loaded / data.total * 100.0, 10);
|
let progress = parseInt(data.loaded / data.total * 100.0, 10);
|
||||||
$("#progress").text(Array(progress).join("=") + "> " + progress + "%");
|
$("#progress").text(Array(progress).join("=") + "> " + progress + "%");
|
||||||
},
|
},
|
||||||
done: function (e, data) { // Called when the file has completely uploaded
|
done: function (e, data) { // Called when the file has completely uploaded
|
||||||
|
|
19
akarpov/templates/music/player.html
Normal file
19
akarpov/templates/music/player.html
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
{% block css %}
|
||||||
|
<link rel="stylesheet" href="{% static 'css/music-player.css' %}">
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center justify-content-center">
|
||||||
|
<div class="simple-audio-player flex-column" id="simp" data-config='{"shide_top":false,"shide_btm":false,"auto_load":true}'>
|
||||||
|
<div class="simp-playlist">
|
||||||
|
<ul>
|
||||||
|
{% for song in song_list %}
|
||||||
|
<li><span class="simp-source" {% if song.image %}data-cover="{{ song.image.url }}"{% endif %} data-artists="{{ song.artists_names }}" data-albumn="{{ song.album_name }}" data-src="{{ song.file.url }}">{{ song.name }}</span><span class="simp-desc">{{ song.full_props }}</span></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="{% static 'js/music-player.js' %}"></script>
|
||||||
|
{% endblock content %}
|
|
@ -2,7 +2,7 @@
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
{% block title %}editing post on akarpov{% endblock %}
|
{% block title %}loading music on akarpov{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<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">
|
||||||
|
|
16
akarpov/templates/users/themes/create.html
Normal file
16
akarpov/templates/users/themes/create.html
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}Create Theme{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form class="form-horizontal" enctype="multipart/form-data" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form|crispy }}
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="controls">
|
||||||
|
<button type="submit" class="btn btn-primary">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
|
@ -8,10 +8,34 @@
|
||||||
<form class="form-horizontal" enctype="multipart/form-data" method="post" action="{% url 'users:update' %}">
|
<form class="form-horizontal" enctype="multipart/form-data" method="post" action="{% url 'users:update' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form|crispy }}
|
{{ form|crispy }}
|
||||||
|
{# Themes block #}
|
||||||
|
<p class="mt-3 ml-3">Theme:</p>
|
||||||
|
<div class="row">
|
||||||
|
<label class="col-6 col-sm-4 col-md-3 gl-mb-5 text-center">
|
||||||
|
<div style="background-color: white; height: 48px; border-radius: 4px; min-width: 112px; margin-bottom: 8px;"></div>
|
||||||
|
<input {% if not request.user.theme %}checked{% endif %} type="radio" value="0" name="theme" id="user_theme_id_0">
|
||||||
|
Default
|
||||||
|
</label>
|
||||||
|
{% for theme in themes %}
|
||||||
|
<label class="col-6 col-sm-4 col-md-3 gl-mb-5 text-center">
|
||||||
|
<div style="background-color: {{ theme.color }}; height: 48px; border-radius: 4px; min-width: 112px; margin-bottom: 8px;"></div>
|
||||||
|
<input {% if request.user.theme_id == theme.id %}checked{% endif %} type="radio" value="{{ theme.id }}" name="theme" id="user_theme_id_{{ theme.id }}">
|
||||||
|
{{ theme.name }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
{% if request.user.is_superuser %}
|
||||||
|
<label class="col-6 col-sm-4 col-md-3 gl-mb-5 text-center">
|
||||||
|
<div style="background-color: white; height: 48px; border-radius: 4px; min-width: 112px; margin-bottom: 8px;"></div>
|
||||||
|
<a href="{% url 'users:themes:create' %}">Create new</a>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button type="submit" class="btn btn-primary">Update</button>
|
<button type="submit" class="btn btn-primary">Update</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
6
akarpov/tools/api/serializers.py
Normal file
6
akarpov/tools/api/serializers.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class URLPathSerializer(serializers.Serializer):
|
||||||
|
path = serializers.URLField()
|
||||||
|
kwargs = serializers.DictField(help_text="{'slug': 'str', 'pk': 'int'}")
|
68
akarpov/tools/api/services.py
Normal file
68
akarpov/tools/api/services.py
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from config import urls as urls_conf
|
||||||
|
|
||||||
|
urls = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_urls(urllist, name="") -> (list, list):
|
||||||
|
res = []
|
||||||
|
res_short = []
|
||||||
|
for entry in urllist:
|
||||||
|
if hasattr(entry, "url_patterns"):
|
||||||
|
if entry.namespace != "admin":
|
||||||
|
rres, rres_short = get_urls(
|
||||||
|
entry.url_patterns,
|
||||||
|
name + entry.namespace + ":" if entry.namespace else name,
|
||||||
|
)
|
||||||
|
res += rres
|
||||||
|
res_short += rres_short
|
||||||
|
else:
|
||||||
|
res.append(
|
||||||
|
(
|
||||||
|
name + entry.pattern.name if entry.pattern.name else "",
|
||||||
|
str(entry.pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
res_short.append(
|
||||||
|
(
|
||||||
|
entry.pattern.name,
|
||||||
|
str(entry.pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return res, res_short
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def urlpattern_to_js(pattern: str) -> (str, dict):
|
||||||
|
if pattern.startswith("^"):
|
||||||
|
return pattern
|
||||||
|
res = ""
|
||||||
|
kwargs = {}
|
||||||
|
for p in pattern.split("<"):
|
||||||
|
if ">" in p:
|
||||||
|
rec = ""
|
||||||
|
pn = p.split(">")
|
||||||
|
k = pn[0].split(":")
|
||||||
|
if len(k) == 1:
|
||||||
|
rec = "{" + k[0] + "}"
|
||||||
|
kwargs[k[0]] = "any"
|
||||||
|
elif len(k) == 2:
|
||||||
|
rec = "{" + k[1] + "}"
|
||||||
|
kwargs[k[1]] = k[0]
|
||||||
|
res += rec + pn[-1]
|
||||||
|
else:
|
||||||
|
res += p
|
||||||
|
|
||||||
|
return res, kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_path_by_url(name: str) -> tuple[str, dict] | None:
|
||||||
|
global urls
|
||||||
|
if not urls:
|
||||||
|
urls, urls_short = get_urls(urls_conf.urlpatterns)
|
||||||
|
urls = dict(urls_short) | dict(urls)
|
||||||
|
|
||||||
|
if name in urls:
|
||||||
|
return urlpattern_to_js(urls[name])
|
||||||
|
return None
|
|
@ -1,7 +1,10 @@
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
|
from akarpov.tools.api.views import RetrieveAPIUrlAPIView
|
||||||
|
|
||||||
app_name = "tools"
|
app_name = "tools"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("<str:path>", RetrieveAPIUrlAPIView.as_view(), name="path"),
|
||||||
path("qr/", include("akarpov.tools.qr.api.urls", namespace="qr")),
|
path("qr/", include("akarpov.tools.qr.api.urls", namespace="qr")),
|
||||||
]
|
]
|
||||||
|
|
18
akarpov/tools/api/views.py
Normal file
18
akarpov/tools/api/views.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
from rest_framework import generics
|
||||||
|
from rest_framework.exceptions import NotFound
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from akarpov.tools.api.serializers import URLPathSerializer
|
||||||
|
from akarpov.tools.api.services import get_api_path_by_url
|
||||||
|
|
||||||
|
|
||||||
|
class RetrieveAPIUrlAPIView(generics.GenericAPIView):
|
||||||
|
serializer_class = URLPathSerializer
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
path, k_args = get_api_path_by_url(self.kwargs["path"])
|
||||||
|
if not path:
|
||||||
|
raise NotFound
|
||||||
|
return Response(data={"path": path, "kwargs": k_args})
|
|
@ -25,7 +25,7 @@ def validate_token(self, token):
|
||||||
|
|
||||||
class UserPublicInfoSerializer(serializers.ModelSerializer):
|
class UserPublicInfoSerializer(serializers.ModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(
|
url = serializers.HyperlinkedIdentityField(
|
||||||
view_name="api:users:user_retrieve_username_api", lookup_field="username"
|
view_name="api:users:get", lookup_field="username"
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -57,3 +57,24 @@ class Meta:
|
||||||
"is_staff": {"read_only": True},
|
"is_staff": {"read_only": True},
|
||||||
"is_superuser": {"read_only": True},
|
"is_superuser": {"read_only": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdatePassword(serializers.ModelSerializer):
|
||||||
|
old_password = serializers.CharField(write_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ("old_password", "password")
|
||||||
|
extra_kwargs = {
|
||||||
|
"password": {"write_only": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_old_password(self, password: str):
|
||||||
|
if not self.instance.check_password(password):
|
||||||
|
raise serializers.ValidationError("Old password is incorrect")
|
||||||
|
return password
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
instance.set_password(validated_data["password"])
|
||||||
|
instance.save(update_fields=["password"])
|
||||||
|
return instance
|
||||||
|
|
|
@ -1,30 +1,36 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
UserListViewSet,
|
UserListAPIViewSet,
|
||||||
UserRetireUpdateSelfViewSet,
|
UserRetireUpdateSelfAPIViewSet,
|
||||||
UserRetrieveIdViewSet,
|
UserRetrieveAPIViewSet,
|
||||||
UserRetrieveViewSet,
|
UserRetrieveIdAPIAPIView,
|
||||||
|
UserUpdatePasswordAPIView,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = "users_api"
|
app_name = "users_api"
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", UserListViewSet.as_view(), name="list"),
|
path("", UserListAPIViewSet.as_view(), name="list"),
|
||||||
path(
|
path(
|
||||||
"self/",
|
"self/",
|
||||||
UserRetireUpdateSelfViewSet.as_view(),
|
UserRetireUpdateSelfAPIViewSet.as_view(),
|
||||||
name="self",
|
name="self",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"self/password",
|
||||||
|
UserUpdatePasswordAPIView.as_view(),
|
||||||
|
name="password",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"id/<int:pk>",
|
"id/<int:pk>",
|
||||||
UserRetrieveIdViewSet.as_view(),
|
UserRetrieveIdAPIAPIView.as_view(),
|
||||||
name="get_by_id",
|
name="get_by_id",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"<str:username>",
|
"<str:username>",
|
||||||
UserRetrieveViewSet.as_view(),
|
UserRetrieveAPIViewSet.as_view(),
|
||||||
name="get",
|
name="get",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,19 +1,22 @@
|
||||||
|
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.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from akarpov.common.api import SmallResultsSetPagination
|
from akarpov.common.api import SmallResultsSetPagination
|
||||||
|
from akarpov.common.jwt import sign_jwt
|
||||||
from akarpov.users.api.serializers import (
|
from akarpov.users.api.serializers import (
|
||||||
UserEmailVerification,
|
UserEmailVerification,
|
||||||
UserFullPublicInfoSerializer,
|
UserFullPublicInfoSerializer,
|
||||||
UserFullSerializer,
|
UserFullSerializer,
|
||||||
UserPublicInfoSerializer,
|
UserPublicInfoSerializer,
|
||||||
UserRegisterSerializer,
|
UserRegisterSerializer,
|
||||||
|
UserUpdatePassword,
|
||||||
)
|
)
|
||||||
from akarpov.users.models import User
|
from akarpov.users.models import User
|
||||||
|
|
||||||
|
|
||||||
class UserRegisterViewSet(generics.CreateAPIView):
|
class UserRegisterAPIViewSet(generics.CreateAPIView):
|
||||||
"""Creates new user and sends verification email"""
|
"""Creates new user and sends verification email"""
|
||||||
|
|
||||||
serializer_class = UserRegisterSerializer
|
serializer_class = UserRegisterSerializer
|
||||||
|
@ -26,7 +29,15 @@ def post(self, request, *args, **kwargs):
|
||||||
return self.create(request, *args, **kwargs)
|
return self.create(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class UserEmailValidationViewSet(views.APIView):
|
class GenerateUserJWTTokenAPIView(generics.GenericAPIView):
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(responses={200: OpenApiTypes.STR})
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
return Response(data=sign_jwt(data={"id": self.request.user.id}))
|
||||||
|
|
||||||
|
|
||||||
|
class UserEmailValidationAPIViewSet(views.APIView):
|
||||||
"""Receives token from email and activates user"""
|
"""Receives token from email and activates user"""
|
||||||
|
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
@ -43,7 +54,7 @@ def post(self, request):
|
||||||
return Response(status=status.HTTP_200_OK)
|
return Response(status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class UserListViewSet(generics.ListAPIView):
|
class UserListAPIViewSet(generics.ListAPIView):
|
||||||
serializer_class = UserPublicInfoSerializer
|
serializer_class = UserPublicInfoSerializer
|
||||||
pagination_class = SmallResultsSetPagination
|
pagination_class = SmallResultsSetPagination
|
||||||
|
|
||||||
|
@ -54,7 +65,7 @@ def get(self, request, *args, **kwargs):
|
||||||
return self.list(request, *args, **kwargs)
|
return self.list(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class UserRetrieveViewSet(generics.RetrieveAPIView):
|
class UserRetrieveAPIViewSet(generics.RetrieveAPIView):
|
||||||
"""Returns user's instance on username"""
|
"""Returns user's instance on username"""
|
||||||
|
|
||||||
serializer_class = UserFullPublicInfoSerializer
|
serializer_class = UserFullPublicInfoSerializer
|
||||||
|
@ -70,7 +81,7 @@ def get(self, request, *args, **kwargs):
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class UserRetrieveIdViewSet(UserRetrieveViewSet):
|
class UserRetrieveIdAPIAPIView(UserRetrieveAPIViewSet):
|
||||||
"""Returns user's instance on user's id"""
|
"""Returns user's instance on user's id"""
|
||||||
|
|
||||||
lookup_field = "pk"
|
lookup_field = "pk"
|
||||||
|
@ -82,8 +93,15 @@ def get(self, request, *args, **kwargs):
|
||||||
return self.retrieve(request, *args, **kwargs)
|
return self.retrieve(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class UserRetireUpdateSelfViewSet(generics.RetrieveUpdateDestroyAPIView):
|
class UserRetireUpdateSelfAPIViewSet(generics.RetrieveUpdateDestroyAPIView):
|
||||||
serializer_class = UserFullSerializer
|
serializer_class = UserFullSerializer
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
return self.request.user
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdatePasswordAPIView(generics.UpdateAPIView):
|
||||||
|
serializer_class = UserUpdatePassword
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.request.user
|
||||||
|
|
23
akarpov/users/migrations/0012_user_theme.py
Normal file
23
akarpov/users/migrations/0012_user_theme.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 4.2.6 on 2023-10-25 06:37
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("themes", "0001_initial"),
|
||||||
|
("users", "0011_alter_userhistory_options_userhistory_created"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="theme",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="themes.theme",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -27,6 +27,7 @@ class User(AbstractUser, BaseImageModel, ShortLinkModel):
|
||||||
left_file_upload = models.BigIntegerField(
|
left_file_upload = models.BigIntegerField(
|
||||||
"Left file upload(in bites)", default=0, validators=[MinValueValidator(0)]
|
"Left file upload(in bites)", default=0, validators=[MinValueValidator(0)]
|
||||||
)
|
)
|
||||||
|
theme = models.ForeignKey("themes.Theme", null=True, on_delete=models.SET_NULL)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
"""Get url for user's detail view.
|
"""Get url for user's detail view.
|
||||||
|
|
|
@ -0,0 +1,101 @@
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from pytest_lambda import lambda_fixture, static_fixture
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
|
||||||
|
class TestChangePassword:
|
||||||
|
url = static_fixture(reverse_lazy("api:users:password"))
|
||||||
|
new_password = static_fixture("P@ssw0rd123")
|
||||||
|
user = lambda_fixture(lambda user_factory: user_factory(password="P@ssw0rd"))
|
||||||
|
|
||||||
|
def test_ok(self, api_user_client, url, new_password, user):
|
||||||
|
response = api_user_client.put(
|
||||||
|
url, {"old_password": "P@ssw0rd", "password": new_password}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
user.refresh_from_db()
|
||||||
|
assert user.check_password(new_password)
|
||||||
|
|
||||||
|
def test_return_err_if_data_is_invalid(
|
||||||
|
self, api_user_client, url, new_password, user
|
||||||
|
):
|
||||||
|
response = api_user_client.put(
|
||||||
|
url, {"old_password": "123456", "password": new_password}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
user.refresh_from_db()
|
||||||
|
assert not user.check_password(new_password)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserListRetrieve:
|
||||||
|
url = static_fixture(reverse_lazy("api:users:list"))
|
||||||
|
url_retrieve = static_fixture(
|
||||||
|
reverse_lazy("api:users:get", kwargs={"username": "TestUser"})
|
||||||
|
)
|
||||||
|
user = lambda_fixture(
|
||||||
|
lambda user_factory: user_factory(password="P@ssw0rd", username="TestUser")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_user_list_site_users(self, api_user_client, url, user):
|
||||||
|
response = api_user_client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.json()["count"] == 1
|
||||||
|
assert response.json()["results"][0]["username"] == user.username
|
||||||
|
|
||||||
|
def test_user_retrieve_by_username(self, api_user_client, url_retrieve, user):
|
||||||
|
response = api_user_client.get(url_retrieve)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.json()["username"] == user.username
|
||||||
|
assert response.json()["id"] == user.id
|
||||||
|
|
||||||
|
def test_user_retrieve_by_id(self, api_user_client, user):
|
||||||
|
response = api_user_client.get(
|
||||||
|
reverse_lazy("api:users:get_by_id", kwargs={"pk": user.id})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.json()["username"] == user.username
|
||||||
|
assert response.json()["id"] == user.id
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserSelfRetrieve:
|
||||||
|
url = static_fixture(reverse_lazy("api:users:self"))
|
||||||
|
user = lambda_fixture(lambda user_factory: user_factory(password="P@ssw0rd"))
|
||||||
|
|
||||||
|
def test_user_self_retrieve(self, api_user_client, url, user):
|
||||||
|
response = api_user_client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.json()["username"] == user.username
|
||||||
|
assert response.json()["id"] == user.id
|
||||||
|
|
||||||
|
def test_user_self_update_put(self, api_user_client, url, user):
|
||||||
|
response = api_user_client.put(url, {"username": "NewUsername"})
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.json()["username"] == "NewUsername"
|
||||||
|
assert response.json()["id"] == user.id
|
||||||
|
user.refresh_from_db()
|
||||||
|
assert user.username == "NewUsername"
|
||||||
|
|
||||||
|
def test_user_self_update_patch(self, api_user_client, url, user):
|
||||||
|
response = api_user_client.patch(url, {"username": "NewUsername"})
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.json()["username"] == "NewUsername"
|
||||||
|
assert response.json()["id"] == user.id
|
||||||
|
user.refresh_from_db()
|
||||||
|
assert user.username == "NewUsername"
|
||||||
|
|
||||||
|
def test_user_self_delete(self, api_user_client, url, user):
|
||||||
|
response = api_user_client.delete(url)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||||
|
assert response.content == b""
|
||||||
|
with pytest.raises(user.DoesNotExist):
|
||||||
|
user.refresh_from_db()
|
0
akarpov/users/themes/__init__.py
Normal file
0
akarpov/users/themes/__init__.py
Normal file
8
akarpov/users/themes/admin.py
Normal file
8
akarpov/users/themes/admin.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from akarpov.users.themes.models import Theme
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Theme)
|
||||||
|
class ThemeAdmin(admin.ModelAdmin):
|
||||||
|
...
|
6
akarpov/users/themes/apps.py
Normal file
6
akarpov/users/themes/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ThemesConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "akarpov.users.themes"
|
9
akarpov/users/themes/forms.py
Normal file
9
akarpov/users/themes/forms.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
from akarpov.users.themes.models import Theme
|
||||||
|
|
||||||
|
|
||||||
|
class ThemeForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Theme
|
||||||
|
fields = ["name", "file", "color"]
|
35
akarpov/users/themes/migrations/0001_initial.py
Normal file
35
akarpov/users/themes/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
# Generated by Django 4.2.6 on 2023-10-25 06:37
|
||||||
|
|
||||||
|
import colorfield.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Theme",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=250)),
|
||||||
|
("file", models.FileField(upload_to="themes/")),
|
||||||
|
(
|
||||||
|
"color",
|
||||||
|
colorfield.fields.ColorField(
|
||||||
|
default="#FFFFFF", image_field=None, max_length=18, samples=None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
0
akarpov/users/themes/migrations/__init__.py
Normal file
0
akarpov/users/themes/migrations/__init__.py
Normal file
11
akarpov/users/themes/models.py
Normal file
11
akarpov/users/themes/models.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
from colorfield.fields import ColorField
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Theme(models.Model):
|
||||||
|
name = models.CharField(max_length=250)
|
||||||
|
file = models.FileField(upload_to="themes/")
|
||||||
|
color = ColorField()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
0
akarpov/users/themes/tests.py
Normal file
0
akarpov/users/themes/tests.py
Normal file
8
akarpov/users/themes/urls.py
Normal file
8
akarpov/users/themes/urls.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from akarpov.users.themes.views import CreateFormView
|
||||||
|
|
||||||
|
app_name = "themes"
|
||||||
|
urlpatterns = [
|
||||||
|
path("create", CreateFormView.as_view(), name="create"),
|
||||||
|
]
|
13
akarpov/users/themes/views.py
Normal file
13
akarpov/users/themes/views.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
from django.views import generic
|
||||||
|
|
||||||
|
from akarpov.common.views import SuperUserRequiredMixin
|
||||||
|
from akarpov.users.themes.models import Theme
|
||||||
|
|
||||||
|
|
||||||
|
class CreateFormView(generic.CreateView, SuperUserRequiredMixin):
|
||||||
|
model = Theme
|
||||||
|
fields = ["name", "file", "color"]
|
||||||
|
template_name = "users/themes/create.html"
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return ""
|
|
@ -1,4 +1,4 @@
|
||||||
from django.urls import path
|
from django.urls import include, path
|
||||||
|
|
||||||
from akarpov.users.views import (
|
from akarpov.users.views import (
|
||||||
user_detail_view,
|
user_detail_view,
|
||||||
|
@ -11,6 +11,7 @@
|
||||||
app_name = "users"
|
app_name = "users"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("redirect/", view=user_redirect_view, name="redirect"),
|
path("redirect/", view=user_redirect_view, name="redirect"),
|
||||||
|
path("themes/", include("akarpov.users.themes.urls", namespace="themes")),
|
||||||
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"),
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
from akarpov.users.models import 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.themes.models import Theme
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
@ -26,11 +27,24 @@ class UserUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
|
||||||
success_message = _("Information successfully updated")
|
success_message = _("Information successfully updated")
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
assert (
|
|
||||||
self.request.user.is_authenticated
|
|
||||||
) # for mypy to know that the user is authenticated
|
|
||||||
return self.request.user.get_absolute_url()
|
return self.request.user.get_absolute_url()
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
kwargs["themes"] = Theme.objects.all()
|
||||||
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
data = self.request.POST
|
||||||
|
if "theme" in data:
|
||||||
|
if data["theme"] == "0":
|
||||||
|
self.object.theme = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self.object.theme = Theme.objects.get(id=data["theme"])
|
||||||
|
except Theme.DoesNotExist:
|
||||||
|
...
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
return self.request.user
|
return self.request.user
|
||||||
|
|
||||||
|
|
|
@ -60,11 +60,14 @@ def crop_image(image_path: str, length: int = 500):
|
||||||
def user_file_upload_mixin(instance, filename):
|
def user_file_upload_mixin(instance, filename):
|
||||||
"""stores user uploaded files at their folder in media dir"""
|
"""stores user uploaded files at their folder in media dir"""
|
||||||
username = ""
|
username = ""
|
||||||
if isinstance(instance, get_user_model()):
|
try:
|
||||||
username = instance.username + "/"
|
if isinstance(instance, get_user_model()):
|
||||||
elif hasattr(instance, "user"):
|
username = instance.username + "/"
|
||||||
username = instance.user.username + "/"
|
elif hasattr(instance, "user"):
|
||||||
elif hasattr(instance, "creator"):
|
username = instance.user.username + "/"
|
||||||
username = instance.creator.username + "/"
|
elif hasattr(instance, "creator"):
|
||||||
|
username = instance.creator.username + "/"
|
||||||
|
except AttributeError:
|
||||||
|
username = "__all"
|
||||||
|
|
||||||
return os.path.join(f"uploads/{username}", filename)
|
return os.path.join(f"uploads/{username}", filename)
|
||||||
|
|
|
@ -25,9 +25,11 @@ WORKDIR ${APP_HOME}
|
||||||
|
|
||||||
# Install required system dependencies
|
# Install required system dependencies
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y build-essential libpq-dev gettext libmagic-dev libjpeg-dev zlib1g-dev && \
|
apt-get install -y build-essential libpq-dev gettext libmagic-dev libjpeg-dev zlib1g-dev && \
|
||||||
# Dependencies for file preview generation
|
# Dependencies for file preview generation
|
||||||
apt-get install -y webp libimage-exiftool-perl libmagickwand-dev ffmpeg libgdal-dev && \
|
apt-get install -y webp libimage-exiftool-perl libmagickwand-dev ffmpeg libgdal-dev && \
|
||||||
|
# ML dependencies \
|
||||||
|
# none for now
|
||||||
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/*
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@ ARG APP_HOME=/app
|
||||||
ENV PYTHONUNBUFFERED 1
|
ENV PYTHONUNBUFFERED 1
|
||||||
ENV PYTHONDONTWRITEBYTECODE 1
|
ENV PYTHONDONTWRITEBYTECODE 1
|
||||||
ENV BUILD_ENV ${BUILD_ENVIRONMENT}
|
ENV BUILD_ENV ${BUILD_ENVIRONMENT}
|
||||||
|
ENV POETRY_VERSION 1.4.2
|
||||||
|
|
||||||
WORKDIR ${APP_HOME}
|
WORKDIR ${APP_HOME}
|
||||||
|
|
||||||
|
@ -34,14 +35,12 @@ RUN addgroup --system django \
|
||||||
|
|
||||||
|
|
||||||
# Install required system dependencies
|
# Install required system dependencies
|
||||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
RUN apt-get update && \
|
||||||
# psycopg2 dependencies
|
apt-get install -y build-essential libpq-dev gettext libmagic-dev libjpeg-dev zlib1g-dev && \
|
||||||
libpq-dev \
|
# Dependencies for file preview generation
|
||||||
# Translations dependencies
|
apt-get install -y webp libimage-exiftool-perl libmagickwand-dev ffmpeg libgdal-dev && \
|
||||||
gettext \
|
apt-get purge -y --auto-remove -o APT:AutoRemove:RecommendsImportant=false && \
|
||||||
# cleaning up unused files
|
rm -rf /var/lib/apt/lists/*
|
||||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
|
|
||||||
RUN pip install "poetry==$POETRY_VERSION"
|
RUN pip install "poetry==$POETRY_VERSION"
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
FROM postgres:14
|
FROM postgres:14
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y postgresql-14-pgvector && \
|
||||||
|
apt-get purge -y --auto-remove -o APT:AutoRemove:RecommendsImportant=false && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY ./compose/production/postgres/maintenance /usr/local/bin/maintenance
|
COPY ./compose/production/postgres/maintenance /usr/local/bin/maintenance
|
||||||
RUN chmod +x /usr/local/bin/maintenance/*
|
RUN chmod +x /usr/local/bin/maintenance/*
|
||||||
RUN mv /usr/local/bin/maintenance/* /usr/local/bin \
|
RUN mv /usr/local/bin/maintenance/* /usr/local/bin \
|
||||||
|
|
|
@ -11,10 +11,6 @@ entryPoints:
|
||||||
entryPoint:
|
entryPoint:
|
||||||
to: web-secure
|
to: web-secure
|
||||||
|
|
||||||
web-secure:
|
|
||||||
# https
|
|
||||||
address: ":443"
|
|
||||||
|
|
||||||
flower:
|
flower:
|
||||||
address: ":5555"
|
address: ":5555"
|
||||||
|
|
||||||
|
@ -29,27 +25,6 @@ certificatesResolvers:
|
||||||
entryPoint: web
|
entryPoint: web
|
||||||
|
|
||||||
http:
|
http:
|
||||||
routers:
|
|
||||||
web-secure-router:
|
|
||||||
rule: "Host(`akarpov.ru`) || Host(`www.akarpov.ru`)"
|
|
||||||
entryPoints:
|
|
||||||
- web-secure
|
|
||||||
middlewares:
|
|
||||||
- csrf
|
|
||||||
service: django
|
|
||||||
tls:
|
|
||||||
# https://docs.traefik.io/master/routing/routers/#certresolver
|
|
||||||
certResolver: letsencrypt
|
|
||||||
|
|
||||||
flower-secure-router:
|
|
||||||
rule: "Host(`akarpov.ru`)"
|
|
||||||
entryPoints:
|
|
||||||
- flower
|
|
||||||
service: flower
|
|
||||||
tls:
|
|
||||||
# https://docs.traefik.io/master/routing/routers/#certresolver
|
|
||||||
certResolver: letsencrypt
|
|
||||||
|
|
||||||
middlewares:
|
middlewares:
|
||||||
csrf:
|
csrf:
|
||||||
# https://docs.traefik.io/master/middlewares/headers/#hostsproxyheaders
|
# https://docs.traefik.io/master/middlewares/headers/#hostsproxyheaders
|
||||||
|
@ -63,6 +38,11 @@ http:
|
||||||
servers:
|
servers:
|
||||||
- url: http://django:5000
|
- url: http://django:5000
|
||||||
|
|
||||||
|
redirect:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: http://redirect:3000
|
||||||
|
|
||||||
flower:
|
flower:
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
servers:
|
servers:
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from rest_framework.authtoken.views import obtain_auth_token
|
from rest_framework.authtoken.views import obtain_auth_token
|
||||||
|
|
||||||
from akarpov.users.api.views import UserRegisterViewSet
|
from akarpov.users.api.views import GenerateUserJWTTokenAPIView, UserRegisterAPIViewSet
|
||||||
|
|
||||||
app_name = "api"
|
app_name = "api"
|
||||||
|
|
||||||
|
@ -11,9 +11,12 @@
|
||||||
include(
|
include(
|
||||||
[
|
[
|
||||||
path(
|
path(
|
||||||
"register/", UserRegisterViewSet.as_view(), name="user_register_api"
|
"register/",
|
||||||
|
UserRegisterAPIViewSet.as_view(),
|
||||||
|
name="user_register_api",
|
||||||
),
|
),
|
||||||
path("token/", obtain_auth_token),
|
path("token/", obtain_auth_token),
|
||||||
|
path("jwt/", GenerateUserJWTTokenAPIView.as_view()),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -21,10 +24,18 @@
|
||||||
"users/",
|
"users/",
|
||||||
include("akarpov.users.api.urls", namespace="users"),
|
include("akarpov.users.api.urls", namespace="users"),
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"notifications/",
|
||||||
|
include("akarpov.notifications.providers.urls", namespace="notifications"),
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"blog/",
|
"blog/",
|
||||||
include("akarpov.blog.api.urls", namespace="blog"),
|
include("akarpov.blog.api.urls", namespace="blog"),
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"music/",
|
||||||
|
include("akarpov.music.api.urls", namespace="music"),
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"tools/",
|
"tools/",
|
||||||
include(
|
include(
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
from django.urls import re_path
|
from django.urls import re_path
|
||||||
|
|
||||||
from akarpov.music.consumers import RadioConsumer
|
from akarpov.music.consumers import RadioConsumer
|
||||||
|
from akarpov.notifications.providers.site.consumers import NotificationsConsumer
|
||||||
|
|
||||||
websocket_urlpatterns = [
|
websocket_urlpatterns = [
|
||||||
re_path(r"ws/radio/", RadioConsumer.as_asgi()),
|
re_path(r"ws/radio/", RadioConsumer.as_asgi()),
|
||||||
|
re_path(r"ws/notifications/", NotificationsConsumer.as_asgi()),
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import environ
|
import environ
|
||||||
import structlog
|
import structlog
|
||||||
|
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent
|
ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent
|
||||||
# akarpov/
|
# akarpov/
|
||||||
|
@ -81,7 +82,7 @@
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||||
"CONFIG": {
|
"CONFIG": {
|
||||||
"hosts": [("127.0.0.1", 6379)],
|
"hosts": [env("REDIS_URL")],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -155,10 +156,12 @@
|
||||||
"akarpov.files",
|
"akarpov.files",
|
||||||
"akarpov.music",
|
"akarpov.music",
|
||||||
"akarpov.gallery",
|
"akarpov.gallery",
|
||||||
|
"akarpov.tools.qr",
|
||||||
"akarpov.pipeliner",
|
"akarpov.pipeliner",
|
||||||
|
"akarpov.users.themes",
|
||||||
|
"akarpov.notifications",
|
||||||
"akarpov.test_platform",
|
"akarpov.test_platform",
|
||||||
"akarpov.tools.shortener",
|
"akarpov.tools.shortener",
|
||||||
"akarpov.tools.qr",
|
|
||||||
"akarpov.tools.promocodes",
|
"akarpov.tools.promocodes",
|
||||||
]
|
]
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||||
|
@ -306,6 +309,18 @@
|
||||||
)
|
)
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#email-timeout
|
# https://docs.djangoproject.com/en/dev/ref/settings/#email-timeout
|
||||||
EMAIL_TIMEOUT = 5
|
EMAIL_TIMEOUT = 5
|
||||||
|
EMAIL_HOST_PASSWORD = env(
|
||||||
|
"EMAIL_PASSWORD",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
EMAIL_HOST_USER = env(
|
||||||
|
"EMAIL_USER",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
EMAIL_USE_SSL = env(
|
||||||
|
"EMAIL_USE_SSL",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
# ADMIN
|
# ADMIN
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
@ -469,7 +484,7 @@
|
||||||
"SERVE_INCLUDE_SCHEMA": False,
|
"SERVE_INCLUDE_SCHEMA": False,
|
||||||
"SERVERS": [
|
"SERVERS": [
|
||||||
{"url": "http://127.0.0.1:8000", "description": "Local Development server"},
|
{"url": "http://127.0.0.1:8000", "description": "Local Development server"},
|
||||||
{"url": "https://akarpov.ru", "description": "Production server"},
|
{"url": "https://new.akarpov.ru", "description": "Production server"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -573,5 +588,6 @@
|
||||||
signals_spans=True,
|
signals_spans=True,
|
||||||
cache_spans=True,
|
cache_spans=True,
|
||||||
),
|
),
|
||||||
|
CeleryIntegration(monitor_beat_tasks=True, propagate_traces=True),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
"DJANGO_SECRET_KEY",
|
"DJANGO_SECRET_KEY",
|
||||||
default="Lm4alUqtub6qQT4MnV4NmtXQP02RCBtmGj1bJhyDho07Bkjk9WFZxGtwpnLNQGJQ",
|
default="Lm4alUqtub6qQT4MnV4NmtXQP02RCBtmGj1bJhyDho07Bkjk9WFZxGtwpnLNQGJQ",
|
||||||
)
|
)
|
||||||
|
TOKEN_EXP = 24 * 60 * 60
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
||||||
ALLOWED_HOSTS = ["*"]
|
ALLOWED_HOSTS = ["*"]
|
||||||
CSRF_TRUSTED_ORIGINS = ["http://127.0.0.1", "https://*.akarpov.ru"]
|
CSRF_TRUSTED_ORIGINS = ["http://127.0.0.1", "https://*.akarpov.ru"]
|
||||||
|
@ -20,6 +21,7 @@
|
||||||
EMAIL_HOST = env("EMAIL_HOST", default="mailhog")
|
EMAIL_HOST = env("EMAIL_HOST", default="mailhog")
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#email-port
|
# https://docs.djangoproject.com/en/dev/ref/settings/#email-port
|
||||||
EMAIL_PORT = env("EMAIL_PORT", default="1025")
|
EMAIL_PORT = env("EMAIL_PORT", default="1025")
|
||||||
|
EMAIL_FROM = env("EMAIL_FROM", default="noreply@akarpov.ru")
|
||||||
|
|
||||||
# WhiteNoise
|
# WhiteNoise
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
@ -50,14 +52,9 @@
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration
|
# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration
|
||||||
INSTALLED_APPS += ["django_extensions"] # noqa F405
|
INSTALLED_APPS += ["django_extensions"] # noqa F405
|
||||||
# Celery
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-eager-propagates
|
|
||||||
CELERY_TASK_EAGER_PROPAGATES = True
|
|
||||||
|
|
||||||
|
|
||||||
# SHORTENER
|
# SHORTENER
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
SHORTENER_REDIRECT_TO = "http://127.0.0.1:8000"
|
SHORTENER_REDIRECT_TO = env("SHORTENER_REDIRECT_TO", default="http://127.0.0.1:8000")
|
||||||
SHORTENER_HOST = "http://127.0.0.1:3000"
|
SHORTENER_HOST = env("SHORTENER_HOST", default="http://127.0.0.1:8000")
|
||||||
|
|
4291
poetry.lock
generated
4291
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
|
@ -24,7 +24,6 @@ services:
|
||||||
- ./.envs/.production/.postgres
|
- ./.envs/.production/.postgres
|
||||||
command: /start
|
command: /start
|
||||||
|
|
||||||
|
|
||||||
redirect:
|
redirect:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
@ -35,16 +34,30 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
- redis
|
- redis
|
||||||
- mailhog
|
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app:z
|
- type: bind
|
||||||
|
source: /var/www/media/
|
||||||
|
target: /app/akarpov/media/
|
||||||
env_file:
|
env_file:
|
||||||
- ./.envs/.production/.django
|
- ./.envs/.production/.django
|
||||||
- ./.envs/.production/.postgres
|
- ./.envs/.production/.postgres
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
command: /start-redirect
|
command: /start-redirect
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./compose/production/traefik/Dockerfile
|
||||||
|
image: akarpov_production_traefik
|
||||||
|
depends_on:
|
||||||
|
- django
|
||||||
|
- redirect
|
||||||
|
volumes:
|
||||||
|
- production_traefik:/etc/traefik/acme
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:80:80"
|
||||||
|
- "0.0.0.0:443:443"
|
||||||
|
- "0.0.0.0:3000:3000"
|
||||||
|
- "0.0.0.0:5555:5555"
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
build:
|
build:
|
||||||
|
|
|
@ -11,7 +11,7 @@ pytz = "^2023.3"
|
||||||
psutil = "^5.9.5"
|
psutil = "^5.9.5"
|
||||||
python-slugify = "^7.0.0"
|
python-slugify = "^7.0.0"
|
||||||
pillow = "^10.0.0"
|
pillow = "^10.0.0"
|
||||||
argon2-cffi = "^21.3.0"
|
argon2-cffi = "^23.1.0"
|
||||||
whitenoise = "^6.3.0"
|
whitenoise = "^6.3.0"
|
||||||
redis = "^4.6.0"
|
redis = "^4.6.0"
|
||||||
hiredis = "^2.2.3"
|
hiredis = "^2.2.3"
|
||||||
|
@ -82,7 +82,6 @@ xvfbwrapper = "^0.2.9"
|
||||||
vtk = "^9.2.6"
|
vtk = "^9.2.6"
|
||||||
ffmpeg-python = "^0.2.0"
|
ffmpeg-python = "^0.2.0"
|
||||||
cairosvg = "^2.7.0"
|
cairosvg = "^2.7.0"
|
||||||
textract = "^1.6.5"
|
|
||||||
spotipy = "2.16.1"
|
spotipy = "2.16.1"
|
||||||
django-robots = "^5.0"
|
django-robots = "^5.0"
|
||||||
django-tables2 = "^2.5.3"
|
django-tables2 = "^2.5.3"
|
||||||
|
@ -107,6 +106,9 @@ pytest-xdist = "^3.3.1"
|
||||||
pytest-mock = "^3.11.1"
|
pytest-mock = "^3.11.1"
|
||||||
pytest-asyncio = "^0.21.1"
|
pytest-asyncio = "^0.21.1"
|
||||||
pytest-lambda = "^2.2.0"
|
pytest-lambda = "^2.2.0"
|
||||||
|
pgvector = "^0.2.2"
|
||||||
|
pycld2 = "^0.41"
|
||||||
|
textract = "^1.6.5"
|
||||||
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|
Loading…
Reference in New Issue
Block a user