mirror of
https://github.com/Alexander-D-Karpov/akarpov
synced 2024-11-28 01:03:43 +03:00
Compare commits
37 Commits
9ec7a39a83
...
1ad5fc4240
Author | SHA1 | Date | |
---|---|---|---|
|
1ad5fc4240 | ||
|
aff1cc0591 | ||
|
b52366d9a8 | ||
80a1b2b554 | |||
8fd1d55f08 | |||
43aeb2290d | |||
e38888e411 | |||
957d748647 | |||
b9302f16a8 | |||
f57472f1f3 | |||
cdec1efdef | |||
4dec3867ca | |||
773fd2830e | |||
1644fa5403 | |||
30616ba17d | |||
4cdd0ebe12 | |||
6fb08d6569 | |||
5b457b3668 | |||
356476217d | |||
9ac5a1f235 | |||
e94f90d091 | |||
4ef6021499 | |||
62ed999dc4 | |||
03b0de017c | |||
b9715981e7 | |||
be9a5146c3 | |||
3c32430e9e | |||
4df9bfb2ec | |||
3f844bbca1 | |||
b3b015488b | |||
90f15db5e3 | |||
|
29f78393f4 | ||
e4bfd5ca07 | |||
59fc828097 | |||
403fb8ffa5 | |||
f6f15d3979 | |||
f59df63dd4 |
|
@ -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
|
||||
|
|
|
@ -7,6 +7,7 @@ DJANGO_READ_DOT_ENV_FILE=no
|
|||
# ------------------------------------------------------------------------------
|
||||
REDIS_URL=redis://redis:6379/1
|
||||
REDIS_CACHE=rediscache://redis:6379/1
|
||||
REDIS_CACHE_URL=redis://redis:6379/1
|
||||
CELERY_BROKER_URL=redis://redis:6379/0
|
||||
|
||||
# Celery
|
||||
|
@ -15,3 +16,5 @@ CELERY_BROKER_URL=redis://redis:6379/0
|
|||
# Flower
|
||||
CELERY_FLOWER_USER=debug
|
||||
CELERY_FLOWER_PASSWORD=debug
|
||||
|
||||
ELASTIC_SEARCH=http://elasticsearch:9200/
|
||||
|
|
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -49,7 +49,7 @@ jobs:
|
|||
- name: Install poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'poetry'
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,4 +1,6 @@
|
|||
.idea
|
||||
django_setup.txt
|
||||
django_setup.prof
|
||||
|
||||
### Python template
|
||||
# Byte-compiled / optimized / DLL files
|
||||
|
|
|
@ -12,7 +12,6 @@ https://git.akarpov.ru/sanspie/akarpov
|
|||
### installation
|
||||
```shell
|
||||
$ poetry install & poetry shell
|
||||
$ ./spacy_setup.sh
|
||||
$ python3 manage.py migrate
|
||||
```
|
||||
|
||||
|
@ -53,3 +52,4 @@ $ mypy --config-file setup.cfg akarpov
|
|||
- short link generator
|
||||
- about me app
|
||||
- gallery
|
||||
- notifications
|
||||
|
|
0
akarpov/about/api/__init__.py
Normal file
0
akarpov/about/api/__init__.py
Normal file
5
akarpov/about/api/serializers.py
Normal file
5
akarpov/about/api/serializers.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
|
||||
class StatusSerializer(serializers.Serializer):
|
||||
status = serializers.CharField(default="pong")
|
7
akarpov/about/api/urls.py
Normal file
7
akarpov/about/api/urls.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
from django.urls import path
|
||||
|
||||
from akarpov.about.api.views import PingAPIView
|
||||
|
||||
app_name = "about"
|
||||
|
||||
urlpatterns = [path("ping", PingAPIView.as_view(), name="ping")]
|
11
akarpov/about/api/views.py
Normal file
11
akarpov/about/api/views.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from rest_framework import generics, permissions, response
|
||||
|
||||
from akarpov.about.api.serializers import StatusSerializer
|
||||
|
||||
|
||||
class PingAPIView(generics.GenericAPIView):
|
||||
serializer_class = StatusSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
return response.Response(data={"status": "pong"})
|
|
@ -2,7 +2,7 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from akarpov.blog.models import Comment, Post, Tag
|
||||
from akarpov.common.api import RecursiveField
|
||||
from akarpov.common.api.serializers import RecursiveField
|
||||
from akarpov.users.api.serializers import UserPublicInfoSerializer
|
||||
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
)
|
||||
from akarpov.blog.models import Post
|
||||
from akarpov.blog.services import get_main_rating_posts
|
||||
from akarpov.common.api import StandardResultsSetPagination
|
||||
from akarpov.common.api.pagination import StandardResultsSetPagination
|
||||
|
||||
|
||||
class ListMainPostsView(generics.ListAPIView):
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
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):
|
||||
page_size = 10
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 50
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 200
|
||||
|
||||
|
||||
class BigResultsSetPagination(PageNumberPagination):
|
||||
page_size = 100
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 1000
|
||||
|
||||
|
||||
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
|
0
akarpov/common/api/__init__.py
Normal file
0
akarpov/common/api/__init__.py
Normal file
19
akarpov/common/api/pagination.py
Normal file
19
akarpov/common/api/pagination.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
|
||||
class SmallResultsSetPagination(PageNumberPagination):
|
||||
page_size = 10
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 50
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 200
|
||||
|
||||
|
||||
class BigResultsSetPagination(PageNumberPagination):
|
||||
page_size = 100
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 1000
|
19
akarpov/common/api/permissions.py
Normal file
19
akarpov/common/api/permissions.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
from akarpov.utils.models import get_object_user
|
||||
|
||||
|
||||
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 IsAdminOrReadOnly(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
return bool(
|
||||
request.method in SAFE_METHODS or request.user and request.user.is_staff
|
||||
)
|
14
akarpov/common/api/serializers.py
Normal file
14
akarpov/common/api/serializers.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
|
||||
class RecursiveField(serializers.Serializer):
|
||||
def to_representation(self, value):
|
||||
serializer = self.parent.parent.__class__(value, context=self.context)
|
||||
return serializer.data
|
||||
|
||||
|
||||
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
|
|
@ -11,7 +11,7 @@ class HasPermissions(SingleObjectMixin):
|
|||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
has_perm = False
|
||||
if self.request.user.is_authentificated:
|
||||
if self.request.user.is_authenticated:
|
||||
has_perm = self.object.user == self.request.user
|
||||
context["has_permissions"] = has_perm
|
||||
return context
|
||||
|
|
31
akarpov/files/documents.py
Normal file
31
akarpov/files/documents.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
from django_elasticsearch_dsl import Document
|
||||
from django_elasticsearch_dsl.registries import registry
|
||||
|
||||
from akarpov.files.models import File
|
||||
|
||||
|
||||
@registry.register_document
|
||||
class FileDocument(Document):
|
||||
class Index:
|
||||
name = "files"
|
||||
settings = {"number_of_shards": 1, "number_of_replicas": 0}
|
||||
|
||||
class Django:
|
||||
model = File
|
||||
fields = [
|
||||
"name",
|
||||
"description",
|
||||
"content",
|
||||
]
|
||||
|
||||
def prepare_description(self, instance):
|
||||
# This method is called for every instance before indexing
|
||||
return instance.description or ""
|
||||
|
||||
def prepare_content(self, instance):
|
||||
# This method is called for every instance before indexing
|
||||
return (
|
||||
instance.content.decode("utf-8")
|
||||
if isinstance(instance.content, bytes)
|
||||
else instance.content
|
||||
)
|
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 4.2.6 on 2023-11-06 21:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("files", "0028_file_content_file_lang"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="file",
|
||||
name="embeddings",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="file",
|
||||
name="lang",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="file",
|
||||
name="content",
|
||||
field=models.TextField(),
|
||||
),
|
||||
]
|
19
akarpov/files/migrations/0030_auto_20231107_0023.py
Normal file
19
akarpov/files/migrations/0030_auto_20231107_0023.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 4.2.6 on 2023-11-06 21:23
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("files", "0029_remove_file_embeddings_remove_file_lang_and_more"),
|
||||
]
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
sql="CREATE EXTENSION IF NOT EXISTS unaccent;",
|
||||
reverse_sql="DROP EXTENSION unaccent;",
|
||||
),
|
||||
migrations.RunSQL(
|
||||
sql="CREATE EXTENSION IF NOT EXISTS pg_trgm;",
|
||||
reverse_sql="DROP EXTENSION pg_trgm;",
|
||||
),
|
||||
]
|
|
@ -17,7 +17,6 @@
|
|||
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
|
||||
|
@ -70,9 +69,8 @@ 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")])
|
||||
content = TextField()
|
||||
# lang = CharField(max_length=2, choices=[("ru", "ru"), ("en", "en")])
|
||||
|
||||
# meta
|
||||
name = CharField(max_length=255, null=True, blank=True)
|
||||
|
@ -128,7 +126,7 @@ class Folder(BaseFileItem, ShortLinkModel, UserHistoryModel):
|
|||
amount = IntegerField(default=0)
|
||||
|
||||
def get_last_preview_files(self, cut=4):
|
||||
return self.children.filter(~Q(File___preview=""))[:cut]
|
||||
return self.children.cache().filter(~Q(File___preview=""))[:cut]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("files:folder", kwargs={"slug": self.slug})
|
||||
|
|
42
akarpov/files/services/lema.py
Normal file
42
akarpov/files/services/lema.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
from nltk.corpus import stopwords
|
||||
from nltk.stem import WordNetLemmatizer
|
||||
from nltk.tokenize import word_tokenize
|
||||
from pymorphy3 import MorphAnalyzer
|
||||
|
||||
# Set up stop words
|
||||
english_stopwords = set(stopwords.words("english"))
|
||||
russian_stopwords = set(stopwords.words("russian"))
|
||||
|
||||
# Set up lemmatizers
|
||||
english_lemmatizer = None
|
||||
russian_lemmatizer = None
|
||||
|
||||
|
||||
def lemmatize_and_remove_stopwords(text, language="english"):
|
||||
# Tokenize the text
|
||||
global english_lemmatizer, russian_lemmatizer
|
||||
tokens = word_tokenize(text)
|
||||
|
||||
# Lemmatize each token based on the specified language
|
||||
lemmatized_tokens = []
|
||||
for token in tokens:
|
||||
if language == "russian":
|
||||
if not russian_lemmatizer:
|
||||
russian_lemmatizer = MorphAnalyzer()
|
||||
lemmatized_token = russian_lemmatizer.parse(token)[0].normal_form
|
||||
else: # Default to English
|
||||
if not english_lemmatizer:
|
||||
english_lemmatizer = WordNetLemmatizer()
|
||||
lemmatized_token = english_lemmatizer.lemmatize(token)
|
||||
lemmatized_tokens.append(lemmatized_token)
|
||||
|
||||
# Remove stop words
|
||||
filtered_tokens = [
|
||||
token
|
||||
for token in lemmatized_tokens
|
||||
if token not in english_stopwords and token not in russian_stopwords
|
||||
]
|
||||
|
||||
# Reconstruct the text
|
||||
filtered_text = " ".join(filtered_tokens)
|
||||
return filtered_text
|
|
@ -16,7 +16,7 @@
|
|||
"Consola.ttf",
|
||||
]
|
||||
|
||||
manager = PreviewManager(cache_path, create_folder=True)
|
||||
manager = None
|
||||
|
||||
|
||||
def textfile_to_image(textfile_path) -> Image:
|
||||
|
@ -79,7 +79,10 @@ def _font_points_to_pixels(pt):
|
|||
|
||||
|
||||
def create_preview(file_path: str) -> str:
|
||||
global manager
|
||||
# TODO: add text image generation/code image
|
||||
if not manager:
|
||||
manager = PreviewManager(cache_path, create_folder=True)
|
||||
if manager.has_jpeg_preview(file_path):
|
||||
return manager.get_jpeg_preview(file_path, height=500)
|
||||
return ""
|
||||
|
@ -91,6 +94,10 @@ def get_file_mimetype(file_path: str) -> str:
|
|||
|
||||
|
||||
def get_description(file_path: str) -> str:
|
||||
global manager
|
||||
if not manager:
|
||||
manager = PreviewManager(cache_path, create_folder=True)
|
||||
|
||||
if manager.has_text_preview(file_path):
|
||||
return manager.get_text_preview(file_path)
|
||||
return ""
|
||||
|
|
167
akarpov/files/services/search.py
Normal file
167
akarpov/files/services/search.py
Normal file
|
@ -0,0 +1,167 @@
|
|||
import os
|
||||
import re
|
||||
from typing import BinaryIO
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.db.models import Case, F, FloatField, Func, Q, QuerySet, Value, When
|
||||
from django.db.models.functions import Coalesce
|
||||
from elasticsearch_dsl import Q as ES_Q
|
||||
|
||||
from akarpov.files.models import File
|
||||
|
||||
from ..documents import FileDocument
|
||||
from .lema import lemmatize_and_remove_stopwords
|
||||
|
||||
|
||||
class BaseSearch:
|
||||
def __init__(self, queryset: QuerySet | None = None):
|
||||
self.queryset: QuerySet | None = queryset
|
||||
|
||||
def search(self, query: str) -> QuerySet | list[File]:
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
|
||||
|
||||
class NeuroSearch(BaseSearch):
|
||||
def search(self, query: str):
|
||||
if not self.queryset:
|
||||
raise ValueError("Queryset cannot be None for search")
|
||||
|
||||
# Perform the Elasticsearch query using a combination of match, match_phrase_prefix, and wildcard queries
|
||||
search = FileDocument.search()
|
||||
search_query = ES_Q(
|
||||
"bool",
|
||||
should=[
|
||||
ES_Q(
|
||||
"multi_match",
|
||||
query=query,
|
||||
fields=["name", "description", "content"],
|
||||
type="best_fields",
|
||||
),
|
||||
ES_Q("match_phrase_prefix", name=query),
|
||||
ES_Q("wildcard", name=f"*{query}*"),
|
||||
ES_Q("wildcard", description=f"*{query}*"),
|
||||
ES_Q("wildcard", content=f"*{query}*"),
|
||||
],
|
||||
minimum_should_match=1,
|
||||
)
|
||||
|
||||
search = search.query(search_query)
|
||||
|
||||
# Execute the search to get the results
|
||||
response = search.execute()
|
||||
|
||||
# Check if there are hits, if not return an empty queryset
|
||||
if not response.hits:
|
||||
return self.queryset.none()
|
||||
|
||||
# Collect the IDs of the hits
|
||||
hit_ids = [hit.meta.id for hit in response.hits]
|
||||
|
||||
# Use the hit IDs to filter the queryset and preserve the order
|
||||
preserved_order = Case(
|
||||
*[When(pk=pk, then=pos) for pos, pk in enumerate(hit_ids)]
|
||||
)
|
||||
relevant_queryset = self.queryset.filter(pk__in=hit_ids).order_by(
|
||||
preserved_order
|
||||
)
|
||||
|
||||
return relevant_queryset
|
||||
|
||||
|
||||
class CaseSensitiveSearch(BaseSearch):
|
||||
def search(self, query: str) -> QuerySet[File]:
|
||||
if self.queryset is None:
|
||||
raise ValueError("Queryset cannot be None for text search")
|
||||
|
||||
# Escape any regex special characters in the query string
|
||||
query_escaped = re.escape(query)
|
||||
|
||||
# Use a case-sensitive regex to filter
|
||||
return self.queryset.filter(
|
||||
Q(name__regex=query_escaped)
|
||||
| Q(description__regex=query_escaped)
|
||||
| Q(content__regex=query_escaped)
|
||||
)
|
||||
|
||||
|
||||
class ByteSearch(BaseSearch):
|
||||
def search(self, hex_query: str) -> list[File]:
|
||||
# Convert the hex query to bytes
|
||||
try:
|
||||
byte_query: bytes = bytes.fromhex(hex_query)
|
||||
except ValueError:
|
||||
# If hex_query is not a valid hex, return an empty list
|
||||
return []
|
||||
|
||||
matching_files: list[File] = []
|
||||
if self.queryset is not None:
|
||||
for file_item in self.queryset:
|
||||
file_path: str = file_item.file.path
|
||||
full_path: str = os.path.join(settings.MEDIA_ROOT, file_path)
|
||||
if os.path.exists(full_path):
|
||||
with open(full_path, "rb") as file:
|
||||
if self._byte_search_in_file(file, byte_query):
|
||||
matching_files.append(file_item)
|
||||
return matching_files
|
||||
|
||||
@staticmethod
|
||||
def _byte_search_in_file(file: BinaryIO, byte_sequence: bytes) -> bool:
|
||||
# Read the file in chunks to avoid loading large files into memory
|
||||
chunk_size: int = 4096 # or another size depending on the expected file sizes
|
||||
while True:
|
||||
chunk: bytes = file.read(chunk_size)
|
||||
if byte_sequence in chunk:
|
||||
return True
|
||||
if not chunk: # End of file reached
|
||||
return False
|
||||
|
||||
|
||||
class UnaccentLower(Func):
|
||||
function = "UNACCENT"
|
||||
|
||||
def as_sql(self, compiler, connection):
|
||||
unaccented_sql, unaccented_params = compiler.compile(
|
||||
self.get_source_expressions()[0]
|
||||
)
|
||||
lower_unaccented_sql = f"LOWER({unaccented_sql})"
|
||||
return lower_unaccented_sql, unaccented_params
|
||||
|
||||
|
||||
class SimilaritySearch(BaseSearch):
|
||||
def search(self, query: str) -> QuerySet[File]:
|
||||
if self.queryset is None:
|
||||
raise ValueError("Queryset cannot be None for similarity search")
|
||||
|
||||
language = "russian" if re.search("[а-яА-Я]", query) else "english"
|
||||
filtered_query = lemmatize_and_remove_stopwords(query, language=language)
|
||||
queryset = (
|
||||
self.queryset.annotate(
|
||||
name_similarity=Coalesce(
|
||||
TrigramSimilarity(UnaccentLower("name"), filtered_query),
|
||||
Value(0),
|
||||
output_field=FloatField(),
|
||||
),
|
||||
description_similarity=Coalesce(
|
||||
TrigramSimilarity(UnaccentLower("description"), filtered_query),
|
||||
Value(0),
|
||||
output_field=FloatField(),
|
||||
),
|
||||
content_similarity=Coalesce(
|
||||
TrigramSimilarity(UnaccentLower("content"), filtered_query),
|
||||
Value(0),
|
||||
output_field=FloatField(),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
combined_similarity=(
|
||||
F("name_similarity")
|
||||
+ F("description_similarity")
|
||||
+ F("content_similarity")
|
||||
)
|
||||
)
|
||||
.filter(combined_similarity__gt=0.1)
|
||||
.order_by("-combined_similarity")
|
||||
)
|
||||
|
||||
return queryset
|
|
@ -1,7 +1,18 @@
|
|||
import chardet
|
||||
import textract
|
||||
from textract.exceptions import ExtensionNotSupported
|
||||
|
||||
|
||||
def extract_file_text(file: str) -> str:
|
||||
try:
|
||||
text = textract.process(file)
|
||||
except ExtensionNotSupported:
|
||||
try:
|
||||
rawdata = open(file, "rb").read()
|
||||
enc = chardet.detect(rawdata)
|
||||
with open(file, encoding=enc["encoding"]) as f:
|
||||
text = f.read()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
return text
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import os
|
||||
import time
|
||||
|
||||
import structlog
|
||||
from celery import shared_task
|
||||
from django.core import management
|
||||
from django.core.files import File
|
||||
|
||||
from akarpov.files.models import File as FileModel
|
||||
from akarpov.files.services.preview import create_preview, get_file_mimetype
|
||||
from akarpov.files.services.text import extract_file_text
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
@ -28,7 +31,17 @@ def process_file(pk: int):
|
|||
except Exception as e:
|
||||
logger.error(e)
|
||||
file.file_type = get_file_mimetype(file.file.path)
|
||||
file.save(update_fields=["preview", "name", "file_type"])
|
||||
file.content = extract_file_text(file.file.path)
|
||||
file.save(update_fields=["preview", "name", "file_type", "content"])
|
||||
if pth and os.path.isfile(pth):
|
||||
os.remove(pth)
|
||||
return pk
|
||||
|
||||
|
||||
@shared_task
|
||||
def update_index_task():
|
||||
start_time = time.time()
|
||||
management.call_command("search_index", "--rebuild", "-f")
|
||||
end_time = time.time()
|
||||
duration = end_time - start_time
|
||||
logger.info("update_index_completed", duration=duration)
|
||||
|
|
|
@ -33,15 +33,44 @@
|
|||
from akarpov.files.previews import extensions, meta, meta_extensions, previews
|
||||
from akarpov.files.services.folders import delete_folder
|
||||
from akarpov.files.services.preview import get_base_meta
|
||||
from akarpov.files.services.search import (
|
||||
ByteSearch,
|
||||
CaseSensitiveSearch,
|
||||
NeuroSearch,
|
||||
SimilaritySearch,
|
||||
)
|
||||
from akarpov.files.tables import FileTable
|
||||
from akarpov.notifications.services import send_notification
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
search_classes = {
|
||||
"neuro": NeuroSearch,
|
||||
"case_sensitive": CaseSensitiveSearch,
|
||||
"byte_search": ByteSearch,
|
||||
"similarity": SimilaritySearch,
|
||||
}
|
||||
|
||||
class TopFolderView(LoginRequiredMixin, ListView):
|
||||
|
||||
class FileFilterView(View):
|
||||
def filter(self, queryset):
|
||||
if "query" in self.request.GET and "search_type" in self.request.GET:
|
||||
query = self.request.GET["query"]
|
||||
search_type = self.request.GET["search_type"]
|
||||
if not query or not self.request.user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
if search_type in search_classes:
|
||||
search_instance = search_classes[search_type](
|
||||
queryset=File.objects.filter(user=self.request.user)
|
||||
)
|
||||
queryset = search_instance.search(query)
|
||||
return queryset
|
||||
|
||||
|
||||
class TopFolderView(LoginRequiredMixin, ListView, FileFilterView):
|
||||
template_name = "files/list.html"
|
||||
paginate_by = 18
|
||||
paginate_by = 38
|
||||
model = BaseFileItem
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
@ -55,10 +84,18 @@ def get_context_data(self, **kwargs):
|
|||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
return BaseFileItem.objects.filter(user=self.request.user, parent__isnull=True)
|
||||
if (
|
||||
"query" in self.request.GET
|
||||
and "search_type" in self.request.GET
|
||||
and self.request.GET["query"]
|
||||
):
|
||||
return self.filter(BaseFileItem.objects.none())
|
||||
return self.filter(
|
||||
BaseFileItem.objects.filter(user=self.request.user, parent__isnull=True)
|
||||
)
|
||||
|
||||
|
||||
class FileFolderView(ListView):
|
||||
class FileFolderView(ListView, FileFilterView):
|
||||
template_name = "files/folder.html"
|
||||
model = BaseFileItem
|
||||
paginate_by = 38
|
||||
|
@ -94,6 +131,13 @@ def get_object(self, *args):
|
|||
|
||||
def get_queryset(self):
|
||||
folder = self.get_object()
|
||||
|
||||
if (
|
||||
"query" in self.request.GET
|
||||
and "search_type" in self.request.GET
|
||||
and self.request.GET["query"]
|
||||
):
|
||||
return self.filter(BaseFileItem.objects.none())
|
||||
return BaseFileItem.objects.filter(parent=folder)
|
||||
|
||||
|
||||
|
|
0
akarpov/gallery/api/__init__.py
Normal file
0
akarpov/gallery/api/__init__.py
Normal file
24
akarpov/gallery/api/serializers.py
Normal file
24
akarpov/gallery/api/serializers.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from akarpov.gallery.models import Image
|
||||
|
||||
|
||||
class ImageSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Image
|
||||
fields = (
|
||||
"slug",
|
||||
"image",
|
||||
"collection",
|
||||
"public",
|
||||
"image_cropped",
|
||||
"created",
|
||||
"modified",
|
||||
)
|
||||
extra_kwargs = {
|
||||
"slug": {"read_only": True},
|
||||
"collection": {"write_only": True},
|
||||
"image_cropped": {"read_only": True},
|
||||
"created": {"read_only": True},
|
||||
"modified": {"read_only": True},
|
||||
}
|
9
akarpov/gallery/api/urls.py
Normal file
9
akarpov/gallery/api/urls.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "gallery"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.ListCreateImageAPIView.as_view(), name="list-create-all"),
|
||||
]
|
21
akarpov/gallery/api/views.py
Normal file
21
akarpov/gallery/api/views.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
from rest_framework import generics, parsers
|
||||
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||
|
||||
from akarpov.common.api.pagination import StandardResultsSetPagination
|
||||
from akarpov.gallery.api.serializers import ImageSerializer
|
||||
from akarpov.gallery.models import Image
|
||||
|
||||
|
||||
class ListCreateImageAPIView(generics.ListCreateAPIView):
|
||||
serializer_class = ImageSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||
parser_classes = [parsers.MultiPartParser, parsers.FormParser]
|
||||
pagination_class = StandardResultsSetPagination
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_authenticated:
|
||||
return self.request.user.images.all()
|
||||
return Image.objects.filter(public=True)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
25
akarpov/gallery/forms.py
Normal file
25
akarpov/gallery/forms.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
from django import forms
|
||||
|
||||
from akarpov.gallery.models import Collection, Image
|
||||
|
||||
|
||||
class ImageUploadForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Image
|
||||
fields = (
|
||||
"collection",
|
||||
"public",
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop("user", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
if user is not None:
|
||||
self.fields["collection"].queryset = Collection.objects.filter(user=user)
|
||||
|
||||
|
||||
ImageFormSet = forms.modelformset_factory(
|
||||
Image,
|
||||
form=ImageUploadForm,
|
||||
extra=3, # Number of images to upload at once; adjust as needed
|
||||
)
|
17
akarpov/gallery/migrations/0004_image_public.py
Normal file
17
akarpov/gallery/migrations/0004_image_public.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 4.2.6 on 2023-11-17 20:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("gallery", "0003_alter_collection_options_alter_image_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="image",
|
||||
name="public",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
|
@ -20,6 +20,9 @@ class Collection(TimeStampedModel, ShortLinkModel, UserHistoryModel):
|
|||
def get_absolute_url(self):
|
||||
return reverse("gallery:collection", kwargs={"slug": self.slug})
|
||||
|
||||
def get_preview_images(self):
|
||||
return self.images.cache().all()[:6]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
@ -31,6 +34,7 @@ class Image(TimeStampedModel, ShortLinkModel, BaseImageModel, UserHistoryModel):
|
|||
collection = models.ForeignKey(
|
||||
"Collection", related_name="images", on_delete=models.CASCADE
|
||||
)
|
||||
public = models.BooleanField(default=False)
|
||||
user = models.ForeignKey(
|
||||
"users.User", related_name="images", on_delete=models.CASCADE
|
||||
)
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from akarpov.gallery.views import (
|
||||
collection_view,
|
||||
image_upload_view,
|
||||
image_view,
|
||||
list_collections_view,
|
||||
list_tag_images_view,
|
||||
|
@ -10,6 +11,7 @@
|
|||
app_name = "gallery"
|
||||
urlpatterns = [
|
||||
path("", list_collections_view, name="list"),
|
||||
path("upload/", image_upload_view, name="upload"),
|
||||
path("<str:slug>", collection_view, name="collection"),
|
||||
path("tag/<str:slug>", list_tag_images_view, name="tag"),
|
||||
path("image/<str:slug>", image_view, name="view"),
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views import generic
|
||||
|
||||
from akarpov.common.views import HasPermissions
|
||||
from akarpov.gallery.forms import ImageUploadForm
|
||||
from akarpov.gallery.models import Collection, Image, Tag
|
||||
|
||||
|
||||
|
@ -14,6 +16,17 @@ def get_queryset(self):
|
|||
return self.request.user.collections.all()
|
||||
return Collection.objects.filter(public=True)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["collection_previews"] = [
|
||||
{
|
||||
"collection": collection,
|
||||
"preview_images": collection.get_preview_images(),
|
||||
}
|
||||
for collection in context["collection_list"]
|
||||
]
|
||||
return context
|
||||
|
||||
|
||||
list_collections_view = ListCollectionsView.as_view()
|
||||
|
||||
|
@ -46,3 +59,25 @@ class ImageView(generic.DetailView, HasPermissions):
|
|||
|
||||
|
||||
image_view = ImageView.as_view()
|
||||
|
||||
|
||||
class ImageUploadView(LoginRequiredMixin, generic.CreateView):
|
||||
model = Image
|
||||
form_class = ImageUploadForm
|
||||
success_url = "" # Replace with your success URL
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["user"] = self.request.user
|
||||
return kwargs
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
form = self.get_form()
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return self.form_valid(form)
|
||||
else:
|
||||
return self.form_invalid(form)
|
||||
|
||||
|
||||
image_upload_view = ImageUploadView.as_view()
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from akarpov.common.api import SetUserModelSerializer
|
||||
from akarpov.common.api.serializers import SetUserModelSerializer
|
||||
from akarpov.music.models import Album, Author, Playlist, Song
|
||||
from akarpov.users.api.serializers import UserPublicInfoSerializer
|
||||
|
||||
|
@ -56,6 +56,10 @@ class Meta:
|
|||
|
||||
class ListSongSerializer(SetUserModelSerializer):
|
||||
album = serializers.CharField(source="album.name", read_only=True)
|
||||
liked = serializers.SerializerMethodField(method_name="get_liked")
|
||||
|
||||
def get_liked(self, obj):
|
||||
return obj.id in self.context["likes_ids"]
|
||||
|
||||
class Meta:
|
||||
model = Song
|
||||
|
|
|
@ -10,8 +10,18 @@
|
|||
app_name = "music"
|
||||
|
||||
urlpatterns = [
|
||||
path("playlists/", ListCreatePlaylistAPIView.as_view()),
|
||||
path("playlists/<str:slug>", RetrieveUpdateDestroyPlaylistAPIView.as_view()),
|
||||
path("song/", ListCreateSongAPIView.as_view()),
|
||||
path("song/<str:slug>", RetrieveUpdateDestroySongAPIView.as_view()),
|
||||
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",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,13 +1,28 @@
|
|||
from rest_framework import generics, permissions
|
||||
|
||||
from akarpov.common.api import IsCreatorOrReadOnly
|
||||
from akarpov.common.api.pagination import StandardResultsSetPagination
|
||||
from akarpov.common.api.permissions import IsAdminOrReadOnly, IsCreatorOrReadOnly
|
||||
from akarpov.music.api.serializers import (
|
||||
FullPlaylistSerializer,
|
||||
ListSongSerializer,
|
||||
PlaylistSerializer,
|
||||
SongSerializer,
|
||||
)
|
||||
from akarpov.music.models import Playlist, Song
|
||||
from akarpov.music.models import Playlist, Song, SongUserRating
|
||||
|
||||
|
||||
class LikedSongsContextMixin(generics.GenericAPIView):
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
if self.request.user.is_authenticated:
|
||||
context["likes_ids"] = (
|
||||
SongUserRating.objects.cache()
|
||||
.filter(user=self.request.user, like=True)
|
||||
.values_list("song_id", flat=True)
|
||||
)
|
||||
else:
|
||||
context["likes_ids"] = []
|
||||
return context
|
||||
|
||||
|
||||
class ListCreatePlaylistAPIView(generics.ListCreateAPIView):
|
||||
|
@ -18,7 +33,9 @@ def get_queryset(self):
|
|||
return Playlist.objects.filter(creator=self.request.user)
|
||||
|
||||
|
||||
class RetrieveUpdateDestroyPlaylistAPIView(generics.RetrieveUpdateDestroyAPIView):
|
||||
class RetrieveUpdateDestroyPlaylistAPIView(
|
||||
LikedSongsContextMixin, generics.RetrieveUpdateDestroyAPIView
|
||||
):
|
||||
lookup_field = "slug"
|
||||
lookup_url_kwarg = "slug"
|
||||
permission_classes = [IsCreatorOrReadOnly]
|
||||
|
@ -34,12 +51,23 @@ def get_object(self):
|
|||
return self.object
|
||||
|
||||
|
||||
class ListCreateSongAPIView(generics.ListCreateAPIView):
|
||||
class ListCreateSongAPIView(LikedSongsContextMixin, generics.ListCreateAPIView):
|
||||
serializer_class = ListSongSerializer
|
||||
permission_classes = [IsCreatorOrReadOnly]
|
||||
permission_classes = [IsAdminOrReadOnly]
|
||||
pagination_class = StandardResultsSetPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return Song.objects.all()
|
||||
if self.request.user.is_authenticated:
|
||||
return (
|
||||
Song.objects.exclude(
|
||||
id__in=SongUserRating.objects.filter(
|
||||
user=self.request.user
|
||||
).values_list("song_id", flat=True)
|
||||
)
|
||||
.prefetch_related("authors")
|
||||
.select_related("album")
|
||||
)
|
||||
return Song.objects.all().prefetch_related("authors").select_related("album")
|
||||
|
||||
|
||||
class RetrieveUpdateDestroySongAPIView(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
@ -56,3 +84,21 @@ def get_object(self):
|
|||
if not self.object:
|
||||
self.object = super().get_object()
|
||||
return self.object
|
||||
|
||||
|
||||
class ListLikedSongsAPIView(generics.ListAPIView):
|
||||
serializer_class = ListSongSerializer
|
||||
pagination_class = StandardResultsSetPagination
|
||||
authentication_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Song.objects.cache()
|
||||
.filter(
|
||||
id__in=self.request.user.song_likes.objects.cache()
|
||||
.all()
|
||||
.values_list("song_id", flat=True)
|
||||
)
|
||||
.prefetch_related("authors")
|
||||
.select_related("album")
|
||||
)
|
||||
|
|
56
akarpov/music/migrations/0010_song_likes_songuserrating.py
Normal file
56
akarpov/music/migrations/0010_song_likes_songuserrating.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
# Generated by Django 4.2.7 on 2023-12-07 22:36
|
||||
|
||||
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", "0009_alter_songinque_name_alter_songinque_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="song",
|
||||
name="likes",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SongUserRating",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("like", models.BooleanField(default=True)),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"song",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="user_likes",
|
||||
to="music.song",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="song_likes",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created"],
|
||||
"unique_together": {("song", "user")},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -43,6 +43,7 @@ class Song(BaseImageModel, ShortLinkModel):
|
|||
"users.User", related_name="songs", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
meta = models.JSONField(blank=True, null=True)
|
||||
likes = models.IntegerField(default=0)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("music:song", kwargs={"slug": self.slug})
|
||||
|
@ -128,3 +129,21 @@ class RadioSong(models.Model):
|
|||
start = models.DateTimeField(auto_now=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
song = models.ForeignKey("Song", related_name="radio", on_delete=models.CASCADE)
|
||||
|
||||
|
||||
class SongUserRating(models.Model):
|
||||
song = models.ForeignKey(
|
||||
"Song", related_name="user_likes", on_delete=models.PROTECT
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
"users.User", related_name="song_likes", on_delete=models.CASCADE
|
||||
)
|
||||
like = models.BooleanField(default=True)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} {self.song} {'like' if self.like else 'dislike'}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["song", "user"]
|
||||
ordering = ["-created"]
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
from PIL import Image
|
||||
from pydub import AudioSegment
|
||||
from pytube import Search, YouTube
|
||||
from yt_dlp import YoutubeDL
|
||||
|
||||
from akarpov.music.models import Song
|
||||
from akarpov.music.services.db import load_track
|
||||
|
@ -67,7 +66,7 @@ def parse_description(description: str) -> list:
|
|||
def download_from_youtube_link(link: str, user_id: int) -> Song:
|
||||
song = None
|
||||
|
||||
with YoutubeDL(ydl_opts) as ydl:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info_dict = ydl.extract_info(link, download=False)
|
||||
title = info_dict.get("title", None)
|
||||
description = info_dict.get("description", None)
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import os
|
||||
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.db.models.signals import post_delete, post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from akarpov.music.models import Song
|
||||
from akarpov.music.models import Song, SongUserRating
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Song)
|
||||
|
@ -16,3 +16,21 @@ def auto_delete_file_on_delete(sender, instance, **kwargs):
|
|||
@receiver(post_save)
|
||||
def send_que_status(sender, instance, created, **kwargs):
|
||||
...
|
||||
|
||||
|
||||
@receiver(pre_save, sender=SongUserRating)
|
||||
def create_or_update_rating(sender, instance: SongUserRating, **kwargs):
|
||||
song = instance.song
|
||||
if instance.pk:
|
||||
previous = SongUserRating.objects.get(pk=instance.pk)
|
||||
if previous.like != instance.like:
|
||||
if instance.like:
|
||||
song.likes += 2
|
||||
else:
|
||||
song.likes -= 2
|
||||
else:
|
||||
if instance.like:
|
||||
song.likes += 1
|
||||
else:
|
||||
song.likes -= 1
|
||||
song.save(update_fields=["likes"])
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from rest_framework import generics, permissions
|
||||
|
||||
from akarpov.common.api import StandardResultsSetPagination
|
||||
from akarpov.common.api.pagination import StandardResultsSetPagination
|
||||
from akarpov.notifications.models import Notification
|
||||
from akarpov.notifications.providers.site.api.serializers import (
|
||||
SiteNotificationSerializer,
|
||||
|
|
|
@ -531,3 +531,37 @@ p {
|
|||
.nav-active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.username {
|
||||
max-width: 120px; /* Adjust as needed */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.username:hover {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tooltip CSS */
|
||||
.username:hover::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: black;
|
||||
color: white;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
font-size: smaller;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Responsive font size */
|
||||
@media (max-width: 600px) {
|
||||
.username {
|
||||
font-size: smaller;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +1,29 @@
|
|||
/* Project specific Javascript goes here. */
|
||||
|
||||
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";
|
||||
}
|
||||
|
|
177
akarpov/static/js/ws_script.js
Normal file
177
akarpov/static/js/ws_script.js
Normal file
|
@ -0,0 +1,177 @@
|
|||
// Determine the correct WebSocket protocol to use
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host; // Assuming host includes the port if needed
|
||||
const socketPath = `${protocol}//${host}/ws/radio/`;
|
||||
|
||||
let socket = new WebSocket(socketPath);
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Your existing timeSince function can remain unchanged
|
||||
|
||||
// Update the onmessage handler here as needed for the music player
|
||||
socket.onmessage = function(event) {
|
||||
let data = JSON.parse(event.data);
|
||||
// Process the data
|
||||
};
|
||||
|
||||
// This function will attempt to reconnect the WebSocket after a 5-second delay
|
||||
async function reconnectSocket() {
|
||||
console.log("Radio socket disconnected, reconnecting...");
|
||||
let socketClosed = true;
|
||||
await sleep(5000); // Wait 5 seconds before attempting to reconnect
|
||||
while (socketClosed) {
|
||||
try {
|
||||
socket = new WebSocket(socketPath);
|
||||
socket.onmessage = onMessageHandler; // Reassign your onmessage handler
|
||||
socket.onclose = onSocketCloseHandler; // Reassign the onclose handler to this function
|
||||
socketClosed = false; // Exit the loop if the connection is successful
|
||||
} catch (e) {
|
||||
console.log("Can't connect to socket, retrying in 1 second...");
|
||||
await sleep(1000); // Wait 1 second before retrying
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Define a separate function for the onclose event
|
||||
const onSocketCloseHandler = async function(event) {
|
||||
if (!event.wasClean) {
|
||||
// Only attempt to reconnect if the socket did not close cleanly
|
||||
await reconnectSocket();
|
||||
}
|
||||
};
|
||||
|
||||
// Assign the onclose handler to the socket
|
||||
socket.onclose = onSocketCloseHandler;
|
||||
let isAdmin = false; // This will be set based on socket connect data for admin TODO: retrieve from server
|
||||
|
||||
|
||||
|
||||
function displaySong(data) {
|
||||
// Display song details using jQuery to manipulate the DOM
|
||||
$('#song-info').html(/* HTML structure for song info */);
|
||||
// Update admin controls if isAdmin is true
|
||||
if (isAdmin) {
|
||||
$('#admin-controls').show();
|
||||
// Add slider and other controls
|
||||
}
|
||||
// Add song to history
|
||||
addToHistory(data);
|
||||
}
|
||||
|
||||
function displayAdminControls(data) {
|
||||
// Display admin controls if user is admin
|
||||
if (isAdmin) {
|
||||
$('#admin-controls').html(/* HTML structure for admin controls */);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSong(data) {
|
||||
// Update the song information on the page
|
||||
displaySong(data);
|
||||
}
|
||||
|
||||
function addToHistory(songData) {
|
||||
var authors = songData.authors.map(author => author.name).join(", ");
|
||||
var historyItemHtml = `
|
||||
<div class="card mb-3" style="max-width: 540px;">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-4">
|
||||
<img src="${songData.image}" class="img-fluid rounded-start" alt="${songData.name}">
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">${songData.name}</h5>
|
||||
<p class="card-text"><small class="text-muted">by ${authors}</small></p>
|
||||
<p class="card-text">${songData.album.name}</p>
|
||||
<!-- Admin controls would go here if needed -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
$('#history').prepend(historyItemHtml); // prepend to add it to the top of the list
|
||||
}
|
||||
|
||||
// Functionality to handle sliding track length and selecting track to play
|
||||
// ...
|
||||
|
||||
// ws_script.js
|
||||
|
||||
$(document).ready(function() {
|
||||
var audioContext;
|
||||
var analyser;
|
||||
var started = false; // Flag to indicate if the audio context has started
|
||||
var audio = document.getElementById('audio-player');
|
||||
var canvas = document.getElementById('audio-visualization');
|
||||
var ctx = canvas.getContext('2d');
|
||||
|
||||
// Function to initialize the AudioContext and analyser
|
||||
function initAudioContext() {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
analyser = audioContext.createAnalyser();
|
||||
var audioSrc = audioContext.createMediaElementSource(audio);
|
||||
audioSrc.connect(analyser);
|
||||
analyser.connect(audioContext.destination);
|
||||
}
|
||||
|
||||
// Function to start or resume the audio context on user interaction
|
||||
function startOrResumeContext() {
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume().then(() => {
|
||||
console.log('Playback resumed successfully');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Volume control listener
|
||||
$('#volume-control').on('input change', function() {
|
||||
if (audio) {
|
||||
audio.volume = $(this).val() / 100;
|
||||
}
|
||||
});
|
||||
|
||||
// Listener for play/pause button
|
||||
$('#play-pause-button').on('click', function() {
|
||||
if (!started) {
|
||||
initAudioContext();
|
||||
started = true;
|
||||
}
|
||||
|
||||
startOrResumeContext(); // Resume the audio context if needed
|
||||
|
||||
if (audio.paused) {
|
||||
audio.play().then(() => {
|
||||
console.log('Audio playing');
|
||||
}).catch((e) => {
|
||||
console.error('Error playing audio:', e);
|
||||
});
|
||||
} else {
|
||||
audio.pause();
|
||||
console.log('Audio paused');
|
||||
}
|
||||
});
|
||||
|
||||
function renderFrame() {
|
||||
if (!analyser) return;
|
||||
|
||||
requestAnimationFrame(renderFrame);
|
||||
var fbc_array = new Uint8Array(analyser.frequencyBinCount);
|
||||
analyser.getByteFrequencyData(fbc_array);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas
|
||||
// Visualization code...
|
||||
}
|
||||
|
||||
// Error event listener for the audio element
|
||||
audio.addEventListener('error', function(e) {
|
||||
console.error('Error with audio playback:', e);
|
||||
});
|
||||
|
||||
// Attempt to play the audio and start visualization when the audio can play through
|
||||
audio.addEventListener('canplaythrough', function() {
|
||||
startOrResumeContext();
|
||||
renderFrame(); // Start the visualizer
|
||||
});
|
||||
});
|
|
@ -21,6 +21,9 @@
|
|||
|
||||
<!-- Latest compiled and minified Bootstrap CSS -->
|
||||
<link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet">
|
||||
{% if request.user.is_authenticated %}
|
||||
<link href="{{ request.user.get_theme_url }}" rel="stylesheet">
|
||||
{% 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 -->
|
||||
|
@ -65,12 +68,13 @@
|
|||
<i class="fs-5 bi-folder-fill"></i><span class="ms-1 d-none d-sm-inline">Files</span></a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="dropdown">
|
||||
<a href="#" class="text-muted nav-link dropdown-toggle px-sm-0 px-1" id="dropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle text-muted px-sm-0 px-1" href="#" id="navbarDropdownMenuLink" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fs-5 bi-terminal-fill"></i><span class="ms-1 d-none d-sm-inline">Apps</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-dark text-small shadow" aria-labelledby="dropdown">
|
||||
<ul class="dropdown-menu dropdown-menu-dark text-small shadow" aria-labelledby="navbarDropdownMenuLink">
|
||||
<li><a class="dropdown-item {% active_link 'tools:qr:create' %}" href="{% url 'tools:qr:create' %}">QR generator</a></li>
|
||||
<li><a class="dropdown-item {% active_link 'tools:uuid:main' %}" href="{% url 'tools:uuid:main' %}">UUID tools</a></li>
|
||||
<li><a class="dropdown-item {% active_link 'tools:shortener:create' %}" href="{% url 'tools:shortener:create' %}">URL shortcuter</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
@ -84,7 +88,7 @@
|
|||
{% if request.user.is_authenticated %}
|
||||
<a href="#" class="d-flex align-items-center text-white text-decoration-none dropdown-toggle" id="dropdownUser1" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{% if request.user.image_cropped %}<img src="{{ request.user.image_cropped.url }}" alt="hugenerd" width="28" height="28" class="rounded-circle">{% endif %}
|
||||
<span class="d-none d-sm-inline mx-1">{{ request.user.username }}</span>
|
||||
<span class="d-none d-sm-inline mx-1 username" title="{{ request.user.username }}">{{ request.user.username }}</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-dark text-small shadow" aria-labelledby="dropdownUser1">
|
||||
<li><a class="dropdown-item {% active_link 'users:update' %}" href="{% url 'users:update' %}">Settings</a></li>
|
||||
|
@ -146,40 +150,11 @@
|
|||
{% 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 %}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host; // Assuming host includes the port if needed
|
||||
const socketPath = `${protocol}//${host}/ws/notifications/`;
|
||||
|
||||
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";
|
||||
}
|
||||
let notification_socket = new WebSocket(socketPath);
|
||||
|
||||
const toastContainer = document.getElementById('toastContainer')
|
||||
|
||||
|
@ -205,18 +180,17 @@
|
|||
toastBootstrap.show()
|
||||
}
|
||||
|
||||
socket.onmessage = fn
|
||||
socket.onclose = async function(event) {
|
||||
notification_socket.onmessage = fn
|
||||
notification_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
|
||||
let cl = notification_socket.onclose
|
||||
notification_socket = new WebSocket(socketPath);
|
||||
notification_socket.onmessage = fn
|
||||
notification_socket.onclose = cl
|
||||
socketClosed = false
|
||||
} catch (e) {
|
||||
console.log("Can't connect to socket, reconnecting...")
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<link href=" https://cdn.jsdelivr.net/npm/bootstrap-select@1.13.18/dist/css/bootstrap-select.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.row {
|
||||
display: -webkit-box;
|
||||
|
@ -62,6 +63,29 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form id="searchForm" class="row" method="get">
|
||||
<div class="col-lg-9 col-md-8 col-sm-7">
|
||||
<input type="text" class="form-control" placeholder="Search..." name="query" aria-label="Search" value="{{ request.GET.query|default_if_none:'' }}">
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-sm-4">
|
||||
{# <select class="selectpicker form-select" name="search_type" title="Choose...">#}
|
||||
{# <option data-icon="bi bi-brain" value="neuro" {% if request.GET.search_type == "neuro" %}selected{% endif %}>Neuro Search</option>#}
|
||||
{# <option data-icon="bi bi-textarea-t" value="case_sensitive" {% if request.GET.search_type == "case_sensitive" %}selected{% endif %}>Case Sensitive</option>#}
|
||||
{# <option data-icon="bi bi-file-earmark-binary" value="byte_search" {% if request.GET.search_type == "byte_search" %}selected{% endif %}>Byte Search</option>#}
|
||||
{# <option data-icon="bi bi-stars" value="similarity" {% if request.GET.search_type == "similarity" %}selected{% endif %}>Similarity Search</option>#}
|
||||
{# </select>#}
|
||||
<select name="search_type" class="form-select" id="inlineFormSelectPref">
|
||||
<option data-icon="bi bi-brain" value="neuro" {% if request.GET.search_type == "neuro" %}selected{% endif %}>Neuro Search</option>
|
||||
<option data-icon="bi bi-textarea-t" value="case_sensitive" {% if request.GET.search_type == "case_sensitive" %}selected{% endif %}>Case Sensitive</option>
|
||||
<option data-icon="bi bi-file-earmark-binary" value="byte_search" {% if request.GET.search_type == "byte_search" %}selected{% endif %}>Byte Search</option>
|
||||
<option data-icon="bi bi-stars" value="similarity" {% if request.GET.search_type == "similarity" %}selected{% endif %}>Similarity Search</option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
<div class="col-lg-1 col-md-1 col-sm-2">
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-search"></i> Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="row">
|
||||
{% if request.user.is_authenticated and is_folder_owner %}
|
||||
<div class="col-lg-2 col-xxl-2 col-md-4 col-sm-6 col-xs-12 mb-3 m-3 d-flex align-items-stretch card">
|
||||
|
@ -108,7 +132,7 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for file in basefileitem_list %}
|
||||
{% for file in object_list %}
|
||||
<div class="col-lg-2 col-xxl-2 col-md-4 col-sm-6 col-xs-12 mb-3 m-3 d-flex align-items-stretch card justify-content-center">
|
||||
{% if file.is_file %}
|
||||
<div class="card-body d-flex flex-column">
|
||||
|
@ -172,7 +196,12 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block inline_javascript %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap-select@1.13.18/js/bootstrap-select.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
$(function () {
|
||||
$('selectpicker').selectpicker();
|
||||
});
|
||||
|
||||
$.notify.defaults(
|
||||
{
|
||||
// whether to hide the notification on click
|
||||
|
@ -225,7 +254,7 @@
|
|||
} else {
|
||||
md5 = spark.end();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function read_next_chunk() {
|
||||
var reader = new FileReader();
|
||||
|
|
|
@ -1 +1,36 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: start; /* Align items to the start of the container */
|
||||
align-items: stretch; /* Stretch items to fill the container */
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
/* Adjust the margin as needed */
|
||||
margin: 5px;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto; /* Automatically adjust the basis based on the content size */
|
||||
}
|
||||
|
||||
.gallery-item img {
|
||||
width: 100%; /* Make image responsive */
|
||||
height: auto; /* Maintain aspect ratio */
|
||||
display: block; /* Remove bottom space under the image */
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="gallery">
|
||||
{% for image in object_list %}
|
||||
<div class="gallery-item">
|
||||
<img src="{{ image.url }}" alt="Image">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
150
akarpov/templates/gallery/image_form.html
Normal file
150
akarpov/templates/gallery/image_form.html
Normal file
|
@ -0,0 +1,150 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" action="{% url 'api:gallery:list-create-all' %}" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.management_form }}
|
||||
{% for field in form %}
|
||||
{{ field | as_crispy_field }}
|
||||
{% endfor %}
|
||||
<input type="file" id="imageInput" multiple accept="image/*" class="form-control" />
|
||||
<div class="preview mt-3" id="preview"></div>
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
|
||||
<div id="progress-bar" style="width:0; height:20px; background:green;"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script src="{% static 'js/jquery.min.js' %}"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
let form = document.querySelector('form');
|
||||
let imageInput = document.getElementById('imageInput');
|
||||
const preview = document.getElementById('preview');
|
||||
let currentFiles = [];
|
||||
|
||||
imageInput.addEventListener('change', function () {
|
||||
updateFiles(this.files);
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
function updateFiles(newFiles) {
|
||||
currentFiles = Array.from(newFiles);
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
preview.innerHTML = '';
|
||||
currentFiles.forEach((file, index) => {
|
||||
let imgContainer = document.createElement('div');
|
||||
imgContainer.classList.add('position-relative', 'd-inline-block', 'm-2');
|
||||
imgContainer.setAttribute('data-index', index); // Set the data-index attribute
|
||||
|
||||
let img = document.createElement('img');
|
||||
img.classList.add('img-thumbnail');
|
||||
img.style.width = '150px';
|
||||
img.style.height = '150px';
|
||||
imgContainer.appendChild(img);
|
||||
|
||||
let deleteButton = document.createElement('span');
|
||||
deleteButton.classList.add('material-icons', 'position-absolute', 'top-0', 'end-0', 'btn', 'btn-danger');
|
||||
deleteButton.innerText = 'delete';
|
||||
deleteButton.style.cursor = 'pointer';
|
||||
deleteButton.onclick = function () {
|
||||
currentFiles.splice(index, 1);
|
||||
updatePreview();
|
||||
imageInput.value = "";
|
||||
};
|
||||
imgContainer.appendChild(deleteButton);
|
||||
|
||||
let reader = new FileReader();
|
||||
reader.onload = (function (aImg) {
|
||||
return function (e) {
|
||||
aImg.src = e.target.result;
|
||||
};
|
||||
})(img);
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
preview.appendChild(imgContainer);
|
||||
});
|
||||
}
|
||||
|
||||
function createProgressBar(index) {
|
||||
const progressBarContainer = document.createElement('div');
|
||||
progressBarContainer.classList.add('progress', 'mb-2');
|
||||
progressBarContainer.setAttribute('id', 'progress-container-' + index);
|
||||
|
||||
const progressBar = document.createElement('div');
|
||||
progressBar.id = 'progress-bar-' + index;
|
||||
progressBar.classList.add('progress-bar');
|
||||
progressBar.setAttribute('role', 'progressbar');
|
||||
progressBar.setAttribute('aria-valuenow', '0');
|
||||
progressBar.setAttribute('aria-valuemin', '0');
|
||||
progressBar.setAttribute('aria-valuemax', '100');
|
||||
progressBar.style.width = '0%';
|
||||
progressBar.style.height = '20px'; // Set the height of the progress bar
|
||||
|
||||
progressBarContainer.appendChild(progressBar);
|
||||
form.appendChild(progressBarContainer); // Append the progress bar container to the form
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Get the CSRF token from the hidden input
|
||||
const csrfToken = document.querySelector('input[name="csrfmiddlewaretoken"]').value;
|
||||
|
||||
// Clear previous progress bars if any
|
||||
document.querySelectorAll('.progress').forEach(function(progressBar) {
|
||||
progressBar.remove();
|
||||
});
|
||||
|
||||
// Create a new progress bar for each file
|
||||
currentFiles.forEach((file, index) => createProgressBar(index));
|
||||
|
||||
// Perform the upload for each file
|
||||
currentFiles.forEach((file, index) => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('collection', document.querySelector('#id_collection').value);
|
||||
formData.append('public', document.querySelector('#id_public').checked);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', form.action, true);
|
||||
xhr.setRequestHeader('X-CSRFToken', csrfToken);
|
||||
|
||||
xhr.upload.onprogress = function (e) {
|
||||
if (e.lengthComputable) {
|
||||
const progressBar = document.getElementById('progress-bar-' + index);
|
||||
const percentage = (e.loaded / e.total) * 100;
|
||||
progressBar.style.width = percentage + '%';
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = function () {
|
||||
if (this.status === 200 || this.status === 201) {
|
||||
console.log('Upload complete for file', index);
|
||||
// Remove the image preview and progress bar for the uploaded file
|
||||
const imgPreview = preview.querySelector(`div[data-index="${index}"]`);
|
||||
if (imgPreview) {
|
||||
preview.removeChild(imgPreview);
|
||||
}
|
||||
const progressBarContainer = document.getElementById('progress-container-' + index);
|
||||
if (progressBarContainer) {
|
||||
progressBarContainer.remove();
|
||||
}
|
||||
// Remove the file from the currentFiles array
|
||||
currentFiles = currentFiles.filter((_, i) => i !== index);
|
||||
} else {
|
||||
console.error('Upload failed for file', index, ':', this.responseText);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1 +1,49 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
{% for collection_preview in collection_previews %}
|
||||
<div class="col-md-4 col-sm-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<!-- Folder Icon -->
|
||||
<div class="folder-icon">
|
||||
<i class="fas fa-folder" style="font-size: 6em;"></i>
|
||||
</div>
|
||||
<h5 class="card-title text-center">{{ collection_preview.collection.name }}</h5>
|
||||
<!-- Image Thumbnails -->
|
||||
<div class="folder-images d-flex flex-wrap justify-content-center">
|
||||
{% for image in collection_preview.preview_images %}
|
||||
<img src="{{ image.image_cropped.url }}" class="img-thumbnail m-1" alt="{{ image }}" style="width: 50px; height: 50px; object-fit: cover;">
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a href="{{ collection_preview.collection.get_absolute_url }}" class="btn btn-primary btn-block">View Collection</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p>No collections found.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.folder-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 120px; /* Adjust size as needed */
|
||||
}
|
||||
.folder-images img {
|
||||
transition: transform 0.2s; /* Smooth transition for image hover */
|
||||
}
|
||||
.folder-images img:hover {
|
||||
transform: scale(1.1); /* Slightly enlarge images on hover */
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
|
1
akarpov/templates/gallery/upload.html
Normal file
1
akarpov/templates/gallery/upload.html
Normal file
|
@ -0,0 +1 @@
|
|||
{% extends 'base.html' %}
|
22
akarpov/templates/music/playlist_create.html
Normal file
22
akarpov/templates/music/playlist_create.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
{% extends "base.html" %}
|
||||
{% load crispy_forms_filters %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}creating playlist on akarpov{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form class="pt-2" enctype="multipart/form-data" method="POST" id="designer-form">
|
||||
{% csrf_token %}
|
||||
{{ form.media }}
|
||||
{% for field in form %}
|
||||
{{ field|as_crispy_field }}
|
||||
{% endfor %}
|
||||
<div class="mt-4 flex justify-end space-x-4">
|
||||
<button class="btn btn-success" type="submit" id="submit">
|
||||
<span class="spinner-border spinner-border-sm" id="spinner" role="status" aria-hidden="true" style="display: none"></span>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
<!-- TODO: add song select via API here -->
|
||||
</form>
|
||||
{% endblock %}
|
59
akarpov/templates/music/playlist_list.html
Normal file
59
akarpov/templates/music/playlist_list.html
Normal file
|
@ -0,0 +1,59 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.music-container {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.music-container > div {
|
||||
flex: 1 0 50%;
|
||||
height: 50%;
|
||||
}
|
||||
.music-container > div:nth-child(1):last-child, .music-container > div:nth-child(2):last-child, .music-container > div:nth-child(3):last-child {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.music-container > div:nth-child(1):last-child:nth-child(1):last-child, .music-container > div:nth-child(2):last-child:nth-child(1):last-child, .music-container > div:nth-child(3):last-child:nth-child(1):last-child {
|
||||
height: 100%;
|
||||
}
|
||||
.music-container > div img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="ms-3 row">
|
||||
<div class="col-lg-2 col-xxl-2 col-md-4 col-sm-6 col-xs-12 mb-3 m-3 d-flex align-items-stretch card">
|
||||
<div class="card-body d-flex flex-column justify-content-center align-items-center">
|
||||
<h5 class="card-title">Create Playlist</h5>
|
||||
<p class="card-text">Create your own playlist</p>
|
||||
<a href="{% url 'music:create_playlist' %}" class="btn btn-primary"><i style="bi bi-plus"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
{% for playlist in playlist_list %}
|
||||
<div>
|
||||
<div class="music-container">
|
||||
<div>
|
||||
<img src="https://img.freepik.com/free-photo/people-making-hands-heart-shape-silhouette-sunset_53876-15987.jpg" alt="">
|
||||
</div>
|
||||
<div>
|
||||
<img src="https://thumbs.dreamstime.com/b/bee-flower-27533578.jpg" alt="">
|
||||
</div>
|
||||
<div>
|
||||
<img src="https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTszhmO2dRnPW3Co-zQF_rqipldQM77r2Ut6Q&usqp=CAU" alt="">
|
||||
</div>
|
||||
<div>
|
||||
<img src="https://img.freepik.com/free-photo/wide-angle-shot-single-tree-growing-clouded-sky-during-sunset-surrounded-by-grass_181624-22807.jpg" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ playlist }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,330 +1,77 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
body {
|
||||
background-color: #EDEDED;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: "Open Sans", sans-serif;
|
||||
font-size: 13pt;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: white;
|
||||
cursor: default;
|
||||
#song-info .card {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-family: "Open Sans", sans-serif;
|
||||
font-size: 8pt;
|
||||
font-weight: 400;
|
||||
cursor: default;
|
||||
#history .card {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: "Open Sans", sans-serif;
|
||||
font-size: 13pt;
|
||||
font-weight: 300;
|
||||
color: white;
|
||||
cursor: default;
|
||||
#admin-controls {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.player {
|
||||
height: 190px;
|
||||
width: 430px;
|
||||
background-color: #1E2125;
|
||||
position: absolute;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
}
|
||||
.player ul {
|
||||
list-style: none;
|
||||
}
|
||||
.player ul li {
|
||||
display: inline-block;
|
||||
.card-img-top {
|
||||
width: 100%;
|
||||
height: 200px; /* You can adjust as needed */
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.cover img {
|
||||
height: 190px;
|
||||
width: 190px;
|
||||
.audio-player {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.info h1 {
|
||||
margin-top: 15px;
|
||||
margin-left: 180px;
|
||||
line-height: 0;
|
||||
}
|
||||
.info h4 {
|
||||
margin-left: 180px;
|
||||
line-height: 20px;
|
||||
color: #636367;
|
||||
}
|
||||
.info h2 {
|
||||
margin-left: 180px;
|
||||
.range-slider {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.button-items {
|
||||
margin-left: 180px;
|
||||
}
|
||||
|
||||
#slider {
|
||||
width: 182px;
|
||||
height: 4px;
|
||||
background: #151518;
|
||||
border-radius: 2px;
|
||||
}
|
||||
#slider div {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
margin-top: 1px;
|
||||
background: #EF6DBC;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
#timer {
|
||||
color: #494B4E;
|
||||
line-height: 0;
|
||||
font-size: 9pt;
|
||||
float: right;
|
||||
font-family: Arial, Sans-Serif;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.controls svg:nth-child(2) {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
#play {
|
||||
padding: 0 3px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
x: 0px;
|
||||
y: 0px;
|
||||
enable-background: new 0 0 25 25;
|
||||
}
|
||||
#play g {
|
||||
stroke: #FEFEFE;
|
||||
stroke-width: 1;
|
||||
stroke-miterlimit: 10;
|
||||
}
|
||||
#play g path {
|
||||
fill: #FEFEFE;
|
||||
}
|
||||
|
||||
#play:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
#play:hover g {
|
||||
stroke: #8F4DA9;
|
||||
cursor: pointer;
|
||||
}
|
||||
#play:hover g path {
|
||||
fill: #9b59b6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.step-backward {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
x: 0px;
|
||||
y: 0px;
|
||||
enable-background: new 0 0 25 25;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.step-backward g polygon {
|
||||
fill: #FEFEFE;
|
||||
}
|
||||
|
||||
.step-foreward {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
x: 0px;
|
||||
y: 0px;
|
||||
enable-background: new 0 0 25 25;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.step-foreward g polygon {
|
||||
fill: #FEFEFE;
|
||||
}
|
||||
|
||||
#pause {
|
||||
x: 0px;
|
||||
y: 0px;
|
||||
enable-background: new 0 0 25 25;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
position: absolute;
|
||||
margin-left: -38px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#pause rect {
|
||||
fill: white;
|
||||
}
|
||||
|
||||
#pause:hover rect {
|
||||
fill: #8F4DA9;
|
||||
}
|
||||
|
||||
.step-backward g polygon:hover, .step-foreward g polygon:hover {
|
||||
fill: #EF6DBC;
|
||||
cursor: pointer;
|
||||
}
|
||||
#skip p {
|
||||
color: #2980b9;
|
||||
}
|
||||
#skip p:hover {
|
||||
color: #e74c3c;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.expend {
|
||||
padding: 0.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.expend svg:hover g polygon {
|
||||
fill: #EF6DBC;
|
||||
.range-slider input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="player">
|
||||
<ul>
|
||||
<li class="cover"><img id="cover" src="" alt=""/></li>
|
||||
<li class="info">
|
||||
<h1 id="artist"></h1>
|
||||
<h4 id="album"></h4>
|
||||
<h2 id="name">I Need You Back</h2>
|
||||
|
||||
<div class="button-items">
|
||||
<audio id="music">
|
||||
<source id="music-src" type="audio/mp3">
|
||||
<div class="container mt-5 music-body">
|
||||
<!-- Music Player and Visualization -->
|
||||
<div class="music-player-container">
|
||||
<canvas id="audio-visualization" width="800" height="100"></canvas>
|
||||
<audio id="audio-player" controls>
|
||||
<source src="" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<div id="slider"><div id="elapsed"></div></div>
|
||||
<p id="timer">0:00</p>
|
||||
<div class="controls">
|
||||
<span class="expend"><svg class="step-backward" viewBox="0 0 25 25" xml:space="preserve">
|
||||
<g><polygon points="4.9,4.3 9,4.3 9,11.6 21.4,4.3 21.4,20.7 9,13.4 9,20.7 4.9,20.7"/></g>
|
||||
</svg></span>
|
||||
</div>
|
||||
|
||||
<svg id="play" viewBox="0 0 25 25" xml:space="preserve">
|
||||
<defs><rect x="-49.5" y="-132.9" width="446.4" height="366.4"/></defs>
|
||||
<g><circle fill="none" cx="12.5" cy="12.5" r="10.8"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.7,6.9V18c0,0,0.2,1.4,1.8,0l8.1-4.8c0,0,1.2-1.1-1-2L9.8,6.5 C9.8,6.5,9.1,6,8.7,6.9z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
||||
<svg id="pause" viewBox="0 0 25 25" xml:space="preserve">
|
||||
<g>
|
||||
<rect x="6" y="4.6" width="3.8" height="15.7"/>
|
||||
<rect x="14" y="4.6" width="3.9" height="15.7"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<span class="expend"><svg class="step-foreward" viewBox="0 0 25 25" xml:space="preserve">
|
||||
<g><polygon points="20.7,4.3 16.6,4.3 16.6,11.6 4.3,4.3 4.3,20.7 16.7,13.4 16.6,20.7 20.7,20.7"/></g>
|
||||
</svg></span>
|
||||
<!-- Now Playing Information -->
|
||||
<div class="now-playing">
|
||||
<div class="track-image">
|
||||
<img id="track-image" src="" alt="Track Image">
|
||||
</div>
|
||||
<div class="artist-info">
|
||||
<!-- Dynamically populate artist info -->
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div id="admin-controls" class="mb-3" style="display: none;">
|
||||
<!-- Admin Controls will be inserted here -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block inline_javascript %}
|
||||
|
||||
<script>
|
||||
const music = document.getElementById("music");
|
||||
|
||||
async function playAudio() {
|
||||
try {
|
||||
await music.play();
|
||||
playButton.style.visibility = "hidden";
|
||||
pause.style.visibility = "visible";
|
||||
} catch (err) {
|
||||
playButton.style.visibility = "visible";
|
||||
pause.style.visibility = "hidden";
|
||||
}
|
||||
}
|
||||
|
||||
const music_src = document.getElementById("music-src")
|
||||
const cover_src = document.getElementById("cover")
|
||||
const name = document.getElementById("name")
|
||||
const album = document.getElementById("album")
|
||||
const artist = document.getElementById("artist")
|
||||
|
||||
const socket = new WebSocket(
|
||||
'ws://'
|
||||
+ window.location.host
|
||||
+ '/ws/radio/'
|
||||
);
|
||||
|
||||
socket.onmessage = function(e) {
|
||||
const data = JSON.parse(e.data);
|
||||
console.log(data)
|
||||
music_src.src = data.file
|
||||
if (data.image !== null) {
|
||||
cover_src.src = data.image
|
||||
}
|
||||
name.innerText = data.name
|
||||
playAudio()
|
||||
};
|
||||
|
||||
socket.onclose = function(e) {
|
||||
console.error('Chat socket closed unexpectedly');
|
||||
};
|
||||
const playButton = document.getElementById("play");
|
||||
const pauseButton = document.getElementById("pause");
|
||||
const playhead = document.getElementById("elapsed");
|
||||
const timeline = document.getElementById("slider");
|
||||
const timer = document.getElementById("timer");
|
||||
let duration;
|
||||
pauseButton.style.visibility = "hidden";
|
||||
|
||||
const timelineWidth = timeline.offsetWidth - playhead.offsetWidth;
|
||||
music.addEventListener("timeupdate", timeUpdate, false);
|
||||
|
||||
function timeUpdate() {
|
||||
const playPercent = timelineWidth * (music.currentTime / duration);
|
||||
playhead.style.width = playPercent + "px";
|
||||
|
||||
const secondsIn = Math.floor(((music.currentTime / duration) / 3.5) * 100);
|
||||
if (secondsIn <= 9) {
|
||||
timer.innerHTML = "0:0" + secondsIn;
|
||||
} else {
|
||||
timer.innerHTML = "0:" + secondsIn;
|
||||
}
|
||||
}
|
||||
|
||||
playButton.onclick = function() {
|
||||
playAudio()
|
||||
}
|
||||
|
||||
pauseButton.onclick = function() {
|
||||
music.pause();
|
||||
playButton.style.visibility = "visible";
|
||||
pause.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
music.addEventListener("canplaythrough", function () {
|
||||
duration = music.duration;
|
||||
}, false);
|
||||
</script>
|
||||
|
||||
<h2>History</h2>
|
||||
<div id="history" class="mb-3">
|
||||
<!-- Song History will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{% static 'js/jquery.js' %}"></script>
|
||||
<script src="{% static 'js/ws_script.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
|
3
akarpov/templates/search/indexes/files/file_text.txt
Normal file
3
akarpov/templates/search/indexes/files/file_text.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
{{ object.name }}
|
||||
{{ object.description }}
|
||||
{{ object.content }}
|
26
akarpov/templates/socialaccount/signup.html
Normal file
26
akarpov/templates/socialaccount/signup.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
{% extends "socialaccount/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block head_title %}{% trans "Signup" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans "Sign Up" %}</h1>
|
||||
|
||||
<p>{% blocktrans with provider_name=account.get_provider.name site_name=site.name %}You are about to use your {{provider_name}} account to login to
|
||||
{{site_name}}. As a final step, please complete the following form:{% endblocktrans %}</p>
|
||||
|
||||
<form class="signup" id="signup_form" method="post" action="{% url 'socialaccount_signup' %}">
|
||||
{% csrf_token %}
|
||||
{{ form.media }}
|
||||
{% for field in form %}
|
||||
{{ field|as_crispy_field }}
|
||||
{% endfor %}
|
||||
{% if redirect_field_value %}
|
||||
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}" />
|
||||
{% endif %}
|
||||
<button class="btn btn-success" type="submit">{% trans "Sign Up" %} »</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
58
akarpov/templates/tools/uuid/main.html
Normal file
58
akarpov/templates/tools/uuid/main.html
Normal file
|
@ -0,0 +1,58 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row align-items-center justify-content-center d-flex">
|
||||
<div class="col-lg-12 d-flex align-items-stretch m-3">
|
||||
<div style="width: 100%" class="card text-center">
|
||||
<form class="card-body row justify-content-end ml-5 mr-5" method="get">
|
||||
<div class="col-lg-11 col-sm-10">
|
||||
<label for="uuid">Lookup uuid info</label><input {% if uuid %}value="{{ uuid }}" {% endif %}name="uuid" class="form-control" id="uuid" type="text" placeholder="insert uuid" />
|
||||
</div>
|
||||
<div class="col-lg-1 col-sm-2">
|
||||
<button class="btn btn-success mt-4" type="submit"><i class="bi-search"></i></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% if data %}
|
||||
<div class="col-lg-10">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">name</th>
|
||||
<th scope="col">value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, val in data.items %}
|
||||
<tr>
|
||||
<th scope="row">{{ key }}</th>
|
||||
<td>{{ val }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% for token, version in tokens %}
|
||||
<div class="col-lg-5 d-flex align-items-stretch justify-content-center m-3">
|
||||
<div class="card text-center ml-1">
|
||||
<div class="card-header">
|
||||
<h4 class="bg-gray-300 ">{{ token }}<button class="btn" data-clipboard-text="{{ token }}">
|
||||
<i style="font-size: 0.8em" class="bi bi-clipboard ml-2"></i>
|
||||
</button></h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
Generated: {{ now }}, Version: {{ version }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script src="{% static 'js/clipboard.min.js' %}"></script>
|
||||
<script>
|
||||
new ClipboardJS('.btn');
|
||||
</script>
|
||||
{% endblock %}
|
16
akarpov/templates/users/themes/create.html
Normal file
16
akarpov/templates/users/themes/create.html
Normal file
|
@ -0,0 +1,16 @@
|
|||
{% extends "base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}Create Theme{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form class="form-horizontal" enctype="multipart/form-data" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<div class="control-group">
|
||||
<div class="controls">
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -8,10 +8,34 @@
|
|||
<form class="form-horizontal" enctype="multipart/form-data" method="post" action="{% url 'users:update' %}">
|
||||
{% 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 %}
|
||||
|
|
6
akarpov/tools/api/serializers.py
Normal file
6
akarpov/tools/api/serializers.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
|
||||
class URLPathSerializer(serializers.Serializer):
|
||||
path = serializers.URLField()
|
||||
kwargs = serializers.DictField(help_text="{'slug': 'str', 'pk': 'int'}")
|
68
akarpov/tools/api/services.py
Normal file
68
akarpov/tools/api/services.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
from functools import lru_cache
|
||||
|
||||
from config import urls as urls_conf
|
||||
|
||||
urls = None
|
||||
|
||||
|
||||
def get_urls(urllist, name="") -> (list, list):
|
||||
res = []
|
||||
res_short = []
|
||||
for entry in urllist:
|
||||
if hasattr(entry, "url_patterns"):
|
||||
if entry.namespace != "admin":
|
||||
rres, rres_short = get_urls(
|
||||
entry.url_patterns,
|
||||
name + entry.namespace + ":" if entry.namespace else name,
|
||||
)
|
||||
res += rres
|
||||
res_short += rres_short
|
||||
else:
|
||||
res.append(
|
||||
(
|
||||
name + entry.pattern.name if entry.pattern.name else "",
|
||||
str(entry.pattern),
|
||||
)
|
||||
)
|
||||
res_short.append(
|
||||
(
|
||||
entry.pattern.name,
|
||||
str(entry.pattern),
|
||||
)
|
||||
)
|
||||
return res, res_short
|
||||
|
||||
|
||||
@lru_cache
|
||||
def urlpattern_to_js(pattern: str) -> (str, dict):
|
||||
if pattern.startswith("^"):
|
||||
return pattern
|
||||
res = ""
|
||||
kwargs = {}
|
||||
for p in pattern.split("<"):
|
||||
if ">" in p:
|
||||
rec = ""
|
||||
pn = p.split(">")
|
||||
k = pn[0].split(":")
|
||||
if len(k) == 1:
|
||||
rec = "{" + k[0] + "}"
|
||||
kwargs[k[0]] = "any"
|
||||
elif len(k) == 2:
|
||||
rec = "{" + k[1] + "}"
|
||||
kwargs[k[1]] = k[0]
|
||||
res += rec + pn[-1]
|
||||
else:
|
||||
res += p
|
||||
|
||||
return res, kwargs
|
||||
|
||||
|
||||
def get_api_path_by_url(name: str) -> tuple[str, dict] | None:
|
||||
global urls
|
||||
if not urls:
|
||||
urls, urls_short = get_urls(urls_conf.urlpatterns)
|
||||
urls = dict(urls_short) | dict(urls)
|
||||
|
||||
if name in urls:
|
||||
return urlpattern_to_js(urls[name])
|
||||
return None
|
|
@ -1,7 +1,10 @@
|
|||
from django.urls import include, path
|
||||
|
||||
from 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")),
|
||||
]
|
||||
|
|
18
akarpov/tools/api/views.py
Normal file
18
akarpov/tools/api/views.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
from rest_framework import generics
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
|
||||
from akarpov.tools.api.serializers import URLPathSerializer
|
||||
from akarpov.tools.api.services import get_api_path_by_url
|
||||
|
||||
|
||||
class RetrieveAPIUrlAPIView(generics.GenericAPIView):
|
||||
serializer_class = URLPathSerializer
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
path, k_args = get_api_path_by_url(self.kwargs["path"])
|
||||
if not path:
|
||||
raise NotFound
|
||||
return Response(data={"path": path, "kwargs": k_args})
|
|
@ -3,6 +3,7 @@
|
|||
app_name = "tools"
|
||||
urlpatterns = [
|
||||
path("qr/", include("akarpov.tools.qr.urls", namespace="qr")),
|
||||
path("uuid/", include("akarpov.tools.uuidtools.urls", namespace="uuid")),
|
||||
path(
|
||||
"promocodes/", include("akarpov.tools.promocodes.urls", namespace="promocodes")
|
||||
),
|
||||
|
|
0
akarpov/tools/uuidtools/__init__.py
Normal file
0
akarpov/tools/uuidtools/__init__.py
Normal file
5
akarpov/tools/uuidtools/apps.py
Normal file
5
akarpov/tools/uuidtools/apps.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UuidtoolsConfig(AppConfig):
|
||||
name = "akarpov.tools.uuidtools"
|
5
akarpov/tools/uuidtools/forms.py
Normal file
5
akarpov/tools/uuidtools/forms.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from django import forms
|
||||
|
||||
|
||||
class UUIDForm(forms.Form):
|
||||
token = forms.UUIDField()
|
0
akarpov/tools/uuidtools/migrations/__init__.py
Normal file
0
akarpov/tools/uuidtools/migrations/__init__.py
Normal file
56
akarpov/tools/uuidtools/services.py
Normal file
56
akarpov/tools/uuidtools/services.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
def uuid1_time_to_datetime(time: int):
|
||||
return datetime.datetime(1582, 10, 15) + datetime.timedelta(microseconds=time // 10)
|
||||
|
||||
|
||||
version_info = {
|
||||
1: """UUID Version 1 (Time-Based): The current time and the specific MAC address of the computer generating the
|
||||
UUID are used to construct IDs in UUID version 1. This means that even if they generate UUIDs simultaneously,
|
||||
separate machines are probably going to produce different ones. If your clock also knew your computer’s unique
|
||||
number, it would display a different number each time you glanced at it depending on the time and who you were.
|
||||
When generating a UUID that is specific to a certain computer and linked to the moment it was generated,
|
||||
this version is frequently utilised.""",
|
||||
2: """V2 UUIDs include the MAC address of the generator, lossy timestamp, and an account ID such as user ID or
|
||||
group ID on the local computer. Because of the information included in the UUID, there is limited space for
|
||||
randomness. The clock section of the UUID only advances every 429.47 seconds (~7 minutes). During any 7 minute
|
||||
period, there are only 64 available different UUIDs! """,
|
||||
3: """UUID Version 3 (Name-Based, Using MD5):The UUID version 3 uses MD5 hashing to create IDs.
|
||||
It uses the MD5 technique to combine the “namespace UUID” (a unique UUID) and the name you supply to get a unique
|
||||
identification. Imagine it as a secret recipe book where you add a secret ingredient (namespace UUID) to a standard
|
||||
ingredient (name), and when you combine them using a specific method (MD5), you get a unique dish (UUID).
|
||||
When you need to consistently produce UUIDs based on particular names, this variant is frequently utilised.""",
|
||||
4: """UUID Version 4 (Random): UUID version 4 uses random integers to create identifiers. It doesn’t depend on
|
||||
any particular details like names or times. Instead, it simply generates a slew of random numbers and characters.
|
||||
Imagine shaking a dice-filled box and then examining the face-up numbers that came out. It resembles receiving an
|
||||
unpredictable combination each time. When you just require a single unique identification without any kind of
|
||||
pattern or order, this version is fantastic.""",
|
||||
5: """UUID Version 5 (Name-Based, Using SHA-1): Similar to version 3, UUID version 5 generates identifiers using
|
||||
the SHA-1 algorithm rather than the MD5 technique. Similar to version 3, you provide it a “namespace UUID” and a
|
||||
name, and it uses the SHA-1 technique to combine them to get a unique identification. Consider it similar to
|
||||
baking a cake: you need a special pan (namespace UUID), your recipe (name), and a particular baking technique (
|
||||
SHA-1). No matter how many times you cook this recipe, you will always end up with a unique cake (UUID). Similar
|
||||
to version 3, UUID version 5 is frequently used in situations where a consistent and distinctive identity based
|
||||
on particular names is required. The better encryption capabilities of SHA-1 make its use preferred over MD5 in
|
||||
terms of security.""",
|
||||
}
|
||||
|
||||
|
||||
def decode_uuid(token: str) -> dict:
|
||||
data = {"token": token}
|
||||
try:
|
||||
uuid = UUID(token)
|
||||
except ValueError:
|
||||
return {"message": "not a valid UUID token"}
|
||||
data["version"] = f"{uuid.version}, {uuid.variant}"
|
||||
data["hex"] = uuid.hex
|
||||
data["bytes"] = bin(uuid.int)[2:]
|
||||
data["num"] = uuid.int
|
||||
if uuid.version == 1:
|
||||
data["time"] = uuid1_time_to_datetime(uuid.time)
|
||||
|
||||
if uuid.version in version_info:
|
||||
data["info"] = version_info[uuid.version]
|
||||
return data
|
9
akarpov/tools/uuidtools/urls.py
Normal file
9
akarpov/tools/uuidtools/urls.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "uuidtools"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.MainView.as_view(), name="main"),
|
||||
]
|
28
akarpov/tools/uuidtools/views.py
Normal file
28
akarpov/tools/uuidtools/views.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
import uuid
|
||||
|
||||
import uuid6
|
||||
from django.utils.timezone import now
|
||||
from django.views import generic
|
||||
|
||||
from akarpov.tools.uuidtools.services import decode_uuid
|
||||
|
||||
|
||||
class MainView(generic.TemplateView):
|
||||
template_name = "tools/uuid/main.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
if "uuid" in self.request.GET:
|
||||
data = decode_uuid(str(self.request.GET["uuid"]))
|
||||
context["data"] = data
|
||||
context["uuid"] = self.request.GET["uuid"]
|
||||
|
||||
context["tokens"] = [
|
||||
(uuid.uuid4(), 4),
|
||||
(uuid6.uuid6(), 6),
|
||||
(uuid6.uuid8(), 8),
|
||||
(uuid.uuid1(), 1),
|
||||
]
|
||||
context["now"] = now()
|
||||
return context
|
|
@ -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:
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
from rest_framework import generics, permissions, status, views
|
||||
from rest_framework.response import Response
|
||||
|
||||
from akarpov.common.api import SmallResultsSetPagination
|
||||
from akarpov.common.api.pagination import SmallResultsSetPagination
|
||||
from akarpov.common.jwt import sign_jwt
|
||||
from akarpov.users.api.serializers import (
|
||||
UserEmailVerification,
|
||||
|
|
23
akarpov/users/migrations/0012_user_theme.py
Normal file
23
akarpov/users/migrations/0012_user_theme.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 4.2.6 on 2023-10-25 06:37
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("themes", "0001_initial"),
|
||||
("users", "0011_alter_userhistory_options_userhistory_created"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="theme",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="themes.theme",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
from akarpov.common.models import BaseImageModel
|
||||
from akarpov.tools.shortener.models import ShortLinkModel
|
||||
from akarpov.users.themes.models import Theme
|
||||
|
||||
|
||||
class User(AbstractUser, BaseImageModel, ShortLinkModel):
|
||||
|
@ -27,6 +28,12 @@ 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_theme_url(self):
|
||||
if self.theme_id:
|
||||
return Theme.objects.cache().get(id=self.theme_id).file.url
|
||||
return ""
|
||||
|
||||
def get_absolute_url(self):
|
||||
"""Get url for user's detail view.
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import pytest
|
||||
from django.urls import reverse_lazy
|
||||
from pytest_lambda import lambda_fixture, static_fixture
|
||||
from rest_framework import status
|
||||
|
@ -27,3 +28,74 @@ def test_return_err_if_data_is_invalid(
|
|||
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()
|
||||
|
|
36
akarpov/users/tests/test_forms.py
Normal file
36
akarpov/users/tests/test_forms.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
from django.test import TestCase
|
||||
|
||||
from akarpov.users.forms import UserAdminCreationForm
|
||||
|
||||
|
||||
class UserFormTest(TestCase):
|
||||
def test_valid_form(self):
|
||||
form = UserAdminCreationForm(
|
||||
data={
|
||||
"username": "testuser",
|
||||
"password1": "P4sSw0rD!",
|
||||
"password2": "P4sSw0rD!",
|
||||
}
|
||||
)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_insecure_password(self):
|
||||
form = UserAdminCreationForm(
|
||||
data={
|
||||
"username": "testuser",
|
||||
"password1": "password",
|
||||
"password2": "password",
|
||||
}
|
||||
)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertEqual(len(form.errors), 1)
|
||||
self.assertEqual(
|
||||
form.errors["password2"],
|
||||
["This password is too common."],
|
||||
)
|
||||
|
||||
def test_invalid_form(self):
|
||||
form = UserAdminCreationForm(data={})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertEqual(len(form.errors), 3)
|
||||
self.assertEqual(form.errors["username"], ["This field is required."])
|
|
@ -1,4 +1,11 @@
|
|||
from akarpov.files.consts import USER_INITIAL_FILE_UPLOAD
|
||||
from akarpov.users.models import User
|
||||
|
||||
|
||||
def test_user_creation(user_factory):
|
||||
user = user_factory(username="testuser", email="test@example.com")
|
||||
assert isinstance(user, User)
|
||||
assert user.__str__() == user.username
|
||||
|
||||
|
||||
def test_user_create(user_factory):
|
||||
|
|
18
akarpov/users/tests/test_views.py
Normal file
18
akarpov/users/tests/test_views.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from akarpov.users.tests.factories import UserFactory
|
||||
|
||||
|
||||
class UserViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.user = UserFactory()
|
||||
|
||||
def test_user_detail_view(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse("users:detail", kwargs={"username": self.user.username})
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.user.username)
|
0
akarpov/users/themes/__init__.py
Normal file
0
akarpov/users/themes/__init__.py
Normal file
8
akarpov/users/themes/admin.py
Normal file
8
akarpov/users/themes/admin.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from akarpov.users.themes.models import Theme
|
||||
|
||||
|
||||
@admin.register(Theme)
|
||||
class ThemeAdmin(admin.ModelAdmin):
|
||||
...
|
6
akarpov/users/themes/apps.py
Normal file
6
akarpov/users/themes/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ThemesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "akarpov.users.themes"
|
9
akarpov/users/themes/forms.py
Normal file
9
akarpov/users/themes/forms.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from django import forms
|
||||
|
||||
from akarpov.users.themes.models import Theme
|
||||
|
||||
|
||||
class ThemeForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Theme
|
||||
fields = ["name", "file", "color"]
|
35
akarpov/users/themes/migrations/0001_initial.py
Normal file
35
akarpov/users/themes/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
# Generated by Django 4.2.6 on 2023-10-25 06:37
|
||||
|
||||
import colorfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Theme",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=250)),
|
||||
("file", models.FileField(upload_to="themes/")),
|
||||
(
|
||||
"color",
|
||||
colorfield.fields.ColorField(
|
||||
default="#FFFFFF", image_field=None, max_length=18, samples=None
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
0
akarpov/users/themes/migrations/__init__.py
Normal file
0
akarpov/users/themes/migrations/__init__.py
Normal file
11
akarpov/users/themes/models.py
Normal file
11
akarpov/users/themes/models.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from colorfield.fields import ColorField
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Theme(models.Model):
|
||||
name = models.CharField(max_length=250)
|
||||
file = models.FileField(upload_to="themes/")
|
||||
color = ColorField()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
0
akarpov/users/themes/tests.py
Normal file
0
akarpov/users/themes/tests.py
Normal file
8
akarpov/users/themes/urls.py
Normal file
8
akarpov/users/themes/urls.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from django.urls import path
|
||||
|
||||
from akarpov.users.themes.views import CreateFormView
|
||||
|
||||
app_name = "themes"
|
||||
urlpatterns = [
|
||||
path("create", CreateFormView.as_view(), name="create"),
|
||||
]
|
13
akarpov/users/themes/views.py
Normal file
13
akarpov/users/themes/views.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from django.views import generic
|
||||
|
||||
from akarpov.common.views import SuperUserRequiredMixin
|
||||
from akarpov.users.themes.models import Theme
|
||||
|
||||
|
||||
class CreateFormView(generic.CreateView, SuperUserRequiredMixin):
|
||||
model = Theme
|
||||
fields = ["name", "file", "color"]
|
||||
template_name = "users/themes/create.html"
|
||||
|
||||
def get_success_url(self):
|
||||
return ""
|
|
@ -1,4 +1,4 @@
|
|||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
|
||||
from akarpov.users.views import (
|
||||
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"),
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from config.celery_app import app
|
||||
|
||||
|
||||
def get_scheduled_tasks_name() -> [str]:
|
||||
def get_scheduled_tasks_name() -> list[str]:
|
||||
i = app.control.inspect()
|
||||
t = i.scheduled()
|
||||
all_tasks = []
|
||||
|
|
|
@ -41,6 +41,7 @@ RUN poetry export --without-hashes -f requirements.txt | /venv/bin/pip install -
|
|||
|
||||
COPY . .
|
||||
RUN poetry build && /venv/bin/pip install dist/*.whl
|
||||
RUN /venv/bin/python -m nltk.downloader punkt stopwords wordnet
|
||||
|
||||
|
||||
COPY ./compose/production/django/entrypoint /entrypoint
|
||||
|
@ -51,10 +52,6 @@ COPY ./compose/local/django/start /start
|
|||
RUN sed -i 's/\r$//g' /start
|
||||
RUN chmod +x /start
|
||||
|
||||
COPY ./compose/local/django/spacy_setup /spacy_setup
|
||||
RUN sed -i 's/\r$//g' /spacy_setup
|
||||
RUN chmod +x /spacy_setup
|
||||
|
||||
COPY ./compose/local/django/start-redirect /start-redirect
|
||||
RUN sed -i 's/\r$//g' /start-redirect
|
||||
RUN chmod +x /start-redirect
|
||||
|
|
|
@ -3,5 +3,6 @@
|
|||
set -o errexit
|
||||
set -o nounset
|
||||
|
||||
/install_preview_dependencies
|
||||
|
||||
watchfiles celery.__main__.main --args '-A config.celery_app worker -l INFO'
|
||||
celery -A config.celery_app worker --loglevel=info -c 5
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
#!/bin/bash
|
||||
|
||||
apt-get update
|
||||
apt-get install wget libnotify4 scribus libappindicator3-1 libayatana-indicator3-7 libdbusmenu-glib4 libdbusmenu-gtk3-4
|
||||
apt-get install -y poppler-utils libfile-mimeinfo-perl ghostscript libsecret-1-0 zlib1g-dev libjpeg-dev imagemagick libmagic1 libreoffice inkscape xvfb
|
||||
apt-get install -y libxml2-dev libxslt1-dev antiword unrtf pstotext tesseract-ocr flac lame libmad0 libsox-fmt-mp3 sox swig
|
||||
apt-get install -y python-dev libxml2-dev libxslt1-dev antiword unrtf poppler-utils pstotext tesseract-ocr \
|
||||
apt-get install -y libxml2-dev libxslt1-dev antiword unrtf tesseract-ocr flac lame libmad0 libsox-fmt-mp3 sox swig
|
||||
apt-get install -y python-dev-is-python3 libxml2-dev libxslt1-dev antiword unrtf poppler-utils tesseract-ocr \
|
||||
flac ffmpeg lame libmad0 libsox-fmt-mp3 sox libjpeg-dev swig
|
||||
wget https://github.com/jgraph/drawio-desktop/releases/download/v13.0.3/draw.io-amd64-13.0.3.deb
|
||||
dpkg -i draw.io-amd64-13.0.3.deb
|
||||
/spacy_setup
|
||||
rm draw.io-amd64-13.0.3.deb
|
||||
apt-get purge -y --auto-remove -o APT:AutoRemove:RecommendsImportant=false && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
python -m spacy download en_core_web_lg
|
||||
python -m spacy download xx_sent_ud_sm
|
||||
python -m spacy download ru_core_news_lg
|
|
@ -7,4 +7,4 @@ set -o nounset
|
|||
|
||||
python manage.py migrate auth
|
||||
python manage.py migrate
|
||||
python manage.py runserver_plus 0.0.0.0:8000
|
||||
daphne config.asgi:application --port 8000 --bind 0.0.0.0
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM traefik:2.10.1
|
||||
FROM traefik:2.10.7
|
||||
RUN mkdir -p /etc/traefik/acme \
|
||||
&& touch /etc/traefik/acme/acme.json \
|
||||
&& chmod 600 /etc/traefik/acme/acme.json
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user