added folder creation, folder meta, folder upload, tables

This commit is contained in:
Alexander Karpov 2023-04-24 14:48:15 +03:00
parent 83ea171886
commit 6d65e771d7
18 changed files with 349 additions and 85 deletions

14
akarpov/files/filters.py Normal file
View 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"]

View File

@ -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"]

View File

@ -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)

View File

@ -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
View 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",
)

View File

@ -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"),

View File

@ -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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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,67 +33,101 @@
{% endblock %}
{% block content %}
{% for folder in folders %}
{{ folder.name }}
{% endfor %}
<div class="row justify-content-center">
{% if request.user.is_authenticated %}
<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 %}
<fieldset class="upload_dropZone text-center mb-3 p-4">
<legend class="visually-hidden">Image uploader</legend>
<svg class="upload_svg" width="60" height="60" aria-hidden="true">
<use href="#icon-imageUpload"></use>
</svg>
<p class="small my-2">Drag &amp; Drop file(s) <br><i>or</i></p>
<input class="position-absolute invisible" id="chunked_upload" type="file" name="the_file">
<label class="btn btn-upload mb-3" for="chunked_upload">Choose file(s)</label>
<div class="upload_gallery d-flex flex-wrap justify-content-center gap-3 mb-0"></div>
</fieldset>
<svg style="display:none">
<defs>
<symbol id="icon-imageUpload" clip-rule="evenodd" viewBox="0 0 96 96">
<path d="M47 6a21 21 0 0 0-12.3 3.8c-2.7 2.1-4.4 5-4.7 7.1-5.8 1.2-10.3 5.6-10.3 10.6 0 6 5.8 11 13 11h12.6V22.7l-7.1 6.8c-.4.3-.9.5-1.4.5-1 0-2-.8-2-1.7 0-.4.3-.9.6-1.2l10.3-8.8c.3-.4.8-.6 1.3-.6.6 0 1 .2 1.4.6l10.2 8.8c.4.3.6.8.6 1.2 0 1-.9 1.7-2 1.7-.5 0-1-.2-1.3-.5l-7.2-6.8v15.6h14.4c6.1 0 11.2-4.1 11.2-9.4 0-5-4-8.8-9.5-9.4C63.8 11.8 56 5.8 47 6Zm-1.7 42.7V38.4h3.4v10.3c0 .8-.7 1.5-1.7 1.5s-1.7-.7-1.7-1.5Z M27 49c-4 0-7 2-7 6v29c0 3 3 6 6 6h42c3 0 6-3 6-6V55c0-4-3-6-7-6H28Zm41 3c1 0 3 1 3 3v19l-13-6a2 2 0 0 0-2 0L44 79l-10-5a2 2 0 0 0-2 0l-9 7V55c0-2 2-3 4-3h41Z M40 62c0 2-2 4-5 4s-5-2-5-4 2-4 5-4 5 2 5 4Z"></path>
</symbol>
</defs>
</svg>
<p class="text-break" id="progress-message"></p>
<div id="progress" class="progress w-100" style="display: none" role="progressbar" aria-label="Warning example" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="progress-bar" class="progress-bar text-bg-warning" style="width: 0%">0%</div>
</div>
<div id="messages"></div>
</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">
{% if file.is_file %}
<div class="card-body d-flex flex-column">
<h5 class="card-title">{{ file.name }}</h5>
<p class="card-text mb-4"><small class="text-muted">{{ file.file_size | filesizeformat }}</small></p>
<div class="align-self-stretch align-items-center justify-content-center d-flex flex-column fill-height controlsdiv">
<img src="{{ file.file_image_url }}" class="img-fluid" alt="">
</div>
<a href="{% url 'files:view' file.slug %}" class="stretched-link"></a>
<p class="card-text mb-4 mt-2 ms-3"><small class="text-muted">{{ file.modified | naturaltime }}</small></p>
</div>
{% else %}
<div class="card-body d-flex flex-column">
<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>
</div>
<a href="{% url 'files:folder' file.slug %}" class="stretched-link"></a>
<p class="card-text mb-4 mt-2 ms-3"><small class="text-muted">{{ file.modified | naturaltime }}</small></p>
</div>
{% endif %}
<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 %}
</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>
{% endfor %}
{% 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 %}
<fieldset class="upload_dropZone text-center mb-3 p-4">
<legend class="visually-hidden">Image uploader</legend>
<svg class="upload_svg" width="60" height="60" aria-hidden="true">
<use href="#icon-imageUpload"></use>
</svg>
<p class="small my-2">Drag &amp; Drop file(s) <br><i>or</i></p>
<input class="position-absolute invisible" id="chunked_upload" type="file" name="the_file">
<label class="btn btn-upload mb-3" for="chunked_upload">Choose file(s)</label>
<div class="upload_gallery d-flex flex-wrap justify-content-center gap-3 mb-0"></div>
</fieldset>
<svg style="display:none">
<defs>
<symbol id="icon-imageUpload" clip-rule="evenodd" viewBox="0 0 96 96">
<path d="M47 6a21 21 0 0 0-12.3 3.8c-2.7 2.1-4.4 5-4.7 7.1-5.8 1.2-10.3 5.6-10.3 10.6 0 6 5.8 11 13 11h12.6V22.7l-7.1 6.8c-.4.3-.9.5-1.4.5-1 0-2-.8-2-1.7 0-.4.3-.9.6-1.2l10.3-8.8c.3-.4.8-.6 1.3-.6.6 0 1 .2 1.4.6l10.2 8.8c.4.3.6.8.6 1.2 0 1-.9 1.7-2 1.7-.5 0-1-.2-1.3-.5l-7.2-6.8v15.6h14.4c6.1 0 11.2-4.1 11.2-9.4 0-5-4-8.8-9.5-9.4C63.8 11.8 56 5.8 47 6Zm-1.7 42.7V38.4h3.4v10.3c0 .8-.7 1.5-1.7 1.5s-1.7-.7-1.7-1.5Z M27 49c-4 0-7 2-7 6v29c0 3 3 6 6 6h42c3 0 6-3 6-6V55c0-4-3-6-7-6H28Zm41 3c1 0 3 1 3 3v19l-13-6a2 2 0 0 0-2 0L44 79l-10-5a2 2 0 0 0-2 0l-9 7V55c0-2 2-3 4-3h41Z M40 62c0 2-2 4-5 4s-5-2-5-4 2-4 5-4 5 2 5 4Z"></path>
</symbol>
</defs>
</svg>
<p class="text-break" id="progress-message"></p>
<div id="progress" class="progress w-100" style="display: none" role="progressbar" aria-label="Warning example" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<div id="progress-bar" class="progress-bar text-bg-warning" style="width: 0%">0%</div>
</div>
<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 justify-content-center">
{% if file.is_file %}
<div class="card-body d-flex flex-column">
<h5 class="card-title">{{ file.name }}</h5>
<p class="card-text mb-4"><small class="text-muted">{{ file.file_size | filesizeformat }}</small></p>
<div class="align-self-stretch align-items-center justify-content-center d-flex flex-column fill-height controlsdiv">
<img src="{{ file.file_image_url }}" class="img-fluid" alt="">
</div>
<a href="{% url 'files:view' file.slug %}" class="stretched-link"></a>
<p class="card-text mb-4 mt-2 ms-3"><small class="text-muted">{{ file.modified | naturaltime }}</small></p>
</div>
{% else %}
<div class="card-body d-flex flex-column">
<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">
<img src="{% static 'images/files/folder.jpg' %}" class="img-fluid" alt="">
</div>
<a href="{% url 'files:folder' file.slug %}" class="stretched-link"></a>
<p class="card-text mb-4 mt-2 ms-3"><small class="text-muted">{{ file.modified | naturaltime }}</small></p>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% if page_obj.has_other_pages %}
<div class="btn-group" role="group" aria-label="Item pagination">

View 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 %}

View File

@ -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 %}

View File

@ -115,6 +115,8 @@
"akarpov.contrib.chunked_upload",
"active_link",
"robots",
"django_filters",
"django_tables2",
# django-cms
"cms",
"menus",

57
poetry.lock generated
View File

@ -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"

View File

@ -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]