Compare commits

...

15 Commits

Author SHA1 Message Date
dependabot[bot]
890c8a19d6
Bump argon2-cffi from 21.3.0 to 23.1.0
Bumps [argon2-cffi](https://github.com/hynek/argon2-cffi) from 21.3.0 to 23.1.0.
- [Release notes](https://github.com/hynek/argon2-cffi/releases)
- [Changelog](https://github.com/hynek/argon2-cffi/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/argon2-cffi/compare/21.3.0...23.1.0)

---
updated-dependencies:
- dependency-name: argon2-cffi
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-25 11:04:11 +00:00
Alexander Karpov
29f78393f4
Update README.md 2023-10-25 13:49:02 +03:00
e4bfd5ca07 updated settings 2023-10-25 10:42:08 +03:00
59fc828097 added user's themes, minor fixes 2023-10-25 10:27:55 +03:00
403fb8ffa5 added full tests for user's api 2023-10-16 01:50:33 +03:00
f6f15d3979 added email settings to compose 2023-10-15 23:02:53 +03:00
f59df63dd4 updated requirements, added path api tool 2023-10-13 02:28:51 +03:00
3405b76897 updated music player, added native controls 2023-09-30 16:43:58 +03:00
b02a77ec5e major music loader fixes, added music player 2023-09-29 20:07:14 +03:00
45cd860803 added ml better support, better site notifications 2023-09-26 12:23:00 +03:00
513de19a16 Merge remote-tracking branch 'origin/main' 2023-09-25 20:22:05 +03:00
08198e0535 added file notification on view, minor fixes 2023-09-24 20:29:02 +03:00
8583885960 added notifications, web provider and email provider 2023-09-24 20:28:21 +03:00
Alexander Karpov
0a0714f969
Update README.md 2023-09-12 22:17:52 +03:00
3ef20b5eb9 added user reset password api 2023-09-10 17:38:47 +03:00
98 changed files with 4154 additions and 2343 deletions

View File

@ -6,3 +6,6 @@ USE_DOCKER=no
EMAIL_HOST=127.0.0.1
EMAIL_PORT=1025
SENTRY_DSN=
EMAIL_PASSWORD=
EMAIL_USER=
EMAIL_USE_SSL=false

View File

@ -4,6 +4,9 @@ My collection of apps and tools
Writen in Python 3.11 and Django 4.2
Local upstream mirror:
https://git.akarpov.ru/sanspie/akarpov
## Start up
### installation
@ -50,3 +53,4 @@ $ mypy --config-file setup.cfg akarpov
- short link generator
- about me app
- gallery
- notifications

View File

@ -1,5 +1,8 @@
from rest_framework import serializers
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):
@ -24,3 +27,19 @@ class RecursiveField(serializers.Serializer):
def to_representation(self, value):
serializer = self.parent.parent.__class__(value, context=self.context)
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

View File

@ -1,20 +1,46 @@
from importlib import import_module
from channels.db import database_sync_to_async
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.users.models import User
engine = import_module(settings.SESSION_ENGINE)
sessionstore = engine.SessionStore
@database_sync_to_async
def get_user(headers):
# WARNING headers type is bytes
if b"authorization" not in headers or not headers[b"authorization"]:
return False
if (b"authorization" not in headers or not headers[b"authorization"]) and (
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 = read_jwt(jwt)
payload = {"id": user_id}
else:
payload = {}
if not payload or "id" not in payload:
return False
return None
return payload["id"]
@ -27,7 +53,7 @@ def __init__(self, app):
self.app = app
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:
return await self.app(scope, receive, send)
except ValueError:

View File

@ -3,7 +3,7 @@
import jwt
import pytz
from django.conf import settings
from jwt import ExpiredSignatureError, InvalidSignatureError
from jwt import DecodeError, ExpiredSignatureError, InvalidSignatureError
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:
"""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
try:
payload = jwt.decode(token, key=secret, algorithms=[header_data["alg"]])

View File

51
akarpov/common/ml/text.py Normal file
View 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

View File

@ -6,7 +6,7 @@
class FileForm(forms.ModelForm):
class Meta:
model = File
fields = ["name", "private", "description"]
fields = ["name", "private", "notify_user_on_view", "description"]
class FolderForm(forms.ModelForm):

View 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()]

View File

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

View 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),
),
]

View 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 = []

View 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,
),
]

View File

@ -17,6 +17,7 @@
from django.urls import reverse
from model_utils.fields import AutoCreatedField, AutoLastModifiedField
from model_utils.models import TimeStampedModel
from pgvector.django import VectorField
from polymorphic.models import PolymorphicModel
from akarpov.files.services.files import trash_file_upload, user_unique_file_upload
@ -26,6 +27,7 @@
class BaseFileItem(PolymorphicModel):
parent = ForeignKey(
verbose_name="Folder",
to="files.Folder",
null=True,
blank=True,
@ -68,12 +70,20 @@ class File(BaseFileItem, TimeStampedModel, ShortLinkModel, UserHistoryModel):
preview = FileField(blank=True, upload_to="file/previews/")
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
name = CharField(max_length=255, null=True, blank=True)
description = TextField(blank=True, null=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
def file_name(self):
return self.file.path.split("/")[-1]

View File

@ -1 +1,3 @@
from . import doc, docx, json, odt, pdf, zip # noqa
# TODO: add gzip, xlsx

View File

@ -0,0 +1,7 @@
import textract
def extract_file_text(file: str) -> str:
text = textract.process(file)
return text

View File

@ -34,6 +34,7 @@
from akarpov.files.services.folders import delete_folder
from akarpov.files.services.preview import get_base_meta
from akarpov.files.tables import FileTable
from akarpov.notifications.services import send_notification
logger = structlog.get_logger(__name__)
@ -148,6 +149,16 @@ def dispatch(self, request, *args, **kwargs):
if "bot" in useragent:
if file.file_type and file.file_type.split("/")[0] == "image":
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)
def get_context_data(self, **kwargs):

View File

@ -1,11 +1,15 @@
from drf_spectacular.utils import extend_schema_field
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):
url = serializers.SerializerMethodField(method_name="get_url")
@extend_schema_field(serializers.URLField)
def get_url(self, obj):
return obj.get_absolute_url()
@ -17,6 +21,7 @@ class Meta:
class AlbumSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField(method_name="get_url")
@extend_schema_field(serializers.URLField)
def get_url(self, obj):
return obj.get_absolute_url()
@ -32,7 +37,6 @@ class SongSerializer(serializers.ModelSerializer):
class Meta:
model = Song
fields = [
"id",
"image",
"link",
"length",
@ -42,3 +46,48 @@ class Meta:
"authors",
"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
View 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",
),
]

View 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

View 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,
),
),
]

View 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),
),
]

View File

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

View File

@ -4,6 +4,7 @@
from akarpov.common.models import BaseImageModel
from akarpov.tools.shortener.models import ShortLinkModel
from akarpov.users.services.history import UserHistoryModel
from akarpov.utils.cache import cache_model_property
class Author(BaseImageModel, ShortLinkModel):
@ -38,10 +39,44 @@ class Song(BaseImageModel, ShortLinkModel):
album = models.ForeignKey(
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):
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):
return self.name
@ -80,8 +115,8 @@ class Meta:
class SongInQue(models.Model):
name = models.CharField(blank=True, max_length=250)
status = models.CharField(null=True, blank=True, max_length=250)
name = models.CharField(blank=True, max_length=500)
status = models.CharField(null=True, blank=True, max_length=500)
error = models.BooleanField(default=False)

View File

@ -1,11 +1,11 @@
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("/"):
process_dir.apply_async(kwargs={"path": address})
list_tracks.apply_async(kwargs={"url": address})
process_dir.apply_async(kwargs={"path": address, "user_id": user_id})
list_tracks.apply_async(kwargs={"url": address, "user_id": user_id})
def load_track_file(file):
process_file.apply_async(kwargs={"path": file})
def load_track_file(file, user_id: int):
process_file.apply_async(kwargs={"path": file, "user_id": user_id})

View File

@ -13,6 +13,7 @@
def load_track(
path: str,
image_path: str | None = None,
user_id: int | None = None,
authors: list[str] | str | None = None,
album: str | None = None,
name: str | None = None,
@ -20,12 +21,19 @@ def load_track(
**kwargs,
) -> Song:
p_name = path.split("/")[-1]
if album and type(album) is str and album.startswith("['"):
album = album.replace("['", "").replace("']", "")
if authors:
authors = [Author.objects.get_or_create(name=x)[0] for x in authors if authors]
else:
authors = []
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:
album = None
@ -45,9 +53,11 @@ def load_track(
tag = MP3(path, ID3=ID3)
if image_path:
if not image_path.endswith(".png"):
nm = image_path
im = Image.open(image_path)
image_path = image_path.replace(image_path.split(".")[-1], "png")
im.save(image_path)
os.remove(nm)
song = Song(
link=link if link else "",
@ -56,6 +66,12 @@ def load_track(
album=album,
)
if user_id:
song.user_id = user_id
if kwargs:
song.meta = kwargs
if image_path:
with open(path, "rb") as file, open(image_path, "rb") as image:
song.image = File(image, name=image_path.split("/")[-1])
@ -96,4 +112,10 @@ def load_track(
tag.tags.add(TCON(text=kwargs["genre"]))
tag.save()
if os.path.exists(path):
os.remove(path)
if os.path.exists(image_path):
os.remove(image_path)
return song

View File

@ -11,18 +11,18 @@
from akarpov.music.services.db import load_track
def load_dir(path: str):
def load_dir(path: str, user_id: int):
path = Path(path)
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):
process_mp3_file(path)
def load_file(path: str, user_id: int):
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)
if "artist" in tag:
author = tag["artist"]
@ -55,6 +55,6 @@ def process_mp3_file(path: str) -> None:
im.save(image_pth)
except UnidentifiedImageError:
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):
os.remove(image_pth)

View File

@ -1,18 +1,13 @@
import os
from pathlib import Path
from random import randint
from django.conf import settings
from django.core.files import File
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 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:
@ -48,88 +43,56 @@ def search_ym(name: str):
return info
def load_file_meta(track: int):
def load_file_meta(track: int, user_id: int):
que = SongInQue.objects.create()
client = login()
track = client.tracks(track)[0] # type: Track
que.name = track.title
que.save()
try:
client = login()
track = client.tracks(track)[0] # type: Track
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:
if sng := Song.objects.filter(
name=track.title, album__name=track.albums[0].title
):
que.delete()
return
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)
return sng.first()
except IndexError:
que.delete()
return song
except Exception as e:
que.name = e
que.error = True
que.save()
return
filename = slugify(f"{track.artists[0].name} - {track.title}")
orig_path = f"{settings.MEDIA_ROOT}/{filename}.mp3"
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]
playlist_id = link.split("/")[-1]
client = login()
playlist = client.users_playlists(int(playlist_id), author) # type: Playlist
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}
)

View File

@ -64,7 +64,7 @@ def parse_description(description: str) -> list:
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
with YoutubeDL(ydl_opts) as ydl:
@ -118,6 +118,7 @@ def download_from_youtube_link(link: str) -> Song:
song = load_track(
chapter_path,
f"{img_pth}.png",
user_id,
info["artists"],
info["album_name"],
chapters[i][2],
@ -127,6 +128,7 @@ def download_from_youtube_link(link: str) -> Song:
song = load_track(
chapter_path,
f"{img_pth}.png",
user_id,
info["artists"],
info["album_name"],
chapters[i][2],
@ -152,6 +154,7 @@ def download_from_youtube_link(link: str) -> Song:
song = load_track(
path,
f"{img_pth}.png",
user_id,
info["artists"],
info["album_name"],
title,
@ -161,6 +164,7 @@ def download_from_youtube_link(link: str) -> Song:
song = load_track(
path,
f"{img_pth}.png",
user_id,
info["artists"],
info["album_name"],
title,

View File

@ -11,44 +11,44 @@
@shared_task
def list_tracks(url):
def list_tracks(url, user_id):
if "music.yandex.ru" in url:
yandex.load_playlist(url)
yandex.load_playlist(url, user_id)
elif "channel" in url or "/c/" in url:
p = Channel(url)
for video in p.video_urls:
process_yb.apply_async(kwargs={"url": video})
process_yb.apply_async(kwargs={"url": video, "user_id": user_id})
elif "playlist" in url or "&list=" in url:
p = Playlist(url)
for video in p.video_urls:
process_yb.apply_async(kwargs={"url": video})
process_yb.apply_async(kwargs={"url": video, "user_id": user_id})
else:
process_yb.apply_async(kwargs={"url": url})
process_yb.apply_async(kwargs={"url": url, "user_id": user_id})
return url
@shared_task(max_retries=5)
def process_yb(url):
youtube.download_from_youtube_link(url)
def process_yb(url, user_id):
youtube.download_from_youtube_link(url, user_id)
return url
@shared_task
def process_dir(path):
load_dir(path)
def process_dir(path, user_id):
load_dir(path, user_id)
return path
@shared_task
def process_file(path):
load_file(path)
def process_file(path, user_id):
load_file(path, user_id)
return path
@shared_task
def load_ym_file_meta(track):
return yandex.load_file_meta(track)
def load_ym_file_meta(track, user_id):
return yandex.load_file_meta(track, user_id)
@shared_task()

View File

@ -12,4 +12,5 @@
path("author/<str:slug>", views.author_view, name="author"),
path("playlist/<str:slug>", views.playlist_view, name="playlist"),
path("radio/", views.radio_main_view, name="radio"),
path("player/", views.music_player_view, name="player"),
]

View File

@ -55,7 +55,7 @@ def get_success_url(self):
return ""
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)
@ -73,7 +73,7 @@ def get_success_url(self):
def form_valid(self, form):
for file in form.cleaned_data["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)
@ -86,3 +86,14 @@ class MainRadioView(generic.TemplateView):
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()

View File

View 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"]

View 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

View 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,
},
),
]

View 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

View File

@ -0,0 +1 @@
from .send import send_notification # noqa

View 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

View File

@ -0,0 +1,8 @@
"""
Notifications site provider
meta params:
- user_id: bool, required
- conformation: bool, optional
"""
from .send import send_notification # noqa

View 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"]

View 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

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

View 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

View 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"),
]

View 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")
),
]

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

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

View 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

View File

View File

View 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;}
}

View 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">&#160;/&#160;</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();
}

View File

@ -21,6 +21,11 @@
<!-- Latest compiled and minified Bootstrap CSS -->
<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">
<script src="https://kit.fontawesome.com/32fd82c823.js" crossorigin="anonymous"></script>
<!-- Your stuff: Third-party CSS libraries go here -->
@ -132,6 +137,10 @@
<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>
</footer>
<div id="toastContainer" class="toast-container position-fixed bottom-0 end-0 p-3">
</div>
</div>
</div>
</div>
@ -140,5 +149,87 @@
{% block 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>
</html>

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

View File

@ -56,7 +56,7 @@
</nav>
{% 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">
<a class="me-5" href="{% url 'files:table' %}">table view</a>
</div>

View File

@ -26,11 +26,11 @@
{% block inline_javascript %}
<script type="text/javascript">
var md5 = "",
let md5 = "",
csrf = $("input[name='csrfmiddlewaretoken']")[0].value,
form_data = [{"name": "csrfmiddlewaretoken", "value": csrf}];
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),
current_chunk = 0,
spark = new SparkMD5.ArrayBuffer();
@ -44,9 +44,9 @@
}
};
function read_next_chunk() {
var reader = new FileReader();
let reader = new FileReader();
reader.onload = onload;
var start = current_chunk * chunk_size,
let start = current_chunk * chunk_size,
end = Math.min(start + chunk_size, file.size);
reader.readAsArrayBuffer(slice.call(file, start, end));
};
@ -71,7 +71,7 @@
{"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 + "%");
},
done: function (e, data) { // Called when the file has completely uploaded

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

View File

@ -2,7 +2,7 @@
{% load static %}
{% load crispy_forms_tags %}
{% block title %}editing post on akarpov{% endblock %}
{% block title %}loading music on akarpov{% endblock %}
{% block content %}
<form class="pt-2" enctype="multipart/form-data" method="POST" id="designer-form">

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

View File

@ -8,10 +8,34 @@
<form class="form-horizontal" enctype="multipart/form-data" method="post" action="{% url 'users:update' %}">
{% csrf_token %}
{{ 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="controls">
<button type="submit" class="btn btn-primary">Update</button>
</div>
</div>
</form>
{% endblock %}

View 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'}")

View 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

View File

@ -1,7 +1,10 @@
from django.urls import include, path
from akarpov.tools.api.views import RetrieveAPIUrlAPIView
app_name = "tools"
urlpatterns = [
path("<str:path>", RetrieveAPIUrlAPIView.as_view(), name="path"),
path("qr/", include("akarpov.tools.qr.api.urls", namespace="qr")),
]

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

View File

@ -25,7 +25,7 @@ def validate_token(self, token):
class UserPublicInfoSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name="api:users:user_retrieve_username_api", lookup_field="username"
view_name="api:users:get", lookup_field="username"
)
class Meta:
@ -57,3 +57,24 @@ class Meta:
"is_staff": {"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

View File

@ -1,30 +1,36 @@
from django.urls import path
from .views import (
UserListViewSet,
UserRetireUpdateSelfViewSet,
UserRetrieveIdViewSet,
UserRetrieveViewSet,
UserListAPIViewSet,
UserRetireUpdateSelfAPIViewSet,
UserRetrieveAPIViewSet,
UserRetrieveIdAPIAPIView,
UserUpdatePasswordAPIView,
)
app_name = "users_api"
urlpatterns = [
path("", UserListViewSet.as_view(), name="list"),
path("", UserListAPIViewSet.as_view(), name="list"),
path(
"self/",
UserRetireUpdateSelfViewSet.as_view(),
UserRetireUpdateSelfAPIViewSet.as_view(),
name="self",
),
path(
"self/password",
UserUpdatePasswordAPIView.as_view(),
name="password",
),
path(
"id/<int:pk>",
UserRetrieveIdViewSet.as_view(),
UserRetrieveIdAPIAPIView.as_view(),
name="get_by_id",
),
path(
"<str:username>",
UserRetrieveViewSet.as_view(),
UserRetrieveAPIViewSet.as_view(),
name="get",
),
]

View File

@ -1,19 +1,22 @@
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from rest_framework import generics, permissions, status, views
from rest_framework.response import Response
from akarpov.common.api import SmallResultsSetPagination
from akarpov.common.jwt import sign_jwt
from akarpov.users.api.serializers import (
UserEmailVerification,
UserFullPublicInfoSerializer,
UserFullSerializer,
UserPublicInfoSerializer,
UserRegisterSerializer,
UserUpdatePassword,
)
from akarpov.users.models import User
class UserRegisterViewSet(generics.CreateAPIView):
class UserRegisterAPIViewSet(generics.CreateAPIView):
"""Creates new user and sends verification email"""
serializer_class = UserRegisterSerializer
@ -26,7 +29,15 @@ def post(self, 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"""
permission_classes = [permissions.AllowAny]
@ -43,7 +54,7 @@ def post(self, request):
return Response(status=status.HTTP_200_OK)
class UserListViewSet(generics.ListAPIView):
class UserListAPIViewSet(generics.ListAPIView):
serializer_class = UserPublicInfoSerializer
pagination_class = SmallResultsSetPagination
@ -54,7 +65,7 @@ def get(self, request, *args, **kwargs):
return self.list(request, *args, **kwargs)
class UserRetrieveViewSet(generics.RetrieveAPIView):
class UserRetrieveAPIViewSet(generics.RetrieveAPIView):
"""Returns user's instance on username"""
serializer_class = UserFullPublicInfoSerializer
@ -70,7 +81,7 @@ def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
class UserRetrieveIdViewSet(UserRetrieveViewSet):
class UserRetrieveIdAPIAPIView(UserRetrieveAPIViewSet):
"""Returns user's instance on user's id"""
lookup_field = "pk"
@ -82,8 +93,15 @@ def get(self, request, *args, **kwargs):
return self.retrieve(request, *args, **kwargs)
class UserRetireUpdateSelfViewSet(generics.RetrieveUpdateDestroyAPIView):
class UserRetireUpdateSelfAPIViewSet(generics.RetrieveUpdateDestroyAPIView):
serializer_class = UserFullSerializer
def get_object(self):
return self.request.user
class UserUpdatePasswordAPIView(generics.UpdateAPIView):
serializer_class = UserUpdatePassword
def get_object(self):
return self.request.user

View 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",
),
),
]

