mirror of
https://github.com/Alexander-D-Karpov/akarpov
synced 2024-12-01 04:03:44 +03:00
Compare commits
5 Commits
6ec31a1f52
...
fa2a3687b4
Author | SHA1 | Date | |
---|---|---|---|
|
fa2a3687b4 | ||
|
d08d5ee2fb | ||
bf9e2beda1 | |||
3adeb7c5a7 | |||
af5f1f8afc |
|
@ -1,4 +1,4 @@
|
||||||
from django_elasticsearch_dsl import Document
|
from django_elasticsearch_dsl import Document, fields
|
||||||
from django_elasticsearch_dsl.registries import registry
|
from django_elasticsearch_dsl.registries import registry
|
||||||
|
|
||||||
from akarpov.files.models import File
|
from akarpov.files.models import File
|
||||||
|
@ -6,26 +6,41 @@
|
||||||
|
|
||||||
@registry.register_document
|
@registry.register_document
|
||||||
class FileDocument(Document):
|
class FileDocument(Document):
|
||||||
class Index:
|
name = fields.TextField(
|
||||||
name = "files"
|
attr="name",
|
||||||
settings = {"number_of_shards": 1, "number_of_replicas": 0}
|
fields={
|
||||||
|
"raw": fields.KeywordField(normalizer="lowercase"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
description = fields.TextField(
|
||||||
|
attr="description",
|
||||||
|
fields={
|
||||||
|
"raw": fields.KeywordField(normalizer="lowercase"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
content = fields.TextField(
|
||||||
|
attr="content",
|
||||||
|
fields={
|
||||||
|
"raw": fields.KeywordField(normalizer="lowercase"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
class Django:
|
class Django:
|
||||||
model = File
|
model = File
|
||||||
fields = [
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"content",
|
|
||||||
]
|
|
||||||
|
|
||||||
def prepare_description(self, instance):
|
def prepare_description(self, instance):
|
||||||
# This method is called for every instance before indexing
|
|
||||||
return instance.description or ""
|
return instance.description or ""
|
||||||
|
|
||||||
def prepare_content(self, instance):
|
def prepare_content(self, instance):
|
||||||
# This method is called for every instance before indexing
|
# check instance.content is not None
|
||||||
return (
|
return (
|
||||||
instance.content.decode("utf-8")
|
instance.content.decode("utf-8")
|
||||||
if isinstance(instance.content, bytes)
|
if instance.content and isinstance(instance.content, bytes)
|
||||||
else instance.content
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class Index:
|
||||||
|
name = "files"
|
||||||
|
settings = {"number_of_shards": 1, "number_of_replicas": 0}
|
||||||
|
|
|
@ -40,13 +40,16 @@ def search(self, query: str):
|
||||||
ES_Q(
|
ES_Q(
|
||||||
"multi_match",
|
"multi_match",
|
||||||
query=query,
|
query=query,
|
||||||
fields=["name", "description", "content"],
|
fields=["name^3", "description^2", "content"],
|
||||||
type="best_fields",
|
type="best_fields",
|
||||||
|
fuzziness="AUTO",
|
||||||
),
|
),
|
||||||
ES_Q("match_phrase_prefix", name=query),
|
ES_Q("wildcard", name__raw=f"*{query.lower()}*"),
|
||||||
ES_Q("wildcard", name=f"*{query}*"),
|
ES_Q("wildcard", description__raw=f"*{query.lower()}*"),
|
||||||
ES_Q("wildcard", description=f"*{query}*"),
|
ES_Q("wildcard", content__raw=f"*{query.lower()}*"),
|
||||||
ES_Q("wildcard", content=f"*{query}*"),
|
ES_Q("wildcard", file_type__raw=f"*{query.lower()}*"),
|
||||||
|
ES_Q("wildcard", file_obj__raw=f"*{query.lower()}*"),
|
||||||
|
ES_Q("wildcard", preview__raw=f"*{query.lower()}*"),
|
||||||
],
|
],
|
||||||
minimum_should_match=1,
|
minimum_should_match=1,
|
||||||
)
|
)
|
||||||
|
|
|
@ -25,6 +25,7 @@
|
||||||
SongUserRating,
|
SongUserRating,
|
||||||
UserListenHistory,
|
UserListenHistory,
|
||||||
)
|
)
|
||||||
|
from akarpov.music.services.search import search_song
|
||||||
from akarpov.music.tasks import listen_to_song
|
from akarpov.music.tasks import listen_to_song
|
||||||
|
|
||||||
|
|
||||||
|
@ -83,6 +84,10 @@ class ListCreateSongAPIView(LikedSongsContextMixin, generics.ListCreateAPIView):
|
||||||
pagination_class = StandardResultsSetPagination
|
pagination_class = StandardResultsSetPagination
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
search = self.request.query_params.get("search", None)
|
||||||
|
if search:
|
||||||
|
qs = search_song(search)
|
||||||
|
else:
|
||||||
qs = Song.objects.cache()
|
qs = Song.objects.cache()
|
||||||
|
|
||||||
if "sort" in self.request.query_params:
|
if "sort" in self.request.query_params:
|
||||||
|
@ -111,6 +116,12 @@ def get_queryset(self):
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
parameters=[
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="search",
|
||||||
|
description="Search query",
|
||||||
|
required=False,
|
||||||
|
type=str,
|
||||||
|
),
|
||||||
OpenApiParameter(
|
OpenApiParameter(
|
||||||
name="sort",
|
name="sort",
|
||||||
description="Sorting algorithm",
|
description="Sorting algorithm",
|
||||||
|
|
67
akarpov/music/documents.py
Normal file
67
akarpov/music/documents.py
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
from django_elasticsearch_dsl import Document, fields
|
||||||
|
from django_elasticsearch_dsl.registries import registry
|
||||||
|
|
||||||
|
from akarpov.music.models import Song
|
||||||
|
|
||||||
|
|
||||||
|
@registry.register_document
|
||||||
|
class SongDocument(Document):
|
||||||
|
authors = fields.NestedField(
|
||||||
|
attr="authors",
|
||||||
|
properties={
|
||||||
|
"name": fields.TextField(
|
||||||
|
fields={
|
||||||
|
"raw": fields.KeywordField(normalizer="lowercase"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"link": fields.TextField(),
|
||||||
|
"meta": fields.ObjectField(dynamic=True),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
album = fields.NestedField(
|
||||||
|
attr="album",
|
||||||
|
properties={
|
||||||
|
"name": fields.TextField(
|
||||||
|
fields={
|
||||||
|
"raw": fields.KeywordField(normalizer="lowercase"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"link": fields.TextField(),
|
||||||
|
"meta": fields.ObjectField(dynamic=True),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
name = fields.TextField(
|
||||||
|
attr="name",
|
||||||
|
fields={
|
||||||
|
"raw": fields.KeywordField(normalizer="lowercase"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
meta = fields.ObjectField(dynamic=True) # Added meta field here as dynamic object
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
class Django:
|
||||||
|
model = Song
|
||||||
|
|
||||||
|
def get_instances_from_related(self, related_instance):
|
||||||
|
if isinstance(related_instance, Song):
|
||||||
|
return related_instance.album
|
||||||
|
return related_instance.songs.all()
|
|
@ -19,6 +19,7 @@ def load_dir(path: str, user_id: int):
|
||||||
|
|
||||||
|
|
||||||
def load_file(path: str, user_id: int):
|
def load_file(path: str, user_id: int):
|
||||||
|
# TODO: convert to mp3 if not mp3
|
||||||
process_mp3_file(path, user_id)
|
process_mp3_file(path, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
47
akarpov/music/services/search.py
Normal file
47
akarpov/music/services/search.py
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
from django.db.models import Case, When
|
||||||
|
from elasticsearch_dsl import Q as ES_Q
|
||||||
|
|
||||||
|
from akarpov.music.documents import SongDocument
|
||||||
|
from akarpov.music.models import Song
|
||||||
|
|
||||||
|
|
||||||
|
def search_song(query):
|
||||||
|
search = SongDocument.search()
|
||||||
|
search_query = ES_Q(
|
||||||
|
"bool",
|
||||||
|
should=[
|
||||||
|
ES_Q(
|
||||||
|
"multi_match",
|
||||||
|
query=query,
|
||||||
|
fields=["name^3", "authors.name^2", "album.name"],
|
||||||
|
fuzziness="AUTO",
|
||||||
|
), # Change here
|
||||||
|
ES_Q("wildcard", name__raw=f"*{query.lower()}*"),
|
||||||
|
ES_Q(
|
||||||
|
"nested",
|
||||||
|
path="authors",
|
||||||
|
query=ES_Q("wildcard", authors__name__raw=f"*{query.lower()}*"),
|
||||||
|
),
|
||||||
|
ES_Q(
|
||||||
|
"nested",
|
||||||
|
path="album",
|
||||||
|
query=ES_Q("wildcard", album__name__raw=f"*{query.lower()}*"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
minimum_should_match=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
search = search.query(search_query)
|
||||||
|
|
||||||
|
response = search.execute()
|
||||||
|
|
||||||
|
# Check for hits and get song instances
|
||||||
|
if response.hits:
|
||||||
|
hit_ids = [hit.meta.id for hit in response.hits]
|
||||||
|
songs = Song.objects.filter(id__in=hit_ids).order_by(
|
||||||
|
Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(hit_ids)])
|
||||||
|
)
|
||||||
|
|
||||||
|
return songs
|
||||||
|
|
||||||
|
return Song.objects.none()
|
|
@ -93,8 +93,9 @@
|
||||||
<ul class="dropdown-menu dropdown-menu-dark text-small shadow" aria-labelledby="dropdownUser1">
|
<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>
|
<li><a class="dropdown-item {% active_link 'users:update' %}" href="{% url 'users:update' %}">Settings</a></li>
|
||||||
<li><a class="dropdown-item {% active_link 'users:detail' request.user.username %}" href="{% url 'users:detail' request.user.username %}">Profile</a></li>
|
<li><a class="dropdown-item {% active_link 'users:detail' request.user.username %}" href="{% url 'users:detail' request.user.username %}">Profile</a></li>
|
||||||
<li><a class="dropdown-item {% active_link 'tools:qr:create' %}" href="{% url 'tools:promocodes:activate' %}">Activate promocode</a></li>
|
<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 'tools:qr:create' %}" href="{% url 'users:history' %}">History</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>
|
<li>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider">
|
||||||
</li>
|
</li>
|
||||||
|
|
24
akarpov/templates/users/disable_2fa.html
Normal file
24
akarpov/templates/users/disable_2fa.html
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mx-auto">
|
||||||
|
<div class="card mt-5">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="text-center">Disable Two-Factor Authentication</h2>
|
||||||
|
<p>If you want to disable two-factor authentication, please enter a token generated by your authentication app below. This is to verify your identity and to prevent unauthorized individuals from disabling your two-factor authentication.</p>
|
||||||
|
<p>If faced problems with disabling contact support</p>
|
||||||
|
<br>
|
||||||
|
<form method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% crispy form %}
|
||||||
|
<input type="submit" class="btn btn-primary btn-block" value="Disable 2FA">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
22
akarpov/templates/users/enable_2fa.html
Normal file
22
akarpov/templates/users/enable_2fa.html
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="mt-5">Enable Two-Factor Authentication</h1>
|
||||||
|
<p class="lead">Scan the QR code below or enter key with your TOTP app, then enter the code provided by the app to enable 2FA.</p>
|
||||||
|
<p>Your TOTP secret key is: {{ totp_key }}</p>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<img src="data:image/svg+xml;utf8,{{ qr_code_svg }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="form">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form|crispy }}
|
||||||
|
<button type="submit" class="btn btn-primary mt-2">Confirm</button>
|
||||||
|
<button type="submit" class="btn btn-secondary mt-2" name="cancel">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
19
akarpov/templates/users/otp_verify.html
Normal file
19
akarpov/templates/users/otp_verify.html
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">Enter OTP</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form|crispy }}
|
||||||
|
<button type="submit" class="btn btn-primary mt-2">Submit OTP</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -1,5 +1,6 @@
|
||||||
from allauth.account.forms import SignupForm
|
from allauth.account.forms import SignupForm
|
||||||
from allauth.socialaccount.forms import SignupForm as SocialSignupForm
|
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 forms as admin_forms
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
@ -40,3 +41,7 @@ class UserSocialSignupForm(SocialSignupForm):
|
||||||
Default fields will be added automatically.
|
Default fields will be added automatically.
|
||||||
See UserSignupForm otherwise.
|
See UserSignupForm otherwise.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class OTPForm(forms.Form):
|
||||||
|
otp_token = forms.CharField()
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import resolve
|
||||||
from django.utils.deprecation import MiddlewareMixin
|
from django.utils.deprecation import MiddlewareMixin
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
|
@ -8,3 +10,23 @@ def process_request(self, request):
|
||||||
if not request.user.is_verified:
|
if not request.user.is_verified:
|
||||||
raise AuthenticationFailed("Email is not verified")
|
raise AuthenticationFailed("Email is not verified")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Enforce2FAMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
response = self.get_response(request)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
on_2fa_page = resolve(request.path_info).url_name == "enforce_otp_login"
|
||||||
|
|
||||||
|
# Enforce OTP token input, if user is authenticated but has not verified OTP, and is NOT on the 2FA page
|
||||||
|
if is_authenticated and otp_not_verified and not on_2fa_page:
|
||||||
|
request.session["next"] = request.get_full_path()
|
||||||
|
return redirect("users:enforce_otp_login")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
14
akarpov/users/services/two_factor.py
Normal file
14
akarpov/users/services/two_factor.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import io
|
||||||
|
|
||||||
|
import qrcode
|
||||||
|
import qrcode.image.svg
|
||||||
|
|
||||||
|
|
||||||
|
def generate_qr_code(data):
|
||||||
|
qr_image = qrcode.make(data, image_factory=qrcode.image.svg.SvgImage)
|
||||||
|
byte_arr = io.BytesIO()
|
||||||
|
qr_image.save(byte_arr)
|
||||||
|
|
||||||
|
qr_svg = byte_arr.getvalue().decode().replace("\n", "")
|
||||||
|
|
||||||
|
return qr_svg
|
|
@ -1,6 +1,8 @@
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
from akarpov.users.views import (
|
from akarpov.users.views import (
|
||||||
|
enable_2fa_view,
|
||||||
|
enforce_otp_login,
|
||||||
user_detail_view,
|
user_detail_view,
|
||||||
user_history_delete_view,
|
user_history_delete_view,
|
||||||
user_history_view,
|
user_history_view,
|
||||||
|
@ -16,4 +18,6 @@
|
||||||
path("history/", view=user_history_view, name="history"),
|
path("history/", view=user_history_view, name="history"),
|
||||||
path("history/delete", view=user_history_delete_view, name="history_delete"),
|
path("history/delete", view=user_history_delete_view, name="history_delete"),
|
||||||
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"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,12 +1,22 @@
|
||||||
|
from allauth.account.views import LoginView
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.urls import reverse
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView, RedirectView, UpdateView
|
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.models import UserHistory
|
||||||
from akarpov.users.services.history import create_history_warning_note
|
from akarpov.users.services.history import create_history_warning_note
|
||||||
|
from akarpov.users.services.two_factor import generate_qr_code
|
||||||
from akarpov.users.themes.models import Theme
|
from akarpov.users.themes.models import Theme
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
@ -87,3 +97,103 @@ def get_redirect_url(self):
|
||||||
|
|
||||||
|
|
||||||
user_history_delete_view = UserHistoryDeleteView.as_view()
|
user_history_delete_view = UserHistoryDeleteView.as_view()
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def enable_2fa_view(request):
|
||||||
|
user = request.user
|
||||||
|
devices = TOTPDevice.objects.filter(user=user, confirmed=True)
|
||||||
|
|
||||||
|
if devices.exists():
|
||||||
|
if request.method == "POST":
|
||||||
|
form = OTPForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
token = form.cleaned_data["otp_token"]
|
||||||
|
|
||||||
|
# Verifying the token against all confirmed devices
|
||||||
|
for device in devices:
|
||||||
|
if device.verify_token(token):
|
||||||
|
device.delete() # Delete the device if the token is valid
|
||||||
|
|
||||||
|
# Check if there are still confirmed devices left
|
||||||
|
if not TOTPDevice.objects.filter(user=user, confirmed=True).exists():
|
||||||
|
messages.success(request, "Two-factor authentication disabled!")
|
||||||
|
return redirect(reverse_lazy("blog:post_list"))
|
||||||
|
else:
|
||||||
|
messages.error(request, "Invalid token, please try again.")
|
||||||
|
else:
|
||||||
|
messages.error(
|
||||||
|
request, "The form submission was not valid. Please, try again."
|
||||||
|
)
|
||||||
|
form = OTPForm(initial={"otp_token": ""})
|
||||||
|
return render(request, "users/disable_2fa.html", {"form": form})
|
||||||
|
|
||||||
|
else:
|
||||||
|
if request.method == "POST":
|
||||||
|
device = TOTPDevice.objects.filter(user=user, confirmed=False).first()
|
||||||
|
if request.POST.get("cancel"):
|
||||||
|
device.delete()
|
||||||
|
return redirect(reverse_lazy("blog:post_list"))
|
||||||
|
form = OTPForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
token = form.cleaned_data["otp_token"]
|
||||||
|
if device and device.verify_token(token):
|
||||||
|
device.confirmed = True
|
||||||
|
device.save()
|
||||||
|
messages.success(request, "Two-factor authentication enabled!")
|
||||||
|
return redirect(reverse_lazy("blog:post_list"))
|
||||||
|
else:
|
||||||
|
messages.error(request, "Invalid token, please try again.")
|
||||||
|
else:
|
||||||
|
device, created = TOTPDevice.objects.get_or_create(
|
||||||
|
user=user, confirmed=False
|
||||||
|
)
|
||||||
|
site_name = get_current_site(request).name
|
||||||
|
provisioning_url = device.config_url
|
||||||
|
provisioning_url_site = provisioning_url.replace(
|
||||||
|
"otpauth://totp/", f"otpauth://totp/{site_name}:"
|
||||||
|
)
|
||||||
|
qr_code_svg = generate_qr_code(provisioning_url_site)
|
||||||
|
totp_key = device.key # get device's secret key
|
||||||
|
|
||||||
|
form = OTPForm(initial={"otp_token": ""})
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"users/enable_2fa.html",
|
||||||
|
{"form": form, "qr_code_svg": qr_code_svg, "totp_key": totp_key},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OTPLoginView(LoginView):
|
||||||
|
def form_valid(self, form):
|
||||||
|
# Store URL for next page from the Login form
|
||||||
|
self.request.session["next"] = self.request.GET.get("next")
|
||||||
|
|
||||||
|
resp = super().form_valid(form)
|
||||||
|
|
||||||
|
# Redirect users with OTP devices to enter their tokens
|
||||||
|
if user_has_device(self.request.user):
|
||||||
|
return HttpResponseRedirect(reverse("users:enforce_otp_login"))
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
login_view = OTPLoginView.as_view()
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def enforce_otp_login(request):
|
||||||
|
# TODO gather next url from loginrequired
|
||||||
|
if request.method == "POST":
|
||||||
|
form = OTPForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
otp_token = form.cleaned_data["otp_token"]
|
||||||
|
device = TOTPDevice.objects.filter(user=request.user).first()
|
||||||
|
if device.verify_token(otp_token):
|
||||||
|
request.session["otp_verified"] = True
|
||||||
|
success_url = request.session.pop("next", None) or reverse_lazy("home")
|
||||||
|
return HttpResponseRedirect(success_url)
|
||||||
|
else:
|
||||||
|
form = OTPForm()
|
||||||
|
return render(request, "users/otp_verify.html", {"form": form})
|
||||||
|
|
|
@ -48,6 +48,14 @@ COPY ./compose/production/django/entrypoint /entrypoint
|
||||||
RUN sed -i 's/\r$//g' /entrypoint
|
RUN sed -i 's/\r$//g' /entrypoint
|
||||||
RUN chmod +x /entrypoint
|
RUN chmod +x /entrypoint
|
||||||
|
|
||||||
|
COPY ./compose/production/django/manage /manage
|
||||||
|
RUN sed -i 's/\r$//g' /manage
|
||||||
|
RUN chmod +x /manage
|
||||||
|
|
||||||
|
COPY ./compose/production/django/manage /manage.py
|
||||||
|
RUN sed -i 's/\r$//g' /manage
|
||||||
|
RUN chmod +x /manage
|
||||||
|
|
||||||
COPY ./compose/local/django/start /start
|
COPY ./compose/local/django/start /start
|
||||||
RUN sed -i 's/\r$//g' /start
|
RUN sed -i 's/\r$//g' /start
|
||||||
RUN chmod +x /start
|
RUN chmod +x /start
|
||||||
|
|
14
compose/production/django/manage
Normal file
14
compose/production/django/manage
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -o errexit
|
||||||
|
set -o pipefail
|
||||||
|
set -o nounset
|
||||||
|
|
||||||
|
if [ -z "${POSTGRES_USER}" ]; then
|
||||||
|
base_postgres_image_default_user='postgres'
|
||||||
|
export POSTGRES_USER="${base_postgres_image_default_user}"
|
||||||
|
fi
|
||||||
|
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
|
||||||
|
|
||||||
|
|
||||||
|
exec /venv/bin/python /app/manage.py "$@"
|
|
@ -143,6 +143,12 @@
|
||||||
"django_tables2",
|
"django_tables2",
|
||||||
"location_field",
|
"location_field",
|
||||||
"django_elasticsearch_dsl",
|
"django_elasticsearch_dsl",
|
||||||
|
# 2fa
|
||||||
|
"django_otp",
|
||||||
|
"django_otp.plugins.otp_static",
|
||||||
|
"django_otp.plugins.otp_totp",
|
||||||
|
"django_otp.plugins.otp_hotp",
|
||||||
|
"django_otp.plugins.otp_email",
|
||||||
]
|
]
|
||||||
|
|
||||||
HEALTH_CHECKS = [
|
HEALTH_CHECKS = [
|
||||||
|
@ -237,6 +243,8 @@
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django_otp.middleware.OTPMiddleware",
|
||||||
|
"akarpov.users.middleware.Enforce2FAMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.common.BrokenLinkEmailsMiddleware",
|
"django.middleware.common.BrokenLinkEmailsMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
@ -317,17 +325,6 @@
|
||||||
|
|
||||||
# EMAIL
|
# EMAIL
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
"""
|
|
||||||
host: EMAIL_HOST
|
|
||||||
port: EMAIL_PORT
|
|
||||||
username: EMAIL_HOST_USER
|
|
||||||
password: EMAIL_HOST_PASSWORD
|
|
||||||
use_tls: EMAIL_USE_TLS
|
|
||||||
use_ssl: EMAIL_USE_SSL
|
|
||||||
timeout: EMAIL_TIMEOUT
|
|
||||||
ssl_keyfile: EMAIL_SSL_KEYFILE
|
|
||||||
ssl_certfile: EMAIL_SSL_CERTFILE
|
|
||||||
"""
|
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
|
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
|
||||||
EMAIL_BACKEND = env(
|
EMAIL_BACKEND = env(
|
||||||
"DJANGO_EMAIL_BACKEND",
|
"DJANGO_EMAIL_BACKEND",
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
|
|
||||||
from akarpov.about.views import about_view, list_faq
|
from akarpov.about.views import about_view, list_faq
|
||||||
from akarpov.tools.shortener.views import redirect_view
|
from akarpov.tools.shortener.views import redirect_view
|
||||||
|
from akarpov.users.views import OTPLoginView
|
||||||
from config.sitemaps import sitemaps
|
from config.sitemaps import sitemaps
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
@ -44,6 +45,7 @@
|
||||||
path("gallery/", include("akarpov.gallery.urls", namespace="gallery")),
|
path("gallery/", include("akarpov.gallery.urls", namespace="gallery")),
|
||||||
path("ckeditor/", include("ckeditor_uploader.urls")),
|
path("ckeditor/", include("ckeditor_uploader.urls")),
|
||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
|
path("accounts/login/", OTPLoginView.as_view(), name="account_login"),
|
||||||
path("", include("akarpov.blog.urls", namespace="blog")),
|
path("", include("akarpov.blog.urls", namespace="blog")),
|
||||||
path("<str:slug>", redirect_view, name="short_url"),
|
path("<str:slug>", redirect_view, name="short_url"),
|
||||||
# Your stuff: custom urls includes go here
|
# Your stuff: custom urls includes go here
|
||||||
|
|
63
poetry.lock
generated
63
poetry.lock
generated
|
@ -3324,6 +3324,16 @@ files = [
|
||||||
{file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
|
{file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
|
||||||
{file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
|
{file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
|
||||||
{file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
|
{file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
|
||||||
|
{file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"},
|
||||||
|
{file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"},
|
||||||
|
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"},
|
||||||
|
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"},
|
||||||
|
{file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"},
|
||||||
|
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"},
|
||||||
|
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"},
|
||||||
|
{file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"},
|
||||||
|
{file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"},
|
||||||
|
{file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"},
|
||||||
{file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
|
{file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
|
||||||
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
|
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
|
||||||
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
|
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
|
||||||
|
@ -4654,6 +4664,17 @@ files = [
|
||||||
{file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
|
{file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pypng"
|
||||||
|
version = "0.20220715.0"
|
||||||
|
description = "Pure Python library for saving and loading PNG images"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
files = [
|
||||||
|
{file = "pypng-0.20220715.0-py3-none-any.whl", hash = "sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c"},
|
||||||
|
{file = "pypng-0.20220715.0.tar.gz", hash = "sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pysocks"
|
name = "pysocks"
|
||||||
version = "1.7.1"
|
version = "1.7.1"
|
||||||
|
@ -4968,6 +4989,7 @@ files = [
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
|
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
|
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
|
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
|
||||||
|
{file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
|
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
|
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
|
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
|
||||||
|
@ -4975,8 +4997,15 @@ files = [
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
|
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
|
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
|
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
|
||||||
|
{file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
|
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
|
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
|
||||||
|
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
|
||||||
|
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
|
||||||
|
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
|
||||||
|
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
|
||||||
|
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
|
||||||
|
{file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
|
||||||
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
|
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
|
||||||
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
|
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
|
||||||
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
|
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
|
||||||
|
@ -4993,6 +5022,7 @@ files = [
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
|
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
|
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
|
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
|
||||||
|
{file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
|
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
|
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
|
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
|
||||||
|
@ -5000,11 +5030,36 @@ files = [
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
|
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
|
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
|
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
|
||||||
|
{file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
|
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
|
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
|
||||||
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
|
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "7.4.2"
|
||||||
|
description = "QR Code image generator"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
files = [
|
||||||
|
{file = "qrcode-7.4.2-py3-none-any.whl", hash = "sha256:581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a"},
|
||||||
|
{file = "qrcode-7.4.2.tar.gz", hash = "sha256:9dd969454827e127dbd93696b20747239e6d540e082937c90f14ac95b30f5845"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||||
|
pillow = {version = ">=9.1.0", optional = true, markers = "extra == \"pil\""}
|
||||||
|
pypng = "*"
|
||||||
|
typing-extensions = "*"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
all = ["pillow (>=9.1.0)", "pytest", "pytest-cov", "tox", "zest.releaser[recommended]"]
|
||||||
|
dev = ["pytest", "pytest-cov", "tox"]
|
||||||
|
maintainer = ["zest.releaser[recommended]"]
|
||||||
|
pil = ["pillow (>=9.1.0)"]
|
||||||
|
test = ["coverage", "pytest"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rawpy"
|
name = "rawpy"
|
||||||
version = "0.19.0"
|
version = "0.19.0"
|
||||||
|
@ -6487,13 +6542,13 @@ files = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "werkzeug"
|
name = "werkzeug"
|
||||||
version = "2.3.8"
|
version = "3.0.1"
|
||||||
description = "The comprehensive WSGI web application library."
|
description = "The comprehensive WSGI web application library."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "werkzeug-2.3.8-py3-none-any.whl", hash = "sha256:bba1f19f8ec89d4d607a3bd62f1904bd2e609472d93cd85e9d4e178f472c3748"},
|
{file = "werkzeug-3.0.1-py3-none-any.whl", hash = "sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10"},
|
||||||
{file = "werkzeug-2.3.8.tar.gz", hash = "sha256:554b257c74bbeb7a0d254160a4f8ffe185243f52a52035060b761ca62d977f03"},
|
{file = "werkzeug-3.0.1.tar.gz", hash = "sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
|
@ -6838,4 +6893,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.11"
|
python-versions = "^3.11"
|
||||||
content-hash = "5393f9258c796dbbf62840fdd2ba5d76e62f589f911ad767fbe093ba173b0ae8"
|
content-hash = "430dbcfedd71046dd61f34bd472d95d279b81dad7f14917086953c4b24187b2f"
|
||||||
|
|
|
@ -33,7 +33,7 @@ djangorestframework = "^3.14.0"
|
||||||
django-rest-auth = "^0.9.5"
|
django-rest-auth = "^0.9.5"
|
||||||
django-cors-headers = "^4.0.0"
|
django-cors-headers = "^4.0.0"
|
||||||
drf-spectacular = "^0.26.2"
|
drf-spectacular = "^0.26.2"
|
||||||
werkzeug = {extras = ["watchdog"], version = "^2.3.4"}
|
werkzeug = {extras = ["watchdog"], version = "^3.0.1"}
|
||||||
ipdb = "^0.13.13"
|
ipdb = "^0.13.13"
|
||||||
watchfiles = "^0.18.1"
|
watchfiles = "^0.18.1"
|
||||||
mypy = "^0.991"
|
mypy = "^0.991"
|
||||||
|
@ -115,6 +115,7 @@ numpy = "1.25.2"
|
||||||
deep-translator = "1.4.2"
|
deep-translator = "1.4.2"
|
||||||
textract = {git = "https://github.com/Alexander-D-Karpov/textract.git", branch = "master"}
|
textract = {git = "https://github.com/Alexander-D-Karpov/textract.git", branch = "master"}
|
||||||
django-otp = "^1.3.0"
|
django-otp = "^1.3.0"
|
||||||
|
qrcode = {extras = ["pil"], version = "^7.4.2"}
|
||||||
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|
Loading…
Reference in New Issue
Block a user