added shortcutter view meta

This commit is contained in:
Alexander Karpov 2023-08-05 11:41:29 +03:00
parent f0dd4e2b27
commit 5ab4501783
17 changed files with 267 additions and 34 deletions

7
akarpov/static/js/clipboard.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -42,6 +42,7 @@
<body>
<div class="container-fluid">
<div class="row vh-100 overflow-auto">
{# TODO: add cache for sidebar #}
<div class="sidebar col-12 col-lg-2 col-sm-3 col-md-2 col-xl-1 px-sm-2 px-0 bg-dark d-flex sticky-top">
<div class="d-flex flex-sm-column flex-row flex-grow-1 align-items-center align-items-sm-start px-3 pt-2 text-white">
<a href="/" class="d-flex align-items-center pb-sm-3 mb-md-0 me-md-auto text-white text-wrap text-decoration-none">

View File

@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load static %}
{% block meta %}
{% autoescape off %}
@ -51,7 +52,7 @@
<p>File is private</p>
{% else %}
<p>File is public{% if file.short_link %},
<a href="{{ file.short_link.get_absolute_url }}">short link</a><button class="btn" data-clipboard-text="{{ request.get_host }}{{ file.short_link.get_absolute_url }}">
<a href="{{ file.get_short_link }}">short link</a><button class="btn" data-clipboard-text="{{ request.get_host }}{{ file.get_short_link }}">
<i style="font-size: 0.8em" class="bi bi-clipboard ml-2"></i>
</button>
{% endif %}</p>
@ -68,7 +69,7 @@
{{ preview_content }}
{% endautoescape %}
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js"></script>
<script src="{% static 'js/clipboard.min.js' %}"></script>
<script>
new ClipboardJS('.btn');
</script>

View File