View File

@ -27,6 +27,7 @@ class User(AbstractUser, BaseImageModel, ShortLinkModel):
left_file_upload = models.BigIntegerField(
"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):
"""Get url for user's detail view.

View File

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

View File

View File

@ -0,0 +1,8 @@
from django.contrib import admin
from akarpov.users.themes.models import Theme
@admin.register(Theme)
class ThemeAdmin(admin.ModelAdmin):
...

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ThemesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "akarpov.users.themes"

View 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"]

View 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
),
),
],
),
]

View 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

View File

View 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"),
]

View 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 ""

View File

@ -1,4 +1,4 @@
from django.urls import path
from django.urls import include, path
from akarpov.users.views import (
user_detail_view,
@ -11,6 +11,7 @@
app_name = "users"
urlpatterns = [
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("history/", view=user_history_view, name="history"),
path("history/delete", view=user_history_delete_view, name="history_delete"),

View File

@ -7,6 +7,7 @@
from akarpov.users.models import UserHistory
from akarpov.users.services.history import create_history_warning_note
from akarpov.users.themes.models import Theme
User = get_user_model()
@ -26,11 +27,24 @@ class UserUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
success_message = _("Information successfully updated")
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()
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):
return self.request.user

View File

@ -60,11 +60,14 @@ def crop_image(image_path: str, length: int = 500):
def user_file_upload_mixin(instance, filename):
"""stores user uploaded files at their folder in media dir"""
username = ""
if isinstance(instance, get_user_model()):
username = instance.username + "/"
elif hasattr(instance, "user"):
username = instance.user.username + "/"
elif hasattr(instance, "creator"):
username = instance.creator.username + "/"
try:
if isinstance(instance, get_user_model()):
username = instance.username + "/"
elif hasattr(instance, "user"):
username = instance.user.username + "/"
elif hasattr(instance, "creator"):
username = instance.creator.username + "/"
except AttributeError:
username = "__all"
return os.path.join(f"uploads/{username}", filename)

View File

@ -25,9 +25,11 @@ WORKDIR ${APP_HOME}
# Install required system dependencies
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
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 && \
rm -rf /var/lib/apt/lists/*

View File

@ -26,6 +26,7 @@ ARG APP_HOME=/app
ENV PYTHONUNBUFFERED 1
ENV PYTHONDONTWRITEBYTECODE 1
ENV BUILD_ENV ${BUILD_ENVIRONMENT}
ENV POETRY_VERSION 1.4.2
WORKDIR ${APP_HOME}
@ -34,14 +35,12 @@ RUN addgroup --system django \
# Install required system dependencies
RUN apt-get update && apt-get install --no-install-recommends -y \
# psycopg2 dependencies
libpq-dev \
# Translations dependencies
gettext \
# cleaning up unused files
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && \
apt-get install -y build-essential libpq-dev gettext libmagic-dev libjpeg-dev zlib1g-dev && \
# Dependencies for file preview generation
apt-get install -y webp libimage-exiftool-perl libmagickwand-dev ffmpeg libgdal-dev && \
apt-get purge -y --auto-remove -o APT:AutoRemove:RecommendsImportant=false && \
rm -rf /var/lib/apt/lists/*
RUN pip install "poetry==$POETRY_VERSION"

View File

@ -1,5 +1,10 @@
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
RUN chmod +x /usr/local/bin/maintenance/*
RUN mv /usr/local/bin/maintenance/* /usr/local/bin \

View File

@ -11,10 +11,6 @@ entryPoints:
entryPoint:
to: web-secure
web-secure:
# https
address: ":443"
flower:
address: ":5555"
@ -29,27 +25,6 @@ certificatesResolvers:
entryPoint: web
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:
csrf:
# https://docs.traefik.io/master/middlewares/headers/#hostsproxyheaders
@ -63,6 +38,11 @@ http:
servers:
- url: http://django:5000
redirect:
loadBalancer:
servers:
- url: http://redirect:3000
flower:
loadBalancer:
servers:

View File

@ -1,7 +1,7 @@
from django.urls import include, path
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"
@ -11,9 +11,12 @@
include(
[
path(
"register/", UserRegisterViewSet.as_view(), name="user_register_api"
"register/",
UserRegisterAPIViewSet.as_view(),
name="user_register_api",
),
path("token/", obtain_auth_token),
path("jwt/", GenerateUserJWTTokenAPIView.as_view()),
]
),
),
@ -21,10 +24,18 @@
"users/",
include("akarpov.users.api.urls", namespace="users"),
),
path(
"notifications/",
include("akarpov.notifications.providers.urls", namespace="notifications"),
),
path(
"blog/",
include("akarpov.blog.api.urls", namespace="blog"),
),
path(
"music/",
include("akarpov.music.api.urls", namespace="music"),
),
path(
"tools/",
include(

View File

@ -1,7 +1,9 @@
from django.urls import re_path
from akarpov.music.consumers import RadioConsumer
from akarpov.notifications.providers.site.consumers import NotificationsConsumer
websocket_urlpatterns = [
re_path(r"ws/radio/", RadioConsumer.as_asgi()),
re_path(r"ws/notifications/", NotificationsConsumer.as_asgi()),
]

View File

@ -5,6 +5,7 @@
import environ
import structlog
from sentry_sdk.integrations.celery import CeleryIntegration
ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent
# akarpov/
@ -81,7 +82,7 @@
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [("127.0.0.1", 6379)],
"hosts": [env("REDIS_URL")],
},
},
}
@ -155,10 +156,12 @@
"akarpov.files",
"akarpov.music",
"akarpov.gallery",
"akarpov.tools.qr",
"akarpov.pipeliner",
"akarpov.users.themes",
"akarpov.notifications",
"akarpov.test_platform",
"akarpov.tools.shortener",
"akarpov.tools.qr",
"akarpov.tools.promocodes",
]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
@ -306,6 +309,18 @@
)
# https://docs.djangoproject.com/en/dev/ref/settings/#email-timeout
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
# ------------------------------------------------------------------------------
@ -469,7 +484,7 @@
"SERVE_INCLUDE_SCHEMA": False,
"SERVERS": [
{"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,
cache_spans=True,
),
CeleryIntegration(monitor_beat_tasks=True, propagate_traces=True),
],
)

View File

@ -10,6 +10,7 @@
"DJANGO_SECRET_KEY",
default="Lm4alUqtub6qQT4MnV4NmtXQP02RCBtmGj1bJhyDho07Bkjk9WFZxGtwpnLNQGJQ",
)
TOKEN_EXP = 24 * 60 * 60
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = ["*"]
CSRF_TRUSTED_ORIGINS = ["http://127.0.0.1", "https://*.akarpov.ru"]
@ -20,6 +21,7 @@
EMAIL_HOST = env("EMAIL_HOST", default="mailhog")
# https://docs.djangoproject.com/en/dev/ref/settings/#email-port
EMAIL_PORT = env("EMAIL_PORT", default="1025")
EMAIL_FROM = env("EMAIL_FROM", default="noreply@akarpov.ru")
# WhiteNoise
# ------------------------------------------------------------------------------
@ -50,14 +52,9 @@
# ------------------------------------------------------------------------------
# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration
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_REDIRECT_TO = "http://127.0.0.1:8000"
SHORTENER_HOST = "http://127.0.0.1:3000"
SHORTENER_REDIRECT_TO = env("SHORTENER_REDIRECT_TO", default="http://127.0.0.1:8000")
SHORTENER_HOST = env("SHORTENER_HOST", default="http://127.0.0.1:8000")

4291
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -24,7 +24,6 @@ services:
- ./.envs/.production/.postgres
command: /start
redirect:
build:
context: .
@ -35,16 +34,30 @@ services:
depends_on:
- postgres
- redis
- mailhog
volumes:
- .:/app:z
- type: bind
source: /var/www/media/
target: /app/akarpov/media/
env_file:
- ./.envs/.production/.django
- ./.envs/.production/.postgres
ports:
- "3000:3000"
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:
build:

View File

@ -11,7 +11,7 @@ pytz = "^2023.3"
psutil = "^5.9.5"
python-slugify = "^7.0.0"
pillow = "^10.0.0"
argon2-cffi = "^21.3.0"
argon2-cffi = "^23.1.0"
whitenoise = "^6.3.0"
redis = "^4.6.0"
hiredis = "^2.2.3"
@ -82,7 +82,6 @@ xvfbwrapper = "^0.2.9"
vtk = "^9.2.6"
ffmpeg-python = "^0.2.0"
cairosvg = "^2.7.0"
textract = "^1.6.5"
spotipy = "2.16.1"
django-robots = "^5.0"
django-tables2 = "^2.5.3"
@ -107,6 +106,9 @@ pytest-xdist = "^3.3.1"
pytest-mock = "^3.11.1"
pytest-asyncio = "^0.21.1"
pytest-lambda = "^2.2.0"
pgvector = "^0.2.2"
pycld2 = "^0.41"
textract = "^1.6.5"
[build-system]