Compare commits

...

19 Commits

Author SHA1 Message Date
dependabot[bot]
a47ada9958
Merge 8c97fceef7 into 29f78393f4 2023-10-25 13:49:16 +03: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
b59c8fbf8b added better test fixtures 2023-09-08 16:17:35 +03:00
1fc003636b added model tests for users 2023-09-08 14:00:46 +03:00
b4ed207fd1 dependencies fix 2023-09-08 12:46:09 +03:00
806bbfce79 updated api urls, fixed docker 2023-09-08 12:17:46 +03:00
111 changed files with 4653 additions and 2209 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

@ -18,6 +18,7 @@ class PostSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name="api:blog:post", lookup_field="slug"
)
short_link = serializers.URLField(source="get_short_link")
@extend_schema_field(TagSerializer)
def get_h_tag(self, obj):
@ -51,6 +52,7 @@ class FullPostSerializer(PostSerializer):
comments = serializers.HyperlinkedIdentityField(
view_name="api:blog:post_comments", lookup_field="slug"
)
short_link = serializers.URLField(source="get_short_link")
class Meta:
model = Post
@ -83,4 +85,4 @@ class CommentSerializer(serializers.ModelSerializer):
class Meta:
model = Comment
fields = ["author", "body", "created", "rating"]
fields = ["author", "body", "created", "rating", "children"]

View File

@ -47,4 +47,8 @@ class ListCommentsSerializer(generics.ListAPIView):
def get_queryset(self):
post = get_object_or_404(Post, slug=self.kwargs["slug"])
return post.comments.filter(parent__isnull=True)
return (
post.comments.filter(parent__isnull=True)
.prefetch_related("author")
.prefetch_related("children")
)

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.4 on 2023-09-08 08:58
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("blog", "0008_tag_seo_tags"),
]
operations = [
migrations.AlterField(
model_name="comment",
name="parent",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="children",
to="blog.comment",
),
),
]

View File

@ -115,7 +115,9 @@ class Meta:
class Comment(UserHistoryModel):
parent = models.ForeignKey("self", blank=True, null=True, on_delete=models.CASCADE)
parent = models.ForeignKey(
"self", blank=True, null=True, related_name="children", on_delete=models.CASCADE
)
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="comments")

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

@ -1,14 +1,122 @@
import pytest
import io
import logging
import re
from akarpov.users.models import User
from akarpov.users.tests.factories import UserFactory
import factory
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from pytest_django.lazy_django import skip_if_no_django
from rest_framework.test import APIClient
from akarpov.utils.config import build_redis_uri
from akarpov.utils.faker import configure_factory_faker, configure_faker
from akarpov.utils.pytest_factoryboy import autodiscover_factories
configure_factory_faker(factory.Faker)
autodiscover_factories()
logger = logging.getLogger(__name__)
@pytest.fixture(autouse=True)
def media_storage(settings, tmpdir):
settings.MEDIA_ROOT = tmpdir.strpath
def activate_django_db(db):
pass
@pytest.fixture(scope="session", autouse=True)
def update_config():
from django.conf import settings
settings.DEBUG = True
@pytest.fixture
def user(db) -> User:
return UserFactory()
def api_client():
return APIClient()
@pytest.fixture
def admin_client(client, user_factory):
admin_user = user_factory(is_superuser=True, is_staff=True)
client.force_login(admin_user)
return client
@pytest.fixture
def api_user_client(api_client, user):
api_client.force_authenticate(user)
return api_client
@pytest.fixture
def image_factory():
def factory(filename="test.jpg", **params):
from PIL import Image
width = params.get("width", 520)
height = params.get("height", width)
color = params.get("color", "blue")
image_format = params.get("format", "JPEG")
image_palette = params.get("palette", "RGB")
thumb_io = io.BytesIO()
with Image.new(image_palette, (width, height), color) as thumb:
thumb.save(thumb_io, format=image_format)
return SimpleUploadedFile(filename, thumb_io.getvalue())
return factory
@pytest.fixture
def image(image_factory):
return image_factory()
@pytest.fixture
def uploaded_photo(image):
return SimpleUploadedFile("test.png", image.read())
@pytest.fixture
def plain_file():
return io.BytesIO(b"plain_text")
@pytest.fixture(scope="session", autouse=True)
def faker_session_locale():
return ["en"]
@pytest.fixture(autouse=True)
def add_faker_providers(faker):
configure_faker(faker)
return faker
@pytest.fixture(scope="session", autouse=True)
def set_test_redis_databases(request):
from django.conf import settings
skip_if_no_django()
xdist_worker = getattr(request.config, "workerinput", {}).get("workerid")
if xdist_worker is None:
return
worker_number_search = re.search(r"\d+", xdist_worker)
if not worker_number_search:
return
max_db_number = worker_number_search[0]
max_db_number = int(max_db_number) * 3 + 2
channels_redis_url = build_redis_uri(
settings.CHANNELS_REDIS_HOST,
settings.CHANNELS_REDIS_PORT,
settings.CHANNELS_REDIS_USER,
settings.CHANNELS_REDIS_PASSWORD,
max_db_number,
)
settings.CHANNEL_LAYERS["default"]["CONFIG"]["hosts"][0][
"address"
] = channels_redis_url
settings.CHANNELS_REDIS_DB = max_db_number
settings.CLICKHOUSE_REDIS_CONFIG["db"] = max_db_number - 1
settings.CELERY_REDIS_DB = max_db_number - 2