@ -1,14 +0,0 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<form class="form-horizontal" enctype="multipart/form-data" method="post">
{% csrf_token %}
{{ form|crispy }}
<div class="control-group">
<div class="controls">
<button type="submit" class="btn btn-success">Create</button>
</div>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,53 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<form class="form-horizontal" enctype="multipart/form-data" method="post">
{% csrf_token %}
{{ form|crispy }}
<div class="control-group">
<div class="controls">
<button type="submit" class="btn btn-success">Create</button>
</div>
</div>
</form>
<div class="row m-2 gap-5">
{% for link in object_list %}
<div class="col-auto card text-center {% if card.enabled == False %} opacity-25 {% endif %}">
<div class="m-3">
<h5 class="card-title">{{ link.full_source }}</h5>
<p class="mb-5">Viewed: {{ link.viewed }}</p>
<p>Created: {{ link.created | time:"H:i"}} {{ link.created | date:"d.m.Y"}}</p>
<p>Updated: {{ link.modified | time:"H:i"}} {{ link.modified | date:"d.m.Y"}}</p>
<a href="{{ link.get_absolute_url }}" class="stretched-link"></a>
</div>
</div>
{% endfor %}
{% if page_obj.has_other_pages %}
<div class="btn-group" role="group" aria-label="Item pagination">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}" class="btn btn-outline-warning">&laquo;</a>
{% endif %}
{% for page_number in page_obj.paginator.page_range %}
{% if page_obj.number == page_number %}
<button class="btn btn-outline-warning active">
<span>{{ page_number }} <span class="sr-only">(current)</span></span>
</button>
{% else %}
<a href="?page={{ page_number }}" class="btn btn-outline-warning">
{{ page_number }}
</a>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}" class="btn btn-outline-warning">&raquo;</a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -1,6 +1,39 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
{{ link }}
<div class="m-2">
<h4>Link to: <a class="fs-4" href="{{ link.source }}">{{ link.full_source }}</a></h4>
<p>{{ request.get_host }}{% url 'short_url' slug=link.slug %} <button class="btn" data-clipboard-text="{{ request.get_host }}{% url 'short_url' slug=link.slug %}">
<i style="font-size: 0.8em" class="bi bi-clipboard ml-2"></i>
</button></p>
<p>Viewed: {{ link.viewed }} times</p>
<p>Recent views:</p>
<table class="table table-hover">
<thead>
<tr>
<th scope="col">IP</th>
<th scope="col">User Agent</th>
<th scope="col">Time</th>
<th scope="col">User</th>
</tr>
</thead>
<tbody>
{% for view in views %}
<tr>
<td>{{ view.ip }}</td>
<td>{{ view.user_agent }}</td>
<td>{{ view.viewed | time:"H:i:s"}} {{ view.viewed | date:"d.m.Y"}}</td>
<td>{% if view.user %}<a href="{{ view.user.get_absolute_url }}">{{ view.user.username }}</a>{% else %} - {% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script src="{% static 'js/clipboard.min.js' %}"></script>
<script>
new ClipboardJS('.btn');
</script>
{% endblock %}

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.3 on 2023-08-04 13:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("shortener", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name="link",
options={"ordering": ["-modified"]},
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.3 on 2023-08-05 08:28
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("shortener", "0002_alter_link_options"),
]
operations = [
migrations.AddField(
model_name="linkviewmeta",
name="user",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="link_views",
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.3 on 2023-08-05 08:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("shortener", "0003_linkviewmeta_user"),
]
operations = [
migrations.AlterModelOptions(
name="linkviewmeta",
options={"ordering": ["-viewed"]},
),
]

View File

@ -9,7 +9,7 @@
class Link(TimeStampedModel):
source = models.URLField(blank=False)
slug = models.SlugField(db_index=True)
slug = models.SlugField(db_index=True, unique=False)
creator = models.ForeignKey(
"users.User", related_name="links", null=True, on_delete=models.SET_NULL
)
@ -17,12 +17,23 @@ class Link(TimeStampedModel):
viewed = models.IntegerField(default=0)
@property
def full_source(self):
return (
"https://akarpov.ru" + self.source
if self.source.startswith("/")
else self.source
)
def get_absolute_url(self):
return reverse("short_url", kwargs={"slug": self.slug})
return reverse("tools:shortener:view", kwargs={"slug": self.slug})
def __str__(self):
return f"link to {self.source}"
class Meta:
ordering = ["-modified"]
class LinkViewMeta(models.Model):
# TODO: move to mem, delete within 7 days
@ -31,6 +42,12 @@ class LinkViewMeta(models.Model):
viewed = models.DateTimeField(auto_now_add=True)
ip = models.GenericIPAddressField()
user_agent = models.CharField(max_length=200)
user = models.ForeignKey(
"users.User", related_name="link_views", null=True, on_delete=models.SET_NULL
)
class Meta:
ordering = ["-viewed"]
def __str__(self):
return f"view on {self.link.source}"
@ -38,6 +55,7 @@ def __str__(self):
def create_model_link(sender, instance, created, **kwargs):
# had to move to models due to circular import
# TODO: add link create to celery
if created:
link = Link(source=instance.get_absolute_url())
if hasattr(instance, "private"):
@ -112,7 +130,7 @@ def __init_subclass__(cls, **kwargs):
@abstractmethod
def get_absolute_url(self):
...
raise NotImplementedError
@property
def get_short_link(self) -> str:

View File

@ -1,3 +1,4 @@
from functools import lru_cache
from secrets import compare_digest
from django.conf import settings
@ -28,3 +29,12 @@ def get_link_from_slug(slug: str, check_whole=True) -> Link | bool:
return link
except Link.DoesNotExist:
return False
@lru_cache
def get_cached_link_source(slug: str) -> tuple[bool, bool] | tuple[str, int]:
# TODO: add TTL here or update cache on link update
link = get_link_from_slug(slug)
if link:
return link.source, link.pk
return False, False

View File

@ -0,0 +1,15 @@
from celery import shared_task
from akarpov.tools.shortener.models import Link, LinkViewMeta
@shared_task
def save_view_meta(pk, ip, user_agent, user_id):
link = Link.objects.get(pk=pk)
meta = LinkViewMeta(link=link, ip=ip, user_agent=user_agent)
if user_id:
meta.user_id = user_id
meta.save()
link.viewed += 1
link.save(update_fields=["viewed"])
return

View File

@ -1,10 +1,11 @@
from django.urls import path
from akarpov.tools.shortener.views import short_link_create_view
from akarpov.tools.shortener.views import link_detail_view, short_link_create_view
app_name = "shortener"
urlpatterns = [
path("", short_link_create_view, name="create"),
path("<str:slug>", link_detail_view, name="view"),
path("revoked", short_link_create_view, name="revoked"),
]

View File

@ -1,17 +1,25 @@
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.http import HttpResponseNotFound, HttpResponseRedirect
from django.views.generic import CreateView, DetailView, TemplateView
from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponseNotFound, HttpResponseRedirect
from django.views.generic import CreateView, DetailView, ListView, TemplateView
from ipware import get_client_ip
from akarpov.tools.shortener.forms import LinkForm
from akarpov.tools.shortener.models import Link
from akarpov.tools.shortener.services import get_link_from_slug
from akarpov.tools.shortener.services import get_cached_link_source, get_link_from_slug
from akarpov.tools.shortener.tasks import save_view_meta
class ShortLinkCreateView(CreateView):
class ShortLinkCreateListView(CreateView, ListView):
model = Link
form_class = LinkForm
paginate_by = 50
template_name = "tools/shortener/create.html"
template_name = "tools/shortener/list_create.html"
def get_queryset(self):
if self.request.user.is_authenticated:
return Link.objects.filter(creator=self.request.user)
return Link.objects.none()
def form_valid(self, form):
if self.request.user.is_authenticated:
@ -19,21 +27,27 @@ def form_valid(self, form):
return super().form_valid(form)
short_link_create_view = ShortLinkCreateView.as_view()
short_link_create_view = ShortLinkCreateListView.as_view()
class LinkDetailView(DetailView):
template_name = "tools/shortener/view.html"
def get_object(self, *args, **kwargs):
link = get_link_from_slug(self.kwargs["slug"])
if not link:
raise ObjectDoesNotExist
raise Http404
if self.request.user.is_superuser:
return link
if link.creator and link.creator != self.request.user:
raise PermissionDenied
return link
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["views"] = kwargs["object"].views.all().prefetch_related("user")
return context
link_detail_view = LinkDetailView.as_view()
@ -46,8 +60,21 @@ class LinkRevokedView(TemplateView):
def redirect_view(request, slug):
# TODO: move to faster framework, like FastApi
link = get_link_from_slug(slug)
# TODO: move to faster framework, like FastAPI
link, pk = get_cached_link_source(slug)
if not link:
return HttpResponseNotFound("such link doesn't exist or has been revoked")
return HttpResponseRedirect(link.source)
ip, is_routable = get_client_ip(request)
if request.user.is_authenticated:
user_id = request.user.id
else:
user_id = None
save_view_meta.apply_async(
kwargs={
"pk": pk,
"ip": ip,
"user_agent": request.META["HTTP_USER_AGENT"],
"user_id": user_id,
},
)
return HttpResponseRedirect(link)

7
clipboard.min.js vendored Normal file

File diff suppressed because one or more lines are too long

15
poetry.lock generated
View File

@ -2860,10 +2860,13 @@ files = [
{file = "lxml-4.9.3-cp27-cp27m-macosx_11_0_x86_64.whl", hash = "sha256:b0a545b46b526d418eb91754565ba5b63b1c0b12f9bd2f808c852d9b4b2f9b5c"},
{file = "lxml-4.9.3-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:075b731ddd9e7f68ad24c635374211376aa05a281673ede86cbe1d1b3455279d"},
{file = "lxml-4.9.3-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1e224d5755dba2f4a9498e150c43792392ac9b5380aa1b845f98a1618c94eeef"},
{file = "lxml-4.9.3-cp27-cp27m-win32.whl", hash = "sha256:2c74524e179f2ad6d2a4f7caf70e2d96639c0954c943ad601a9e146c76408ed7"},
{file = "lxml-4.9.3-cp27-cp27m-win_amd64.whl", hash = "sha256:4f1026bc732b6a7f96369f7bfe1a4f2290fb34dce00d8644bc3036fb351a4ca1"},
{file = "lxml-4.9.3-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0781a98ff5e6586926293e59480b64ddd46282953203c76ae15dbbbf302e8bb"},
{file = "lxml-4.9.3-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cef2502e7e8a96fe5ad686d60b49e1ab03e438bd9123987994528febd569868e"},
{file = "lxml-4.9.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:b86164d2cff4d3aaa1f04a14685cbc072efd0b4f99ca5708b2ad1b9b5988a991"},
{file = "lxml-4.9.3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:42871176e7896d5d45138f6d28751053c711ed4d48d8e30b498da155af39aebd"},
{file = "lxml-4.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ae8b9c6deb1e634ba4f1930eb67ef6e6bf6a44b6eb5ad605642b2d6d5ed9ce3c"},
{file = "lxml-4.9.3-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:411007c0d88188d9f621b11d252cce90c4a2d1a49db6c068e3c16422f306eab8"},
{file = "lxml-4.9.3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:cd47b4a0d41d2afa3e58e5bf1f62069255aa2fd6ff5ee41604418ca925911d76"},
{file = "lxml-4.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0e2cb47860da1f7e9a5256254b74ae331687b9672dfa780eed355c4c9c3dbd23"},
@ -2872,6 +2875,7 @@ files = [
{file = "lxml-4.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:97047f0d25cd4bcae81f9ec9dc290ca3e15927c192df17331b53bebe0e3ff96d"},
{file = "lxml-4.9.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:1f447ea5429b54f9582d4b955f5f1985f278ce5cf169f72eea8afd9502973dd5"},
{file = "lxml-4.9.3-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:57d6ba0ca2b0c462f339640d22882acc711de224d769edf29962b09f77129cbf"},
{file = "lxml-4.9.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:9767e79108424fb6c3edf8f81e6730666a50feb01a328f4a016464a5893f835a"},
{file = "lxml-4.9.3-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:71c52db65e4b56b8ddc5bb89fb2e66c558ed9d1a74a45ceb7dcb20c191c3df2f"},
{file = "lxml-4.9.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d73d8ecf8ecf10a3bd007f2192725a34bd62898e8da27eb9d32a58084f93962b"},
{file = "lxml-4.9.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0a3d3487f07c1d7f150894c238299934a2a074ef590b583103a45002035be120"},
@ -2891,6 +2895,7 @@ files = [
{file = "lxml-4.9.3-cp36-cp36m-macosx_11_0_x86_64.whl", hash = "sha256:64f479d719dc9f4c813ad9bb6b28f8390360660b73b2e4beb4cb0ae7104f1c12"},
{file = "lxml-4.9.3-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:dd708cf4ee4408cf46a48b108fb9427bfa00b9b85812a9262b5c668af2533ea5"},
{file = "lxml-4.9.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c31c7462abdf8f2ac0577d9f05279727e698f97ecbb02f17939ea99ae8daa98"},
{file = "lxml-4.9.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e3cd95e10c2610c360154afdc2f1480aea394f4a4f1ea0a5eacce49640c9b190"},
{file = "lxml-4.9.3-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:4930be26af26ac545c3dffb662521d4e6268352866956672231887d18f0eaab2"},
{file = "lxml-4.9.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4aec80cde9197340bc353d2768e2a75f5f60bacda2bab72ab1dc499589b3878c"},
{file = "lxml-4.9.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:14e019fd83b831b2e61baed40cab76222139926b1fb5ed0e79225bc0cae14584"},
@ -2900,6 +2905,7 @@ files = [
{file = "lxml-4.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bef4e656f7d98aaa3486d2627e7d2df1157d7e88e7efd43a65aa5dd4714916cf"},
{file = "lxml-4.9.3-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:46f409a2d60f634fe550f7133ed30ad5321ae2e6630f13657fb9479506b00601"},
{file = "lxml-4.9.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4c28a9144688aef80d6ea666c809b4b0e50010a2aca784c97f5e6bf143d9f129"},
{file = "lxml-4.9.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:141f1d1a9b663c679dc524af3ea1773e618907e96075262726c7612c02b149a4"},
{file = "lxml-4.9.3-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:53ace1c1fd5a74ef662f844a0413446c0629d151055340e9893da958a374f70d"},
{file = "lxml-4.9.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:17a753023436a18e27dd7769e798ce302963c236bc4114ceee5b25c18c52c693"},
{file = "lxml-4.9.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7d298a1bd60c067ea75d9f684f5f3992c9d6766fadbc0bcedd39750bf344c2f4"},
@ -2909,6 +2915,7 @@ files = [
{file = "lxml-4.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:120fa9349a24c7043854c53cae8cec227e1f79195a7493e09e0c12e29f918e52"},
{file = "lxml-4.9.3-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:4d2d1edbca80b510443f51afd8496be95529db04a509bc8faee49c7b0fb6d2cc"},
{file = "lxml-4.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8d7e43bd40f65f7d97ad8ef5c9b1778943d02f04febef12def25f7583d19baac"},
{file = "lxml-4.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:71d66ee82e7417828af6ecd7db817913cb0cf9d4e61aa0ac1fde0583d84358db"},
{file = "lxml-4.9.3-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:6fc3c450eaa0b56f815c7b62f2b7fba7266c4779adcf1cece9e6deb1de7305ce"},
{file = "lxml-4.9.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65299ea57d82fb91c7f019300d24050c4ddeb7c5a190e076b5f48a2b43d19c42"},
{file = "lxml-4.9.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:eadfbbbfb41b44034a4c757fd5d70baccd43296fb894dba0295606a7cf3124aa"},
@ -2918,6 +2925,7 @@ files = [
{file = "lxml-4.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:92af161ecbdb2883c4593d5ed4815ea71b31fafd7fd05789b23100d081ecac96"},
{file = "lxml-4.9.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:9bb6ad405121241e99a86efff22d3ef469024ce22875a7ae045896ad23ba2340"},
{file = "lxml-4.9.3-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8ed74706b26ad100433da4b9d807eae371efaa266ffc3e9191ea436087a9d6a7"},
{file = "lxml-4.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fbf521479bcac1e25a663df882c46a641a9bff6b56dc8b0fafaebd2f66fb231b"},
{file = "lxml-4.9.3-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:303bf1edce6ced16bf67a18a1cf8339d0db79577eec5d9a6d4a80f0fb10aa2da"},
{file = "lxml-4.9.3-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:5515edd2a6d1a5a70bfcdee23b42ec33425e405c5b351478ab7dc9347228f96e"},
{file = "lxml-4.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:690dafd0b187ed38583a648076865d8c229661ed20e48f2335d68e2cf7dc829d"},
@ -2928,13 +2936,16 @@ files = [
{file = "lxml-4.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:4dd9a263e845a72eacb60d12401e37c616438ea2e5442885f65082c276dfb2b2"},
{file = "lxml-4.9.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6689a3d7fd13dc687e9102a27e98ef33730ac4fe37795d5036d18b4d527abd35"},
{file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:f6bdac493b949141b733c5345b6ba8f87a226029cbabc7e9e121a413e49441e0"},
{file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:05186a0f1346ae12553d66df1cfce6f251589fea3ad3da4f3ef4e34b2d58c6a3"},
{file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c2006f5c8d28dee289f7020f721354362fa304acbaaf9745751ac4006650254b"},
{file = "lxml-4.9.3-pp38-pypy38_pp73-macosx_11_0_x86_64.whl", hash = "sha256:5c245b783db29c4e4fbbbfc9c5a78be496c9fea25517f90606aa1f6b2b3d5f7b"},
{file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:4fb960a632a49f2f089d522f70496640fdf1218f1243889da3822e0a9f5f3ba7"},
{file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:50670615eaf97227d5dc60de2dc99fb134a7130d310d783314e7724bf163f75d"},
{file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9719fe17307a9e814580af1f5c6e05ca593b12fb7e44fe62450a5384dbf61b4b"},
{file = "lxml-4.9.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3331bece23c9ee066e0fb3f96c61322b9e0f54d775fccefff4c38ca488de283a"},
{file = "lxml-4.9.3-pp39-pypy39_pp73-macosx_11_0_x86_64.whl", hash = "sha256:ed667f49b11360951e201453fc3967344d0d0263aa415e1619e85ae7fd17b4e0"},
{file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8b77946fd508cbf0fccd8e400a7f71d4ac0e1595812e66025bac475a8e811694"},
{file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e4da8ca0c0c0aea88fd46be8e44bd49716772358d648cce45fe387f7b92374a7"},
{file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fe4bda6bd4340caa6e5cf95e73f8fea5c4bfc55763dd42f1b50a94c1b4a2fbd4"},
{file = "lxml-4.9.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f3df3db1d336b9356dd3112eae5f5c2b8b377f3bc826848567f10bfddfee77e9"},
{file = "lxml-4.9.3.tar.gz", hash = "sha256:48628bd53a426c9eb9bc066a923acaa0878d1e86129fd5359aee99285f4eed9c"},
@ -3534,6 +3545,7 @@ files = [
{file = "Pillow-10.0.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3b08d4cc24f471b2c8ca24ec060abf4bebc6b144cb89cba638c720546b1cf538"},
{file = "Pillow-10.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d737a602fbd82afd892ca746392401b634e278cb65d55c4b7a8f48e9ef8d008d"},
{file = "Pillow-10.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:3a82c40d706d9aa9734289740ce26460a11aeec2d9c79b7af87bb35f0073c12f"},
{file = "Pillow-10.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc2ec7c7b5d66b8ec9ce9f720dbb5fa4bace0f545acd34870eff4a369b44bf37"},
{file = "Pillow-10.0.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:d80cf684b541685fccdd84c485b31ce73fc5c9b5d7523bf1394ce134a60c6883"},
{file = "Pillow-10.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76de421f9c326da8f43d690110f0e79fe3ad1e54be811545d7d91898b4c8493e"},
{file = "Pillow-10.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81ff539a12457809666fef6624684c008e00ff6bf455b4b89fd00a140eecd640"},
@ -3543,6 +3555,7 @@ files = [
{file = "Pillow-10.0.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d50b6aec14bc737742ca96e85d6d0a5f9bfbded018264b3b70ff9d8c33485551"},
{file = "Pillow-10.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:00e65f5e822decd501e374b0650146063fbb30a7264b4d2744bdd7b913e0cab5"},
{file = "Pillow-10.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:f31f9fdbfecb042d046f9d91270a0ba28368a723302786c0009ee9b9f1f60199"},
{file = "Pillow-10.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:1ce91b6ec08d866b14413d3f0bbdea7e24dfdc8e59f562bb77bc3fe60b6144ca"},
{file = "Pillow-10.0.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:349930d6e9c685c089284b013478d6f76e3a534e36ddfa912cde493f235372f3"},
{file = "Pillow-10.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3a684105f7c32488f7153905a4e3015a3b6c7182e106fe3c37fbb5ef3e6994c3"},
{file = "Pillow-10.0.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4f69b3700201b80bb82c3a97d5e9254084f6dd5fb5b16fc1a7b974260f89f43"},
@ -5891,4 +5904,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "d06b2a1ccbc8c661df8bc425ccab27516a6dfea1b550976d1338292b47ce8f83"
content-hash = "7ba29eabdec9961c09b653bd8b10f0f2dfd8cab294233495ea61c1f6d4462b08"

View File

@ -93,6 +93,7 @@ tablib = "^3.4.0"
django-location-field = "^2.7.0"
pydantic = "^2.0.2"
channels-redis = "^4.1.0"
django-ipware = "^5.0.0"
[build-system]