mirror of
https://github.com/Alexander-D-Karpov/akarpov
synced 2024-11-22 09:46:34 +03:00
added folder creation, folder meta, folder upload, tables
This commit is contained in:
parent
83ea171886
commit
6d65e771d7
14
akarpov/files/filters.py
Normal file
14
akarpov/files/filters.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
import django_filters
|
||||
|
||||
from .models import File
|
||||
|
||||
|
||||
class FileFilter(django_filters.FilterSet):
|
||||
modified = django_filters.DateFromToRangeFilter(
|
||||
label="Dates",
|
||||
widget=django_filters.widgets.RangeWidget(attrs={"type": "date"}),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = File
|
||||
fields = ["modified", "private", "parent"]
|
|
@ -1,9 +1,15 @@
|
|||
from django import forms
|
||||
|
||||
from akarpov.files.models import File
|
||||
from akarpov.files.models import File, Folder
|
||||
|
||||
|
||||
class FileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = File
|
||||
fields = ["name", "private", "description"]
|
||||
|
||||
|
||||
class FolderForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Folder
|
||||
fields = ["name", "private"]
|
||||
|
|
|
@ -39,13 +39,13 @@ class Meta:
|
|||
def is_file(self):
|
||||
return type(self) is File
|
||||
|
||||
def get_folder_chain(self):
|
||||
folders = [self]
|
||||
def get_top_folders(self):
|
||||
folders = []
|
||||
obj = self
|
||||
while obj.parent:
|
||||
folders.append(obj.parent)
|
||||
obj = obj.parent
|
||||
return folders
|
||||
return folders[::-1]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get("update_fields", None)
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
from django.core.files.base import File
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
|
||||
from akarpov.files.models import File as FileModel
|
||||
from akarpov.files.models import FileInTrash
|
||||
|
@ -10,8 +11,13 @@
|
|||
|
||||
|
||||
@receiver(post_save, sender=FileModel)
|
||||
def post_on_create(sender, instance: FileModel, created, **kwargs):
|
||||
def file_on_create(sender, instance: FileModel, created, **kwargs):
|
||||
if created:
|
||||
for folder in instance.get_top_folders():
|
||||
folder.modified = now()
|
||||
folder.size += instance.file.size
|
||||
folder.amount += 1
|
||||
folder.save()
|
||||
process_file.apply_async(
|
||||
kwargs={
|
||||
"pk": instance.pk,
|
||||
|
@ -23,6 +29,12 @@ def post_on_create(sender, instance: FileModel, created, **kwargs):
|
|||
@receiver(post_delete, sender=FileModel)
|
||||
def move_file_to_trash(sender, instance, **kwargs):
|
||||
if instance.file:
|
||||
for folder in instance.get_top_folders():
|
||||
folder.modified = now()
|
||||
folder.size -= instance.file.size
|
||||
folder.amount -= 1
|
||||
folder.save()
|
||||
|
||||
name = instance.file.name.split("/")[-1]
|
||||
trash = FileInTrash(user=instance.user, name=name)
|
||||
trash.file = File(instance.file, name=name)
|
||||
|
|
30
akarpov/files/tables.py
Normal file
30
akarpov/files/tables.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
import django_tables2 as tables
|
||||
|
||||
from akarpov.files.models import File
|
||||
|
||||
|
||||
class FileTable(tables.Table):
|
||||
name = tables.columns.Column("name", linkify=True)
|
||||
time = tables.columns.DateTimeColumn(
|
||||
format="H:m:s", accessor="modified", verbose_name="Time"
|
||||
)
|
||||
folder = tables.columns.Column(
|
||||
linkify=True, accessor="parent", verbose_name="Folder"
|
||||
)
|
||||
file = tables.columns.FileColumn(
|
||||
linkify=True, accessor="file_obj", verbose_name="File"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = File
|
||||
template_name = "django_tables2/bootstrap5.html"
|
||||
fields = (
|
||||
"name",
|
||||
"created",
|
||||
"modified",
|
||||
"time",
|
||||
"folder",
|
||||
"private",
|
||||
"file",
|
||||
"file_type",
|
||||
)
|
|
@ -5,14 +5,17 @@
|
|||
MyChunkedUploadView,
|
||||
TopFolderView,
|
||||
delete_file_view,
|
||||
file_table,
|
||||
file_update,
|
||||
files_view,
|
||||
folder_create,
|
||||
folder_view,
|
||||
)
|
||||
|
||||
app_name = "files"
|
||||
urlpatterns = [
|
||||
path("", TopFolderView.as_view(), name="main"),
|
||||
path("table/", file_table, name="table"),
|
||||
path(
|
||||
"api/chunked_upload_complete/",
|
||||
MyChunkedUploadCompleteView.as_view(),
|
||||
|
@ -26,6 +29,8 @@
|
|||
path(
|
||||
"api/chunked_upload/", MyChunkedUploadView.as_view(), name="api_chunked_upload"
|
||||
),
|
||||
path("api/folder/create/", folder_create, name="folder_create"),
|
||||
path("api/folder/create/<str:slug>", folder_create, name="sub_folder_create"),
|
||||
path("<str:slug>", files_view, name="view"),
|
||||
path("<str:slug>/update", file_update, name="update"),
|
||||
path("<str:slug>/delete", delete_file_view, name="delete"),
|
||||
|
|
|
@ -6,9 +6,16 @@
|
|||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from django.views.generic import DetailView, ListView, RedirectView, UpdateView
|
||||
from django.views.generic.base import TemplateView
|
||||
from django.views.generic import (
|
||||
CreateView,
|
||||
DetailView,
|
||||
ListView,
|
||||
RedirectView,
|
||||
UpdateView,
|
||||
)
|
||||
from django_filters.views import FilterView
|
||||
from django_tables2 import SingleTableView
|
||||
from django_tables2.export import ExportMixin
|
||||
|
||||
from akarpov.contrib.chunked_upload.exceptions import ChunkedUploadError
|
||||
from akarpov.contrib.chunked_upload.models import ChunkedUpload
|
||||
|
@ -16,22 +23,29 @@
|
|||
ChunkedUploadCompleteView,
|
||||
ChunkedUploadView,
|
||||
)
|
||||
from akarpov.files.forms import FileForm
|
||||
from akarpov.files.filters import FileFilter
|
||||
from akarpov.files.forms import FileForm, FolderForm
|
||||
from akarpov.files.models import BaseFileItem, File, Folder
|
||||
from akarpov.files.previews import extensions, meta, meta_extensions, previews
|
||||
from akarpov.files.services.preview import get_base_meta
|
||||
from akarpov.files.tables import FileTable
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class TopFolderView(LoginRequiredMixin, ListView):
|
||||
template_name = "files/list.html"
|
||||
paginate_by = 19
|
||||
paginate_by = 18
|
||||
model = BaseFileItem
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["folder_slug"] = None
|
||||
context["folder_form"] = FolderForm()
|
||||
context["is_folder_owner"] = True
|
||||
|
||||
# folder path
|
||||
context["folders"] = []
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
|
@ -41,17 +55,28 @@ def get_queryset(self):
|
|||
class FileFolderView(ListView):
|
||||
template_name = "files/folder.html"
|
||||
model = BaseFileItem
|
||||
paginate_by = 39
|
||||
paginate_by = 38
|
||||
slug_field = "slug"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.object = None
|
||||
|
||||
def get_paginate_by(self, queryset):
|
||||
if self.request.user == self.get_object().user:
|
||||
# return 38 items for owner to fit file and folder forms
|
||||
return 38
|
||||
return 40
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
folder = self.get_object()
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["folder_slug"] = folder.slug
|
||||
context["folder_form"] = FolderForm()
|
||||
context["is_folder_owner"] = self.request.user == self.get_object().user
|
||||
|
||||
# folder path
|
||||
context["folders"] = folder.get_top_folders() + [folder]
|
||||
return context
|
||||
|
||||
def get_object(self, *args):
|
||||
|
@ -70,7 +95,7 @@ class FileUpdateView(LoginRequiredMixin, UpdateView):
|
|||
model = File
|
||||
form_class = FileForm
|
||||
|
||||
def get_object(self):
|
||||
def get_object(self, *args):
|
||||
file = get_object_or_404(File, slug=self.kwargs["slug"])
|
||||
if file.user != self.request.user:
|
||||
raise PermissionDenied
|
||||
|
@ -82,6 +107,27 @@ def get_object(self):
|
|||
file_update = FileUpdateView.as_view()
|
||||
|
||||
|
||||
class FolderCreateView(LoginRequiredMixin, CreateView):
|
||||
model = Folder
|
||||
form_class = FolderForm
|
||||
|
||||
def form_valid(self, form):
|
||||
folder = None
|
||||
if "slug" in self.kwargs and self.kwargs["slug"]:
|
||||
folder = get_object_or_404(Folder, slug=self.kwargs["slug"])
|
||||
form.instance.user = self.request.user
|
||||
form.instance.parent = folder
|
||||
super().form_valid(form)
|
||||
if folder:
|
||||
return HttpResponseRedirect(
|
||||
reverse("files:folder", kwargs={"slug": folder.slug})
|
||||
)
|
||||
return HttpResponseRedirect(reverse("files:main"))
|
||||
|
||||
|
||||
folder_create = FolderCreateView.as_view()
|
||||
|
||||
|
||||
class FileView(DetailView):
|
||||
template_name = "files/view.html"
|
||||
model = File
|
||||
|
@ -161,10 +207,6 @@ def get_redirect_url(self, *args, **kwargs):
|
|||
folder_view = FileFolderView.as_view()
|
||||
|
||||
|
||||
class ChunkedUploadDemo(LoginRequiredMixin, TemplateView):
|
||||
template_name = "files/upload.html"
|
||||
|
||||
|
||||
class MyChunkedUploadView(ChunkedUploadView):
|
||||
model = ChunkedUpload
|
||||
field_name = "the_file"
|
||||
|
@ -214,11 +256,6 @@ def on_completion(self, uploaded_file, request):
|
|||
name=uploaded_file.name,
|
||||
parent=folder,
|
||||
)
|
||||
if folder:
|
||||
folder.modified = now()
|
||||
folder.size += uploaded_file.size
|
||||
folder.amount += 1
|
||||
folder.save()
|
||||
request.user.left_file_upload -= uploaded_file.size
|
||||
request.user.save()
|
||||
self.message = {
|
||||
|
@ -235,3 +272,17 @@ def on_completion(self, uploaded_file, request):
|
|||
|
||||
def get_response_data(self, chunked_upload, request):
|
||||
return self.message
|
||||
|
||||
|
||||
class FileTableView(LoginRequiredMixin, FilterView, ExportMixin, SingleTableView):
|
||||
model = File
|
||||
table_class = FileTable
|
||||
filterset_class = FileFilter
|
||||
template_name = "files/tables.html"
|
||||
paginate_by = 200
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
return File.objects.filter(user=self.request.user)
|
||||
|
||||
|
||||
file_table = FileTableView.as_view()
|
||||
|
|
BIN
akarpov/static/images/files/folder.jpg
Normal file
BIN
akarpov/static/images/files/folder.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
|
@ -1 +1,8 @@
|
|||
{% extends 'files/list.html' %}
|
||||
|
||||
{% block meta %}
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{{ folder.name }}">
|
||||
<meta property="og:url" content="{{ folder.get_absolute_url }}">
|
||||
<meta property="og:description" content="folder on akarpov.ru">
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load static crispy_forms_tags %}
|
||||
|
||||
{% block title %}editing file on akarpov{% endblock %}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% extends 'base.html' %}
|
||||
{% load humanize static %}
|
||||
{% load humanize static crispy_forms_tags %}
|
||||
|
||||
{% block javascript %}
|
||||
<script src="{% static 'js/jquery.min.js' %}"></script>
|
||||
|
@ -33,11 +33,29 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% for folder in folders %}
|
||||
{{ folder.name }}
|
||||
<div class="ms-3 row">
|
||||
{% if is_folder_owner %}
|
||||
{% if folder_slug %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item active" aria-current="page"><a href="{% url 'files:main' %}">home</a></li>
|
||||
{% for f in folders %}
|
||||
<li class="breadcrumb-item"><a href="{% url 'files:folder' slug=f.slug %}">{{ f.name }}</a></li>
|
||||
{% endfor %}
|
||||
<div class="row justify-content-center">
|
||||
{% if request.user.is_authenticated %}
|
||||
</ol>
|
||||
</nav>
|
||||
{% else %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item active" aria-current="page"><a href="{% url 'files:main' %}">home</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-end me-5">
|
||||
<a class="me-5" href="{% url 'files:table' %}">View in table view</a>
|
||||
</div>
|
||||
{% 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">
|
||||
<div class="card-body d-flex flex-column justify-content-center align-items-center">
|
||||
{% csrf_token %}
|
||||
|
@ -65,9 +83,25 @@
|
|||
<div id="messages"></div>
|
||||
</div>
|
||||
</div>
|
||||
<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>Add folder</h5>
|
||||
<form class="pt-2" method="POST" id="designer-form" action="{% if folder_slug %}{% url 'files:sub_folder_create' slug=folder_slug %}{% else %}{% url 'files:folder_create' %}{% endif %}">
|
||||
{% csrf_token %}
|
||||
{% for field in folder_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">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for file in basefileitem_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">
|
||||
<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">
|
||||
<h5 class="card-title">{{ file.name }}</h5>
|
||||
|
@ -84,7 +118,7 @@
|
|||
<h5 class="card-title">{{ file.name }}</h5>
|
||||
<p class="card-text mb-4"><small class="text-muted">{{ file.size | filesizeformat }}, {{ file.amount }} {% if file.amount == 1 %} item {% else %} items {% endif %}</small></p>
|
||||
<div class="align-self-stretch align-items-center justify-content-center d-flex flex-column fill-height controlsdiv">
|
||||
<i class="bi bi-folder"></i>
|
||||
<img src="{% static 'images/files/folder.jpg' %}" class="img-fluid" alt="">
|
||||
</div>
|
||||
|
||||
<a href="{% url 'files:folder' file.slug %}" class="stretched-link"></a>
|
||||
|
|
35
akarpov/templates/files/tables.html
Normal file
35
akarpov/templates/files/tables.html
Normal file
|
@ -0,0 +1,35 @@
|
|||
{% extends "base.html" %}
|
||||
{% load django_tables2 crispy_forms_tags %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.pagination {
|
||||
gap: 20px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="justify-content-end d-flex">
|
||||
<a href="{% url 'files:main' %}">View in folder view</a>
|
||||
</div>
|
||||
<form class="mb-4 p-2" >
|
||||
<div class="row d-flex">
|
||||
{% for field in filter.form %}
|
||||
<div class="col col-md-4 col-sm-5 justify-content-center align-self-center">{{ field | as_crispy_field }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-auto justify-content-center align-self-center">
|
||||
<button class="btn btn-success mt-3">Apply filters</button>
|
||||
</div>
|
||||
<div class="col col-auto justify-content-center align-self-center">
|
||||
<a style="text-decoration: none" href="{% export_url "xlsx" %}" class="btn btn-warning mt-3">Export into Excel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="table-responsive">
|
||||
{% render_table table %}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -14,6 +14,17 @@
|
|||
|
||||
|
||||
{% block content %}
|
||||
{% if has_perm %}
|
||||
<nav class="ms-3" aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item active" aria-current="page"><a href="{% url 'files:main' %}">home</a></li>
|
||||
{% for f in file.get_top_folders %}
|
||||
<li class="breadcrumb-item"><a href="{% url 'files:folder' slug=f.slug %}">{{ f.name }}</a></li>
|
||||
{% endfor %}
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ file.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
<div class="row m-2">
|
||||
<h1 class="fs-1 text-break mb-4">{{ file.name }}
|
||||
{% if has_perm %}
|
||||
|
|
|
@ -115,6 +115,8 @@
|
|||
"akarpov.contrib.chunked_upload",
|
||||
"active_link",
|
||||
"robots",
|
||||
"django_filters",
|
||||
"django_tables2",
|
||||
# django-cms
|
||||
"cms",
|
||||
"menus",
|
||||
|
|
57
poetry.lock
generated
57
poetry.lock
generated
|
@ -1519,6 +1519,21 @@ files = [
|
|||
[package.dependencies]
|
||||
jsonfield = ">=3.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "django-filter"
|
||||
version = "23.1"
|
||||
description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "django-filter-23.1.tar.gz", hash = "sha256:dee5dcf2cea4d7f767e271b6d01f767fce7500676d5e5dc58dac8154000b87df"},
|
||||
{file = "django_filter-23.1-py3-none-any.whl", hash = "sha256:e3c52ad83c32fb5882125105efb5fea2a1d6a85e7dc64b04ef52edbf14451b6c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
Django = ">=3.2"
|
||||
|
||||
[[package]]
|
||||
name = "django-formtools"
|
||||
version = "2.4"
|
||||
|
@ -1757,6 +1772,24 @@ files = [
|
|||
django = "*"
|
||||
typing-extensions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "django-tables2"
|
||||
version = "2.5.3"
|
||||
description = "Table/data-grid framework for Django"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "django-tables2-2.5.3.tar.gz", hash = "sha256:f6c1623aac188d29aae9cf6b4de3211c96c525e49890654bec3359c181600eb9"},
|
||||
{file = "django_tables2-2.5.3-py2.py3-none-any.whl", hash = "sha256:e336fdf8899a8fab110550a40cad956064bd4054818e0b972c1893b3e2542168"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
Django = ">=3.2"
|
||||
|
||||
[package.extras]
|
||||
tablib = ["tablib"]
|
||||
|
||||
[[package]]
|
||||
name = "django-timezone-field"
|
||||
version = "5.0"
|
||||
|
@ -4780,6 +4813,28 @@ docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-
|
|||
tests = ["coverage[toml]", "freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"]
|
||||
typing = ["mypy", "rich", "twisted"]
|
||||
|
||||
[[package]]
|
||||
name = "tablib"
|
||||
version = "3.4.0"
|
||||
description = "Format agnostic tabular data library (XLS, JSON, YAML, CSV, etc.)"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "tablib-3.4.0-py3-none-any.whl", hash = "sha256:0f9c45141195c472202f30d82c0c035bf7e0dd7e4da2257815e506acff4ab364"},
|
||||
{file = "tablib-3.4.0.tar.gz", hash = "sha256:77ea97faf6f92a7e198c05bd0c690f3cba57b83ea45a636b72f967cb6fe6f160"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
all = ["markuppy", "odfpy", "openpyxl (>=2.6.0)", "pandas", "pyyaml", "tabulate", "xlrd", "xlwt"]
|
||||
cli = ["tabulate"]
|
||||
html = ["markuppy"]
|
||||
ods = ["odfpy"]
|
||||
pandas = ["pandas"]
|
||||
xls = ["xlrd", "xlwt"]
|
||||
xlsx = ["openpyxl (>=2.6.0)"]
|
||||
yaml = ["pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "2.2.0"
|
||||
|
@ -5642,4 +5697,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
|
|||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.11"
|
||||
content-hash = "667d1226a682d79bea11fdee05d079b1b494ac325167ca069f79f4d18d1a7d04"
|
||||
content-hash = "c065bd831f46b58518895fa101821a580bebe3f7792348ca31d6a4fb4aab2b82"
|
||||
|
|
|
@ -88,6 +88,9 @@ cairosvg = "^2.7.0"
|
|||
textract = "^1.6.5"
|
||||
spotipy = "2.16.1"
|
||||
django-robots = "^5.0"
|
||||
django-tables2 = "^2.5.3"
|
||||
django-filter = "^23.1"
|
||||
tablib = "^3.4.0"
|
||||
|
||||
|
||||
[build-system]
|
||||
|
|
Loading…
Reference in New Issue
Block a user