mirror of
https://github.com/Alexander-D-Karpov/akarpov
synced 2024-11-10 21:56:34 +03:00
Compare commits
9 Commits
e90c6addf3
...
b5c0ceb340
Author | SHA1 | Date | |
---|---|---|---|
|
b5c0ceb340 | ||
2b2c16db2d | |||
ca199383d4 | |||
4794664e6a | |||
5088ab308a | |||
5e0fafd3b7 | |||
a2da7e724f | |||
7c9890975b | |||
3acd858598 |
|
@ -1,11 +1,12 @@
|
|||
from ckeditor.fields import RichTextFormField
|
||||
from django import forms
|
||||
from django_ckeditor_5.fields import CKEditor5Field
|
||||
from django_ckeditor_5.widgets import CKEditor5Widget
|
||||
|
||||
from akarpov.blog.models import Post, Tag
|
||||
|
||||
|
||||
class PostForm(forms.ModelForm):
|
||||
body = RichTextFormField(label="")
|
||||
body = CKEditor5Field(config_name="extends")
|
||||
image = forms.ImageField(help_text="better use horizontal images", required=False)
|
||||
tags = forms.ModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(), widget=forms.CheckboxSelectMultiple, required=True
|
||||
|
@ -14,3 +15,9 @@ class PostForm(forms.ModelForm):
|
|||
class Meta:
|
||||
model = Post
|
||||
fields = ["title", "body", "image", "tags"]
|
||||
widgets = {
|
||||
"body": CKEditor5Widget(
|
||||
attrs={"class": "django_ckeditor_5"},
|
||||
config_name="extends",
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
# Generated by Django 4.0.8 on 2022-11-23 08:30
|
||||
|
||||
import ckeditor_uploader.fields
|
||||
import colorfield.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
@ -56,7 +55,7 @@ class Migration(migrations.Migration):
|
|||
),
|
||||
),
|
||||
("title", models.CharField(max_length=100)),
|
||||
("body", ckeditor_uploader.fields.RichTextUploadingField()),
|
||||
("body", models.TextField()),
|
||||
("slug", models.SlugField(blank=True, max_length=20)),
|
||||
("post_views", models.IntegerField(default=0)),
|
||||
("rating", models.IntegerField(default=0)),
|
||||
|
|
18
akarpov/blog/migrations/0011_alter_post_body.py
Normal file
18
akarpov/blog/migrations/0011_alter_post_body.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.2.10 on 2024-03-29 15:35
|
||||
|
||||
from django.db import migrations
|
||||
import django_ckeditor_5.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("blog", "0010_alter_tag_color"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="post",
|
||||
name="body",
|
||||
field=django_ckeditor_5.fields.CKEditor5Field(),
|
||||
),
|
||||
]
|
|
@ -1,8 +1,8 @@
|
|||
from ckeditor_uploader.fields import RichTextUploadingField
|
||||
from colorfield.fields import ColorField
|
||||
from django.db import models
|
||||
from django.db.models import Count
|
||||
from django.urls import reverse
|
||||
from django_ckeditor_5.fields import CKEditor5Field
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
|
@ -16,7 +16,7 @@
|
|||
|
||||
class Post(BaseImageModel, ShortLinkModel, UserHistoryModel):
|
||||
title = models.CharField(max_length=100, blank=False)
|
||||
body = RichTextUploadingField(blank=False)
|
||||
body = CKEditor5Field(blank=False, config_name="extends")
|
||||
|
||||
creator = models.ForeignKey(User, on_delete=models.CASCADE, related_name="posts")
|
||||
|
||||
|
|
56
akarpov/music/api/permissions.py
Normal file
56
akarpov/music/api/permissions.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
from rest_framework import permissions
|
||||
|
||||
from akarpov.users.models import User, UserAPIToken
|
||||
|
||||
|
||||
class GetBaseMusicPermission(permissions.BasePermission):
|
||||
def get_token_data(self, request) -> (dict, User | None):
|
||||
try:
|
||||
token = request.headers["Authorization"]
|
||||
if " " in token:
|
||||
token = token.split(" ")[1]
|
||||
except (KeyError, IndexError):
|
||||
return {
|
||||
"listen": False,
|
||||
"upload": False,
|
||||
"playlist": False,
|
||||
}, None
|
||||
try:
|
||||
token = UserAPIToken.objects.cache().get(token=token)
|
||||
except UserAPIToken.DoesNotExist:
|
||||
return {
|
||||
"listen": False,
|
||||
"upload": False,
|
||||
"playlist": False,
|
||||
}, None
|
||||
if "music" not in token.permissions:
|
||||
return {
|
||||
"listen": False,
|
||||
"upload": False,
|
||||
"playlist": False,
|
||||
}, token.user
|
||||
return token.permissions["music"], token.user
|
||||
|
||||
|
||||
class CanListenToMusic(GetBaseMusicPermission):
|
||||
def has_permission(self, request, view):
|
||||
token_data = self.get_token_data(request)
|
||||
if "listen" in token_data:
|
||||
return token_data["listen"]
|
||||
return False
|
||||
|
||||
|
||||
class CanUploadMusic(GetBaseMusicPermission):
|
||||
def has_permission(self, request, view):
|
||||
token_data = self.get_token_data(request)
|
||||
if "upload" in token_data:
|
||||
return token_data["upload"]
|
||||
return False
|
||||
|
||||
|
||||
class CanManagePlaylists(GetBaseMusicPermission):
|
||||
def has_permission(self, request, view):
|
||||
token_data = self.get_token_data(request)
|
||||
if "playlist" in token_data:
|
||||
return token_data["playlist"]
|
||||
return False
|
|
@ -22,9 +22,17 @@ class Meta:
|
|||
|
||||
|
||||
class ListAlbumSerializer(serializers.ModelSerializer):
|
||||
authors = serializers.SerializerMethodField(method_name="get_authors")
|
||||
|
||||
@extend_schema_field(ListAuthorSerializer(many=True))
|
||||
def get_authors(self, obj):
|
||||
return ListAuthorSerializer(
|
||||
Author.objects.cache().filter(albums__id=obj.id), many=True
|
||||
).data
|
||||
|
||||
class Meta:
|
||||
model = Album
|
||||
fields = ["name", "slug", "image_cropped"]
|
||||
fields = ["name", "slug", "image_cropped", "authors"]
|
||||
|
||||
|
||||
class SongSerializer(serializers.ModelSerializer):
|
||||
|
@ -133,10 +141,32 @@ class Meta:
|
|||
|
||||
class PlaylistSerializer(SetUserModelSerializer):
|
||||
creator = UserPublicInfoSerializer(read_only=True)
|
||||
images = serializers.SerializerMethodField(method_name="get_images")
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.ImageField()))
|
||||
def get_images(self, obj):
|
||||
# Get distinct album images from songs
|
||||
images = (
|
||||
Song.objects.cache()
|
||||
.filter(
|
||||
playlists__id__in=PlaylistSong.objects.cache()
|
||||
.filter(playlist=obj)
|
||||
.values("id")
|
||||
)
|
||||
.values_list("album__image", flat=True)
|
||||
.distinct()[:4]
|
||||
)
|
||||
# Build absolute URI for each image
|
||||
images = [
|
||||
self.context["request"].build_absolute_uri(image)
|
||||
for image in images
|
||||
if image
|
||||
]
|
||||
return images
|
||||
|
||||
class Meta:
|
||||
model = Playlist
|
||||
fields = ["name", "length", "slug", "private", "creator"]
|
||||
fields = ["name", "length", "slug", "images", "private", "creator"]
|
||||
extra_kwargs = {
|
||||
"slug": {"read_only": True},
|
||||
"creator": {"read_only": True},
|
||||
|
@ -144,13 +174,21 @@ class Meta:
|
|||
}
|
||||
|
||||
|
||||
class FullPlaylistSerializer(serializers.ModelSerializer):
|
||||
songs = ListSongSerializer(many=True, read_only=True)
|
||||
class FullPlaylistSerializer(PlaylistSerializer):
|
||||
songs = serializers.SerializerMethodField(method_name="get_songs")
|
||||
creator = UserPublicInfoSerializer(read_only=True)
|
||||
|
||||
@extend_schema_field(ListSongSerializer(many=True))
|
||||
def get_songs(self, obj):
|
||||
return ListSongSerializer(
|
||||
Song.objects.cache().filter(playlists__id=obj.id),
|
||||
many=True,
|
||||
context=self.context,
|
||||
).data
|
||||
|
||||
class Meta:
|
||||
model = Playlist
|
||||
fields = ["name", "private", "creator", "songs"]
|
||||
fields = ["name", "private", "creator", "images", "songs"]
|
||||
extra_kwargs = {
|
||||
"slug": {"read_only": True},
|
||||
"creator": {"read_only": True},
|
||||
|
@ -158,8 +196,8 @@ class Meta:
|
|||
|
||||
|
||||
class AddSongToPlaylistSerializer(serializers.ModelSerializer):
|
||||
song = serializers.SlugField()
|
||||
playlist = serializers.SlugField()
|
||||
song = serializers.SlugField(write_only=True)
|
||||
playlist = serializers.SlugField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Playlist
|
||||
|
|
|
@ -39,24 +39,96 @@ class SongDocument(Document):
|
|||
},
|
||||
)
|
||||
|
||||
meta = fields.ObjectField(dynamic=True) # Added meta field here as dynamic object
|
||||
meta = fields.ObjectField(dynamic=True)
|
||||
|
||||
class Index:
|
||||
name = "songs"
|
||||
settings = {"number_of_shards": 1, "number_of_replicas": 0}
|
||||
# settings = {
|
||||
# "number_of_shards": 1,
|
||||
# "number_of_replicas": 0,
|
||||
# "analysis": {
|
||||
# "analyzer": {
|
||||
# "russian_icu": {
|
||||
# "type": "custom",
|
||||
# "tokenizer": "icu_tokenizer",
|
||||
# "filter": ["icu_folding","icu_normalizer"]
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# } TODO
|
||||
settings = {
|
||||
"number_of_shards": 1,
|
||||
"number_of_replicas": 0,
|
||||
"analysis": {
|
||||
"filter": {
|
||||
"russian_stop": {
|
||||
"type": "stop",
|
||||
"stopwords": "_russian_",
|
||||
},
|
||||
"russian_keywords": {
|
||||
"type": "keyword_marker",
|
||||
"keywords": ["пример"],
|
||||
},
|
||||
"russian_stemmer": {
|
||||
"type": "stemmer",
|
||||
"language": "russian",
|
||||
},
|
||||
"english_stemmer": {
|
||||
"type": "stemmer",
|
||||
"language": "english",
|
||||
},
|
||||
"autocomplete_filter": {
|
||||
"type": "edge_ngram",
|
||||
"min_gram": 1,
|
||||
"max_gram": 20,
|
||||
},
|
||||
"synonym_filter": {
|
||||
"type": "synonym",
|
||||
"synonyms": [
|
||||
"бит, трек => песня",
|
||||
"песня, музыка, мелодия, композиция",
|
||||
"певец, исполнитель, артист, музыкант",
|
||||
"альбом, диск, пластинка, сборник, коллекция",
|
||||
],
|
||||
},
|
||||
},
|
||||
"analyzer": {
|
||||
"russian": {
|
||||
"tokenizer": "standard",
|
||||
"filter": [
|
||||
"russian_stop",
|
||||
"russian_keywords",
|
||||
"russian_stemmer",
|
||||
],
|
||||
},
|
||||
"russian_with_synonyms_and_stemming": {
|
||||
"tokenizer": "standard",
|
||||
"filter": [
|
||||
"lowercase",
|
||||
"russian_stop",
|
||||
"russian_keywords",
|
||||
"russian_stemmer",
|
||||
"synonym_filter",
|
||||
],
|
||||
},
|
||||
"english_with_stemming": {
|
||||
"type": "custom",
|
||||
"tokenizer": "standard",
|
||||
"filter": [
|
||||
"lowercase",
|
||||
"english_stemmer",
|
||||
],
|
||||
},
|
||||
"autocomplete_with_stemming": {
|
||||
"type": "custom",
|
||||
"tokenizer": "standard",
|
||||
"filter": [
|
||||
"lowercase",
|
||||
"autocomplete_filter",
|
||||
"english_stemmer", # Apply English stemming for autocomplete
|
||||
"russian_stemmer", # Include Russian stemming if applicable
|
||||
],
|
||||
},
|
||||
"search_synonym_with_stemming": {
|
||||
"type": "custom",
|
||||
"tokenizer": "standard",
|
||||
"filter": [
|
||||
"lowercase",
|
||||
"synonym_filter",
|
||||
"english_stemmer", # Apply English stemming for synonym search
|
||||
"russian_stemmer", # Include Russian stemming if processing Russian synonyms
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
class Django:
|
||||
model = Song
|
||||
|
|
|
@ -72,6 +72,16 @@ def load_track(
|
|||
name = search_info["title"]
|
||||
elif not name:
|
||||
name = process_track_name(" ".join(p_name.strip().split("-")))
|
||||
clear_name = [
|
||||
"(Official HD Video)",
|
||||
"(Official Music Video)",
|
||||
"(Official Video)",
|
||||
"Official Video",
|
||||
"Official Music Video",
|
||||
"Official HD Video",
|
||||
]
|
||||
for c in clear_name:
|
||||
name = name.replace(c, "")
|
||||
|
||||
if not name:
|
||||
name = orig_name
|
||||
|
|
|
@ -65,7 +65,7 @@ def process_mp3_file(path: str, user_id: int) -> None:
|
|||
|
||||
def analyze_music_loudness(mp3_file):
|
||||
y, sr = librosa.load(mp3_file, sr=None)
|
||||
frame_length = int(0.5 * sr)
|
||||
frame_length = int(0.1 * sr)
|
||||
stft = np.abs(librosa.stft(y, n_fft=frame_length, hop_length=frame_length))
|
||||
rms_energy = librosa.feature.rms(
|
||||
S=stft, frame_length=frame_length, hop_length=frame_length
|
||||
|
|
|
@ -360,6 +360,7 @@ def update_author_info(author: Author) -> None:
|
|||
|
||||
|
||||
def search_all_platforms(track_name: str) -> dict:
|
||||
print(track_name)
|
||||
session = spotipy.Spotify(
|
||||
auth_manager=spotipy.SpotifyClientCredentials(
|
||||
client_id=settings.MUSIC_SPOTIFY_ID,
|
||||
|
|
|
@ -13,9 +13,9 @@ def search_song(query):
|
|||
ES_Q(
|
||||
"multi_match",
|
||||
query=query,
|
||||
fields=["name^3", "authors.name^2", "album.name"],
|
||||
fields=["name^5", "authors.name^3", "album.name^3"],
|
||||
fuzziness="AUTO",
|
||||
), # Change here
|
||||
),
|
||||
ES_Q("wildcard", name__raw=f"*{query.lower()}*"),
|
||||
ES_Q(
|
||||
"nested",
|
||||
|
@ -27,6 +27,7 @@ def search_song(query):
|
|||
path="album",
|
||||
query=ES_Q("wildcard", album__name__raw=f"*{query.lower()}*"),
|
||||
),
|
||||
ES_Q("wildcard", meta__raw=f"*{query.lower()}*"),
|
||||
],
|
||||
minimum_should_match=1,
|
||||
)
|
||||
|
|
|
@ -157,22 +157,25 @@ def download_from_youtube_link(link: str, user_id: int) -> Song:
|
|||
print(f"[processing] loading {title}")
|
||||
|
||||
info = search_all_platforms(title)
|
||||
if not info["album_image"].startswith("/"):
|
||||
r = requests.get(info["album_image"])
|
||||
img_pth = str(
|
||||
settings.MEDIA_ROOT
|
||||
+ f"/{info['album_image'].split('/')[-1]}_{str(randint(100, 999))}"
|
||||
)
|
||||
with open(img_pth, "wb") as f:
|
||||
f.write(r.content)
|
||||
if "album_image" in info and info["album_image"]:
|
||||
if not info["album_image"].startswith("/"):
|
||||
r = requests.get(info["album_image"])
|
||||
img_pth = str(
|
||||
settings.MEDIA_ROOT
|
||||
+ f"/{info['album_image'].split('/')[-1]}_{str(randint(100, 999))}"
|
||||
)
|
||||
with open(img_pth, "wb") as f:
|
||||
f.write(r.content)
|
||||
|
||||
im = Image.open(img_pth)
|
||||
im.save(str(f"{img_pth}.png"))
|
||||
im = Image.open(img_pth)
|
||||
im.save(str(f"{img_pth}.png"))
|
||||
|
||||
os.remove(img_pth)
|
||||
img_pth = f"{img_pth}.png"
|
||||
os.remove(img_pth)
|
||||
img_pth = f"{img_pth}.png"
|
||||
else:
|
||||
img_pth = info["album_image"]
|
||||
else:
|
||||
img_pth = info["album_image"]
|
||||
img_pth = None
|
||||
if "genre" in info:
|
||||
song = load_track(
|
||||
path,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from datetime import timedelta
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pylast
|
||||
import spotipy
|
||||
|
@ -28,7 +29,7 @@
|
|||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(soft_time_limit=60 * 20, time_limit=60 * 30)
|
||||
def list_tracks(url, user_id):
|
||||
if "music.youtube.com" in url or "youtu.be" in url:
|
||||
url = url.replace("music.youtube.com", "youtube.com")
|
||||
|
@ -42,7 +43,6 @@ def list_tracks(url, user_id):
|
|||
ytmusic = ytmusicapi.YTMusic()
|
||||
channel_id = url.split("/")[-1]
|
||||
channel_songs = ytmusic.get_artist(channel_id)["songs"]["results"]
|
||||
print(channel_songs)
|
||||
|
||||
for song in channel_songs:
|
||||
process_yb.apply_async(
|
||||
|
@ -54,9 +54,19 @@ def list_tracks(url, user_id):
|
|||
|
||||
elif "playlist" in url or "&list=" in url:
|
||||
ytmusic = ytmusicapi.YTMusic()
|
||||
playlist_id = url.split("=")[-1]
|
||||
playlist_songs = ytmusic.get_playlist(playlist_id)["tracks"]["results"]
|
||||
|
||||
# Parse the URL and the query string
|
||||
parsed_url = urlparse(url)
|
||||
parsed_qs = parse_qs(parsed_url.query)
|
||||
|
||||
# Get the playlist ID from the parsed query string
|
||||
playlist_id = parsed_qs.get("list", [None])[0]
|
||||
|
||||
if playlist_id:
|
||||
playlist_songs = ytmusic.get_playlist(playlist_id)["tracks"]
|
||||
|
||||
else:
|
||||
raise ValueError("No playlist ID found in the URL.")
|
||||
for song in playlist_songs:
|
||||
process_yb.apply_async(
|
||||
kwargs={
|
||||
|
|
|
@ -100,6 +100,7 @@
|
|||
<li><a class="dropdown-item {% active_link 'tools:promocodes:activate' %}" href="{% url 'tools:promocodes:activate' %}">Activate promocode</a></li>
|
||||
<li><a class="dropdown-item {% active_link 'users:history' %}" href="{% url 'users:history' %}">History</a></li>
|
||||
<li><a class="dropdown-item {% active_link 'users:enable_2fa' %}" href="{% url 'users:enable_2fa' %}">2FA</a></li>
|
||||
<li><a class="dropdown-item {% active_link 'users:enable_2fa' %}" href="{% url 'users:list_tokens' %}">API Tokens</a></li>
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
|
@ -139,7 +140,7 @@
|
|||
</div>
|
||||
</main>
|
||||
<footer class="row bg-light py-1 mt-auto text-center">
|
||||
<div class="col"> Writen by <a href="https://akarpov.ru/about">sanspie</a>, find source code <a href="https://github.com/Alexander-D-Karpov/akarpov">here</a>. Copyleft akarpov 2023</div>
|
||||
<div class="col"> Writen by <a href="https://akarpov.ru/about">sanspie</a>, find source code <a href="https://github.com/Alexander-D-Karpov/akarpov">here</a>. Copyleft akarpov 2022</div>
|
||||
</footer>
|
||||
<div id="toastContainer" class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
|
||||
|
|
|
@ -8,13 +8,9 @@
|
|||
<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 %}
|
||||
{{ form|crispy }}
|
||||
<div class="mt-4 flex justify-end space-x-4">
|
||||
<button class="btn btn-secondary" type="submit" id="submit">
|
||||
Save Changes
|
||||
</button>
|
||||
<input class="btn btn-secondary" type="submit" id="submit" value="Save Changes" />
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
12
akarpov/templates/users/confirm_delete_token.html
Normal file
12
akarpov/templates/users/confirm_delete_token.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Confirm Deletion</h2>
|
||||
<p>Are you sure you want to delete this token?</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger">Yes, delete it</button>
|
||||
<a href="{% url 'users:list_tokens' %}" class="btn btn-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
21
akarpov/templates/users/create_token.html
Normal file
21
akarpov/templates/users/create_token.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
{% extends "base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Create API Token</h2>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
{% if field.name|slice:":12" == 'permissions_' %}
|
||||
<fieldset>
|
||||
{{ field|as_crispy_field }}
|
||||
</fieldset>
|
||||
{% else %}
|
||||
{{ field|as_crispy_field }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<button type="submit" class="btn btn-primary mt-2">Create Token</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
27
akarpov/templates/users/list_tokens.html
Normal file
27
akarpov/templates/users/list_tokens.html
Normal file
|
@ -0,0 +1,27 @@
|
|||
{% extends "base.html" %}
|
||||
{% load humanize %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>My API Tokens</h2>
|
||||
<div class="mb-3">
|
||||
<a href="{% url 'users:create_token' %}" class="btn btn-primary">Create New Token</a>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
{% for token in tokens %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
{% if token.name %}{{ token.name }}{% else %}<em>Unnamed Token</em>{% endif %}
|
||||
<br>
|
||||
<small>{{ token.token|slice:":5" }}***{{ token.token|slice:"-5:" }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'users:view_token' token.id %}" class="btn btn-sm btn-outline-primary">Details</a>
|
||||
<a href="{% url 'users:delete_token' token.id %}" class="btn btn-sm btn-outline-danger">Delete</a>
|
||||
</div>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="list-group-item">No tokens found.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
17
akarpov/templates/users/token_created.html
Normal file
17
akarpov/templates/users/token_created.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Token Created Successfully</h2>
|
||||
<p>Your new API token is:</p>
|
||||
<p><code>{{ new_token }}</code><button class="btn" data-clipboard-text="{{ new_token }}">
|
||||
<i style="font-size: 0.8em" class="bi bi-clipboard ml-2"></i>
|
||||
</button></p>
|
||||
<p>Please note it down. You won't be able to see it again.</p>
|
||||
<a href="{% url 'users:list_tokens' %}" class="btn btn-primary mt-4">Back to Tokens</a>
|
||||
</div>
|
||||
<script src="{% static 'js/clipboard.min.js' %}"></script>
|
||||
<script>
|
||||
new ClipboardJS('.btn');
|
||||
</script>
|
||||
{% endblock %}
|
26
akarpov/templates/users/view_token.html
Normal file
26
akarpov/templates/users/view_token.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Token Details</h2>
|
||||
{% if token.name %}
|
||||
<p><strong>Name:</strong> {{ token.name }}</p>
|
||||
{% endif %}
|
||||
<p><strong>Token:</strong> {{ token.token|slice:":5" }}***{{ token.token|slice:"-5:" }}</p>
|
||||
<p><strong>Last Used:</strong> {{ token.last_used|date:"Y-m-d H:i:s" }} ({{ token.last_used|timesince }} ago)</p>
|
||||
<p><strong>Active Until:</strong> {{ token.active_until|date:"Y-m-d" }}</p>
|
||||
<p><strong>Permissions:</strong></p>
|
||||
<ul>
|
||||
{% for app, actions in token.permissions.items %}
|
||||
<li><strong>{{ app|title }}:</strong>
|
||||
<ul>
|
||||
{% for action, value in actions.items %}
|
||||
<li>{{ action|title }}: {{ value|yesno:"✅,❌" }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{# <a href="{% url 'edit_token' token.id %}" class="btn btn-primary">Edit</a> TODO #}
|
||||
<a href="{% url 'users:delete_token' token.id %}" class="btn btn-danger">Delete</a>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -4,6 +4,7 @@
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .forms import UserAdminChangeForm, UserAdminCreationForm
|
||||
from .models import UserAPIToken
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
@ -33,3 +34,19 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||
)
|
||||
list_display = ["username", "is_superuser"]
|
||||
search_fields = ["username", "email"]
|
||||
|
||||
|
||||
@admin.register(UserAPIToken)
|
||||
class UserAPITokenAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "active_until", "last_used"]
|
||||
search_fields = ["user__username", "token"]
|
||||
list_filter = ["active_until", "last_used"]
|
||||
date_hierarchy = "active_until"
|
||||
raw_id_fields = ["user"]
|
||||
actions = ["deactivate_tokens"]
|
||||
|
||||
def deactivate_tokens(self, request, queryset):
|
||||
queryset.update(active_until=None)
|
||||
self.message_user(request, _("Tokens deactivated"))
|
||||
|
||||
deactivate_tokens.short_description = _("Deactivate selected tokens")
|
||||
|
|
22
akarpov/users/api/authentification.py
Normal file
22
akarpov/users/api/authentification.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
from rest_framework.authentication import BaseAuthentication
|
||||
|
||||
from akarpov.users.models import UserAPIToken
|
||||
from akarpov.users.tasks import set_last_active_token
|
||||
|
||||
|
||||
class UserTokenAuthentication(BaseAuthentication):
|
||||
def authenticate(self, request):
|
||||
if "Authorization" not in request.headers:
|
||||
return None
|
||||
token = request.headers["Authorization"]
|
||||
if " " in token:
|
||||
token = token.split(" ")[1]
|
||||
try:
|
||||
token = UserAPIToken.objects.cache().get(token=token)
|
||||
except UserAPIToken.DoesNotExist:
|
||||
return None
|
||||
if not token.is_active:
|
||||
return None
|
||||
set_last_active_token.delay(token.token)
|
||||
|
||||
return token.user, token
|
|
@ -1,10 +1,15 @@
|
|||
import json
|
||||
|
||||
from allauth.account.forms import SignupForm
|
||||
from allauth.socialaccount.forms import SignupForm as SocialSignupForm
|
||||
from django import forms
|
||||
from django.contrib.auth import forms as admin_forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.forms import DateInput, TextInput
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from akarpov.users.models import UserAPIToken
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
|
@ -45,3 +50,80 @@ class UserSocialSignupForm(SocialSignupForm):
|
|||
|
||||
class OTPForm(forms.Form):
|
||||
otp_token = forms.CharField()
|
||||
|
||||
|
||||
class TokenCreationForm(forms.ModelForm):
|
||||
permissions = forms.MultipleChoiceField(
|
||||
choices=[], # To be set in __init__
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = UserAPIToken
|
||||
fields = ["name", "active_until", "permissions"]
|
||||
widgets = {
|
||||
"name": TextInput(attrs={"placeholder": "Token Name (Optional)"}),
|
||||
"active_until": DateInput(attrs={"type": "date"}, format="%d.%m.%Y"),
|
||||
}
|
||||
# Make active_until not required
|
||||
required = {
|
||||
"active_until": False,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
permissions_context = kwargs.pop("permissions_context", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if permissions_context:
|
||||
for app, actions in permissions_context.items():
|
||||
field_name = f"permissions_{app}"
|
||||
self.fields[field_name] = forms.MultipleChoiceField(
|
||||
choices=[(action, action) for action in actions.keys()],
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
label=app.capitalize(),
|
||||
initial=[
|
||||
item
|
||||
for sublist in kwargs.get("initial", {}).get(field_name, [])
|
||||
for item in sublist
|
||||
],
|
||||
)
|
||||
self.fields["active_until"].required = False
|
||||
|
||||
def get_permissions_choices(self):
|
||||
permissions_choices = [
|
||||
(f"{app}.{action}", description)
|
||||
for app, actions in UserAPIToken.permission_template.items()
|
||||
for action, description in actions.items()
|
||||
]
|
||||
return permissions_choices
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
structured_permissions = {
|
||||
category: {perm: False for perm in permissions.keys()}
|
||||
for category, permissions in UserAPIToken.permission_template.items()
|
||||
}
|
||||
|
||||
for category in structured_permissions.keys():
|
||||
input_field_name = f"permissions_{category}"
|
||||
if input_field_name in self.data:
|
||||
selected_perms = self.data.getlist(input_field_name)
|
||||
for perm in selected_perms:
|
||||
if perm in structured_permissions[category]:
|
||||
structured_permissions[category][perm] = True
|
||||
|
||||
cleaned_data["permissions"] = json.dumps(structured_permissions)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
permissions_json = self.cleaned_data.get("permissions", "{}")
|
||||
instance.permissions = json.loads(permissions_json)
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
|
||||
return instance
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from akarpov.users.models import UserAPIToken
|
||||
|
||||
|
||||
class EmailVerificationMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
|
@ -21,12 +23,20 @@ def __init__(self, get_response):
|
|||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
if (
|
||||
request.path_info.startswith("/api/v1/music/")
|
||||
or request.path_info == "/api/v1/auth/token/"
|
||||
):
|
||||
if request.path_info == "/api/v1/auth/token/":
|
||||
return response
|
||||
|
||||
if "Authorization" in request.headers:
|
||||
try:
|
||||
token = request.headers["Authorization"]
|
||||
if " " in token:
|
||||
token = token.split(" ")[1]
|
||||
token = UserAPIToken.objects.cache().get(token=token)
|
||||
request.token_permissions = token.permissions
|
||||
return response
|
||||
except (KeyError, AttributeError, UserAPIToken.DoesNotExist):
|
||||
...
|
||||
|
||||
# Check user is authenticated and OTP token input is not completed
|
||||
is_authenticated = request.user.is_authenticated
|
||||
otp_not_verified = not request.session.get("otp_verified", False)
|
||||
|
|
40
akarpov/users/migrations/0015_userapitoken.py
Normal file
40
akarpov/users/migrations/0015_userapitoken.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
# Generated by Django 4.2.10 on 2024-03-29 15:18
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("users", "0014_alter_user_agree_data_to_be_sold"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserAPIToken",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("token", models.CharField(db_index=True, max_length=255, unique=True)),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("active_until", models.DateTimeField(null=True)),
|
||||
("permissions", models.JSONField(default=dict)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="api_tokens",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
17
akarpov/users/migrations/0016_userapitoken_last_used.py
Normal file
17
akarpov/users/migrations/0016_userapitoken_last_used.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 4.2.10 on 2024-03-29 18:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("users", "0015_userapitoken"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userapitoken",
|
||||
name="last_used",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
17
akarpov/users/migrations/0017_userapitoken_name.py
Normal file
17
akarpov/users/migrations/0017_userapitoken_name.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 4.2.10 on 2024-03-29 19:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("users", "0016_userapitoken_last_used"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userapitoken",
|
||||
name="name",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
|
@ -1,9 +1,12 @@
|
|||
import secrets
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from akarpov.common.models import BaseImageModel
|
||||
|
@ -78,6 +81,46 @@ def __str__(self):
|
|||
return self
|
||||
|
||||
|
||||
class UserNotification:
|
||||
# TODO: add notification system
|
||||
...
|
||||
class UserAPIToken(models.Model):
|
||||
name = models.CharField(max_length=255, blank=True, null=True)
|
||||
user = models.ForeignKey(
|
||||
"User", related_name="api_tokens", on_delete=models.CASCADE
|
||||
)
|
||||
token = models.CharField(max_length=255, unique=True, db_index=True)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
active_until = models.DateTimeField(null=True)
|
||||
permissions = models.JSONField(default=dict)
|
||||
last_used = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
permission_template = {
|
||||
"music": {
|
||||
"listen": "Listen to music",
|
||||
"upload": "Upload music",
|
||||
"playlist": "Manage playlists",
|
||||
},
|
||||
"users": {
|
||||
"edit": "Edit user profile",
|
||||
"delete": "Delete user profile",
|
||||
},
|
||||
"tools": {
|
||||
"shorten": "Shorten links",
|
||||
},
|
||||
"files": {
|
||||
"upload": "Upload files",
|
||||
"download": "Download files",
|
||||
},
|
||||
}
|
||||
|
||||
def __str__(self):
|
||||
return self.token
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.active_until is None or self.active_until > timezone.now()
|
||||
|
||||
@staticmethod
|
||||
def generate_token():
|
||||
token = secrets.token_urlsafe(32)
|
||||
while UserAPIToken.objects.filter(token=token).exists():
|
||||
token = secrets.token_urlsafe(32)
|
||||
return token
|
||||
|
|
11
akarpov/users/tasks.py
Normal file
11
akarpov/users/tasks.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
|
||||
from akarpov.users.models import UserAPIToken
|
||||
|
||||
|
||||
@shared_task
|
||||
def set_last_active_token(token: str):
|
||||
token = UserAPIToken.objects.get(token=token)
|
||||
token.last_used = timezone.now()
|
||||
token.save()
|
|
@ -1,13 +1,17 @@
|
|||
from django.urls import include, path
|
||||
|
||||
from akarpov.users.views import (
|
||||
create_token,
|
||||
delete_token,
|
||||
enable_2fa_view,
|
||||
enforce_otp_login,
|
||||
list_tokens,
|
||||
user_detail_view,
|
||||
user_history_delete_view,
|
||||
user_history_view,
|
||||
user_redirect_view,
|
||||
user_update_view,
|
||||
view_token,
|
||||
)
|
||||
|
||||
app_name = "users"
|
||||
|
@ -17,7 +21,11 @@
|
|||
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"),
|
||||
path("<str:username>/", view=user_detail_view, name="detail"),
|
||||
path("<str:username>", view=user_detail_view, name="detail"),
|
||||
path("2fa/login", enforce_otp_login, name="enforce_otp_login"),
|
||||
path("2fa/enable", enable_2fa_view, name="enable_2fa"),
|
||||
path("tokens/", list_tokens, name="list_tokens"),
|
||||
path("tokens/create/", create_token, name="create_token"),
|
||||
path("tokens/<int:token_id>/", view_token, name="view_token"),
|
||||
path("tokens/<int:token_id>/delete/", delete_token, name="delete_token"),
|
||||
]
|
||||
|
|
|
@ -5,16 +5,16 @@
|
|||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import redirect, render
|
||||
from django.http import HttpResponseRedirect, QueryDict
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView, RedirectView, UpdateView
|
||||
from django_otp import user_has_device
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
|
||||
from akarpov.users.forms import OTPForm
|
||||
from akarpov.users.models import UserHistory
|
||||
from akarpov.users.forms import OTPForm, TokenCreationForm
|
||||
from akarpov.users.models import UserAPIToken, UserHistory
|
||||
from akarpov.users.services.history import create_history_warning_note
|
||||
from akarpov.users.services.two_factor import generate_qr_code
|
||||
from akarpov.users.themes.models import Theme
|
||||
|
@ -203,3 +203,70 @@ def enforce_otp_login(request):
|
|||
else:
|
||||
form = OTPForm()
|
||||
return render(request, "users/otp_verify.html", {"form": form})
|
||||
|
||||
|
||||
@login_required
|
||||
def list_tokens(request):
|
||||
tokens = UserAPIToken.objects.filter(user=request.user).order_by("last_used")
|
||||
return render(request, "users/list_tokens.html", {"tokens": tokens})
|
||||
|
||||
|
||||
@login_required
|
||||
def create_token(request):
|
||||
initial_data = {}
|
||||
|
||||
# Обработка параметров 'name' и 'active_until'
|
||||
if "name" in request.GET:
|
||||
initial_data["name"] = request.GET["name"]
|
||||
if "active_until" in request.GET:
|
||||
initial_data["active_until"] = request.GET["active_until"]
|
||||
|
||||
# Создаем QueryDict для разрешений, чтобы правильно обработать повторяющиеся ключи
|
||||
permissions_query_dict = QueryDict("", mutable=True)
|
||||
|
||||
# Разбор параметров разрешений
|
||||
permissions = request.GET.getlist("permissions")
|
||||
for perm in permissions:
|
||||
category, permission = perm.split(".")
|
||||
permissions_query_dict.update({f"permissions_{category}": [permission]})
|
||||
|
||||
# Переводим QueryDict в обычный словарь для использования в initial
|
||||
permissions_data = {key: value for key, value in permissions_query_dict.lists()}
|
||||
|
||||
initial_data.update(permissions_data)
|
||||
|
||||
for key, value_list in permissions_data.items():
|
||||
initial_data[key] = [item for sublist in value_list for item in sublist]
|
||||
|
||||
form = TokenCreationForm(
|
||||
initial=initial_data, permissions_context=UserAPIToken.permission_template
|
||||
)
|
||||
if request.method == "POST":
|
||||
print(request.POST)
|
||||
form = TokenCreationForm(request.POST)
|
||||
if form.is_valid():
|
||||
new_token = form.save(commit=False)
|
||||
new_token.user = request.user
|
||||
new_token.token = UserAPIToken.generate_token()
|
||||
new_token.save()
|
||||
token_created = new_token.token
|
||||
return render(
|
||||
request, "users/token_created.html", {"new_token": token_created}
|
||||
)
|
||||
|
||||
return render(request, "users/create_token.html", {"form": form})
|
||||
|
||||
|
||||
@login_required
|
||||
def view_token(request, token_id):
|
||||
token = get_object_or_404(UserAPIToken, id=token_id, user=request.user)
|
||||
return render(request, "users/view_token.html", {"token": token})
|
||||
|
||||
|
||||
@login_required
|
||||
def delete_token(request, token_id):
|
||||
token = get_object_or_404(UserAPIToken, id=token_id, user=request.user)
|
||||
if request.method == "POST":
|
||||
token.delete()
|
||||
return redirect("users:list_tokens")
|
||||
return render(request, "users/confirm_delete_token.html", {"token": token})
|
||||
|
|
|
@ -5,4 +5,4 @@ set -o nounset
|
|||
|
||||
/install_preview_dependencies
|
||||
|
||||
celery -A config.celery_app worker --loglevel=info -c 5
|
||||
celery -A config.celery_app worker --autoscale 20 -l INFO
|
||||
|
|
4
compose/production/elasticsearch/Dockerfile
Normal file
4
compose/production/elasticsearch/Dockerfile
Normal file
|
@ -0,0 +1,4 @@
|
|||
FROM elasticsearch:8.11.1
|
||||
|
||||
# Install the ICU plugin
|
||||
RUN bin/elasticsearch-plugin install https://akarpov.ru/media/analysis-icu-8.11.1.zip
|
|
@ -1,6 +1,7 @@
|
|||
"""
|
||||
Base settings to build other settings files upon.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import environ
|
||||
|
@ -78,6 +79,7 @@
|
|||
"auth.permission": {"ops": "all", "timeout": 60 * 15},
|
||||
"music.*": {"ops": ("fetch", "get", "list"), "timeout": 60 * 15},
|
||||
"otp_totp.totpdevice": {"ops": "all", "timeout": 15 * 60},
|
||||
"users.userapitoken": {"ops": "all", "timeout": 20 * 60},
|
||||
}
|
||||
CACHEOPS_REDIS = env.str("REDIS_URL")
|
||||
|
||||
|
@ -131,8 +133,7 @@
|
|||
"rest_framework.authtoken",
|
||||
"corsheaders",
|
||||
"drf_spectacular",
|
||||
"ckeditor",
|
||||
"ckeditor_uploader",
|
||||
"django_ckeditor_5",
|
||||
"colorfield",
|
||||
"polymorphic",
|
||||
"cacheops",
|
||||
|
@ -510,6 +511,7 @@
|
|||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
"rest_framework.authentication.TokenAuthentication",
|
||||
"akarpov.users.api.authentification.UserTokenAuthentication",
|
||||
),
|
||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
|
@ -530,24 +532,128 @@
|
|||
|
||||
# CKEDITOR
|
||||
# ------------------------------------------------------------------------------
|
||||
CKEDITOR_UPLOAD_PATH = "uploads/"
|
||||
CKEDITOR_CONFIGS = {
|
||||
CKEDITOR_5_UPLOAD_PATH = "uploads/"
|
||||
CKEDITOR_5_CONFIGS = {
|
||||
"default": {
|
||||
"width": "full",
|
||||
"extra_plugins": [
|
||||
"autosave",
|
||||
"autogrow",
|
||||
"autolink",
|
||||
"autoembed",
|
||||
"clipboard",
|
||||
"dialog",
|
||||
"dialogui",
|
||||
"toolbar": [
|
||||
"heading",
|
||||
"|",
|
||||
"bold",
|
||||
"italic",
|
||||
"link",
|
||||
"bulletedList",
|
||||
"numberedList",
|
||||
"blockQuote",
|
||||
"imageUpload",
|
||||
],
|
||||
"autosave": {
|
||||
"autoLoad": True,
|
||||
"delay": 60,
|
||||
"NotOlderThen": 20,
|
||||
},
|
||||
"extends": {
|
||||
"blockToolbar": [
|
||||
"paragraph",
|
||||
"heading1",
|
||||
"heading2",
|
||||
"heading3",
|
||||
"|",
|
||||
"bulletedList",
|
||||
"numberedList",
|
||||
"|",
|
||||
"blockQuote",
|
||||
],
|
||||
"toolbar": [
|
||||
"heading",
|
||||
"|",
|
||||
"outdent",
|
||||
"indent",
|
||||
"|",
|
||||
"bold",
|
||||
"italic",
|
||||
"link",
|
||||
"underline",
|
||||
"strikethrough",
|
||||
"code",
|
||||
"subscript",
|
||||
"superscript",
|
||||
"highlight",
|
||||
"|",
|
||||
"codeBlock",
|
||||
"sourceEditing",
|
||||
"insertImage",
|
||||
"bulletedList",
|
||||
"numberedList",
|
||||
"todoList",
|
||||
"|",
|
||||
"blockQuote",
|
||||
"imageUpload",
|
||||
"|",
|
||||
"fontSize",
|
||||
"fontFamily",
|
||||
"fontColor",
|
||||
"fontBackgroundColor",
|
||||
"mediaEmbed",
|
||||
"removeFormat",
|
||||
"insertTable",
|
||||
],
|
||||
"image": {
|
||||
"toolbar": [
|
||||
"imageTextAlternative",
|
||||
"|",
|
||||
"imageStyle:alignLeft",
|
||||
"imageStyle:alignRight",
|
||||
"imageStyle:alignCenter",
|
||||
"imageStyle:side",
|
||||
"|",
|
||||
],
|
||||
"styles": [
|
||||
"full",
|
||||
"side",
|
||||
"alignLeft",
|
||||
"alignRight",
|
||||
"alignCenter",
|
||||
],
|
||||
},
|
||||
"table": {
|
||||
"contentToolbar": [
|
||||
"tableColumn",
|
||||
"tableRow",
|
||||
"mergeTableCells",
|
||||
"tableProperties",
|
||||
"tableCellProperties",
|
||||
],
|
||||
},
|
||||
"heading": {
|
||||
"options": [
|
||||
{
|
||||
"model": "paragraph",
|
||||
"title": "Paragraph",
|
||||
"class": "ck-heading_paragraph",
|
||||
},
|
||||
{
|
||||
"model": "heading1",
|
||||
"view": "h1",
|
||||
"title": "Heading 1",
|
||||
"class": "ck-heading_heading1",
|
||||
},
|
||||
{
|
||||
"model": "heading2",
|
||||
"view": "h2",
|
||||
"title": "Heading 2",
|
||||
"class": "ck-heading_heading2",
|
||||
},
|
||||
{
|
||||
"model": "heading3",
|
||||
"view": "h3",
|
||||
"title": "Heading 3",
|
||||
"class": "ck-heading_heading3",
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
"list": {
|
||||
"properties": {
|
||||
"styles": "true",
|
||||
"startIndex": "true",
|
||||
"reversed": "true",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -43,7 +43,9 @@
|
|||
path("forms/", include("akarpov.test_platform.urls", namespace="forms")),
|
||||
path("tools/", include("akarpov.tools.urls", namespace="tools")),
|
||||
path("gallery/", include("akarpov.gallery.urls", namespace="gallery")),
|
||||
path("ckeditor/", include("ckeditor_uploader.urls")),
|
||||
path(
|
||||
"ckeditor5/", include("django_ckeditor_5.urls"), name="ck_editor_5_upload_file"
|
||||
),
|
||||
path("accounts/", include("allauth.urls")),
|
||||
path("accounts/login/", OTPLoginView.as_view(), name="account_login"),
|
||||
path("", include("akarpov.blog.urls", namespace="blog")),
|
||||
|
|
|
@ -101,7 +101,9 @@ services:
|
|||
command: /start-flower
|
||||
|
||||
elasticsearch:
|
||||
image: elasticsearch:8.11.1
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./compose/production/elasticsearch/Dockerfile
|
||||
ports:
|
||||
- "9200:9200"
|
||||
- "9300:9300"
|
||||
|
|
2939
poetry.lock
generated
2939
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
|
@ -27,7 +27,6 @@ django-allauth = "^0.54.0"
|
|||
django-crispy-forms = "^2.1"
|
||||
crispy-bootstrap5 = "^0.7"
|
||||
django-redis = "^5.2.0"
|
||||
django-ckeditor = "^6.5.1"
|
||||
django-colorfield = "^0.11.0"
|
||||
djangorestframework = "^3.14.0"
|
||||
django-rest-auth = "^0.9.5"
|
||||
|
@ -121,6 +120,7 @@ python-levenshtein = "^0.23.0"
|
|||
pylast = "^5.2.0"
|
||||
textract = {git = "https://github.com/Alexander-D-Karpov/textract.git", branch = "master"}
|
||||
librosa = "^0.10.1"
|
||||
django-ckeditor-5 = "^0.2.12"
|
||||
|
||||
|
||||
[build-system]
|
||||
|
|
Loading…
Reference in New Issue
Block a user