1
akarpov/files/consts.py Normal file
View File

@ -0,0 +1 @@
USER_INITIAL_FILE_UPLOAD = 100 * 1024 * 1024

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="user_list_api"),
path("", UserListAPIViewSet.as_view(), name="list"),
path(
"self/",
UserRetireUpdateSelfViewSet.as_view(),
name="user_get_update_delete_self_api",
UserRetireUpdateSelfAPIViewSet.as_view(),
name="self",
),
path(
"self/password",
UserUpdatePasswordAPIView.as_view(),
name="password",
),
path(
"id/<int:pk>",
UserRetrieveIdViewSet.as_view(),
name="user_retrieve_id_api",
UserRetrieveIdAPIAPIView.as_view(),
name="get_by_id",
),
path(
"<str:username>",
UserRetrieveViewSet.as_view(),
name="user_retrieve_username_api",
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

@ -10,6 +10,7 @@
from django.db.models.signals import pre_save
from django.dispatch import receiver
from akarpov.files.consts import USER_INITIAL_FILE_UPLOAD
from akarpov.users.models import User
from akarpov.users.services.history import (
create_history_note,
@ -21,7 +22,7 @@
def user_create(sender, instance: User, **kwargs):
if instance.id is None:
# give user some space on file share on register
instance.left_file_upload += 100 * 1024 * 1024
instance.left_file_upload += USER_INITIAL_FILE_UPLOAD
@receiver(user_logged_in)

View File

@ -1,33 +1,20 @@
from collections.abc import Sequence
from typing import Any
from django.contrib.auth import get_user_model
from factory import Faker, post_generation
import factory.fuzzy
from factory.django import DjangoModelFactory
from akarpov.utils.pytest_factoryboy import global_register
@global_register
class UserFactory(DjangoModelFactory):
username = Faker("user_name")
email = Faker("email")
name = Faker("name")
@post_generation
def password(self, create: bool, extracted: Sequence[Any], **kwargs):
password = (
extracted
if extracted
else Faker(
"password",
length=42,
special_chars=True,
digits=True,
upper_case=True,
lower_case=True,
).evaluate(None, None, extra={"locale": None})
)
self.set_password(password)
email = factory.Sequence(lambda i: f"user_{i}@akarpov.ru")
username = factory.Faker("word")
image = factory.fuzzy.FuzzyText(prefix="https://img")
password = "P@ssw0rd"
class Meta:
model = get_user_model()
django_get_or_create = ["username"]
model = "users.User"
@classmethod
def _create(cls, model_class, *args, **kwargs):
manager = cls._get_manager(model_class)
return manager.create_user(*args, **kwargs)

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

@ -1,7 +1,19 @@
from akarpov.users.models import User
from akarpov.files.consts import USER_INITIAL_FILE_UPLOAD
def test_user_create(user: User):
def test_user_create(user_factory):
user = user_factory()
password = "123"
user.set_password(password)
assert user.check_password(password)
def test_auto_file_upload_size(user_factory):
user = user_factory()
size = USER_INITIAL_FILE_UPLOAD
assert user.left_file_upload == size
def test_user_image_create(user_factory):
user = user_factory()
assert user.image

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

@ -1,6 +1,9 @@
from decimal import Decimal as D
from io import BytesIO
from django.core.files.uploadedfile import InMemoryUploadedFile
from faker.providers.python import Provider as PythonProvider
from PIL import Image
class MoneyProvider(PythonProvider):
@ -12,11 +15,28 @@ def money(self):
def configure_factory_faker(factory_faker):
factory_faker._DEFAULT_LOCALE = "ru_RU"
factory_faker._DEFAULT_LOCALE = "en"
for provider in additional_providers:
factory_faker.add_provider(provider, locale="ru_RU")
factory_faker.add_provider(provider, locale="en")
def configure_faker(faker):
for provider in additional_providers:
faker.add_provider(provider)
def django_image(name, size=200, color="red"):
thumb = Image.new(
"RGB",
(
size,
size,
),
color,
)
thumb_io = BytesIO()
thumb.save(thumb_io, format="JPEG")
thumb_io.seek(0)
return InMemoryUploadedFile(
thumb_io, None, name, "image/jpeg", thumb_io.getbuffer().nbytes, None
)

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

@ -0,0 +1,47 @@
from django.utils.module_loading import autodiscover_modules
from pytest_factoryboy.fixture import get_caller_locals, register
class RegisteredFactory:
def __init__(self, factory_class, args, kwargs):
self.factory_class = factory_class
self.args = args
self.kwargs = kwargs
factory_registry = set() # set of registered factories
def global_register(factory_class=None, *args, **kwargs):
if factory_class is None:
def _global_register(factory_class):
return global_register(factory_class, *args, **kwargs)
return _global_register
factory_registry.add(RegisteredFactory(factory_class, args, kwargs))
return factory_class
def autodiscover_factories():
assert (
not factory_registry
), "You've already called `autodiscover_factories` function"
caller_locals = get_caller_locals()
assert caller_locals["__name__"].endswith(
"conftest"
), "You must call `autodiscover_factories` from `conftest.py` file"
autodiscover_modules("tests.factories")
for registered_factory in factory_registry:
register(
registered_factory.factory_class,
*registered_factory.args,
_caller_locals=caller_locals,
**registered_factory.kwargs,
)

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/*
@ -35,7 +37,7 @@ RUN pip install "poetry==$POETRY_VERSION"
RUN python -m venv /venv
COPY pyproject.toml poetry.lock /app/
RUN poetry export -f requirements.txt | /venv/bin/pip install -r /dev/stdin
RUN poetry export --without-hashes -f requirements.txt | /venv/bin/pip install -r /dev/stdin
COPY . .
RUN poetry build && /venv/bin/pip install dist/*.whl

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,21 +35,19 @@ 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"
RUN python -m venv /venv
COPY pyproject.toml poetry.lock /app/
RUN poetry export -f requirements.txt | /venv/bin/pip install -r /dev/stdin
RUN poetry export --without-hashes -f requirements.txt | /venv/bin/pip install -r /dev/stdin
COPY . .
RUN poetry build && /venv/bin/pip install dist/*.whl

Some files were not shown because too many files have changed in this diff Show More