Merge pull request #829 from ae-utbm/taiste

Family tree and blazingly fast SAS
This commit is contained in:
thomas girod 2024-09-18 16:06:01 +02:00 committed by GitHub
commit ec434bec56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 2689 additions and 1201 deletions

View File

@ -6,13 +6,13 @@ runs:
- name: Install apt packages
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: gettext libgraphviz-dev
packages: gettext
version: 1.0 # increment to reset cache
- name: Install dependencies
run: |
sudo apt update
sudo apt install gettext libgraphviz-dev
sudo apt install gettext
shell: bash
- name: Set up python

View File

@ -43,6 +43,18 @@ class CurrencyField(models.DecimalField):
return None
if settings.TESTING:
from model_bakery import baker
baker.generators.add(
CurrencyField,
lambda: baker.random_gen.gen_decimal(max_digits=8, decimal_places=2),
)
else: # pragma: no cover
# baker is only used in tests, so we don't need coverage for this part
pass
# Accounting classes

View File

@ -1,10 +1,24 @@
from typing import Annotated
import annotated_types
from django.conf import settings
from django.http import HttpResponse
from ninja_extra import ControllerBase, api_controller, route
from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Mailing
from core.schemas import MarkdownSchema
from core.api_permissions import CanView, IsLoggedInCounter, IsOldSubscriber, IsRoot
from core.models import User
from core.schemas import (
FamilyGodfatherSchema,
MarkdownSchema,
UserFamilySchema,
UserFilterSchema,
UserProfileSchema,
)
from core.templatetags.renderer import markdown
@ -27,3 +41,57 @@ class MailingListController(ControllerBase):
).prefetch_related("subscriptions")
data = "\n".join(m.fetch_format() for m in mailings)
return data
@api_controller("/user", permissions=[IsOldSubscriber | IsRoot | IsLoggedInCounter])
class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema])
def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks)
@route.get("/search", response=PaginatedResponseSchema[UserProfileSchema])
@paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]):
return filters.filter(User.objects.all())
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
DEFAULT_DEPTH = 4
@api_controller("/family")
class FamilyController(ControllerBase):
@route.get(
"/{user_id}",
permissions=[CanView],
response=UserFamilySchema,
url_name="family_graph",
)
def get_family_graph(
self,
user_id: int,
godfathers_depth: DepthValue = DEFAULT_DEPTH,
godchildren_depth: DepthValue = DEFAULT_DEPTH,
):
user: User = self.get_object_or_exception(User, pk=user_id)
relations = user.get_family(godfathers_depth, godchildren_depth)
if not relations:
# If the user has no relations, return only the user
# He is alone in its family, but the family exists nonetheless
return {"users": [user], "relationships": []}
user_ids = {r.from_user_id for r in relations} | {
r.to_user_id for r in relations
}
return {
"users": User.objects.filter(id__in=user_ids).distinct(),
"relationships": (
[
FamilyGodfatherSchema(
godchild=r.from_user_id, godfather=r.to_user_id
)
for r in relations
]
),
}

View File

@ -42,6 +42,8 @@ from django.http import HttpRequest
from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission
from counter.models import Counter
class IsInGroup(BasePermission):
"""Check that the user is in the group whose primary key is given."""
@ -78,7 +80,7 @@ class CanView(BasePermission):
"""Check that this user has the permission to view the object of this route.
Wrap the `user.can_view(obj)` method.
To see an example, look at the exemple in the module docstring.
To see an example, look at the example in the module docstring.
"""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
@ -94,7 +96,7 @@ class CanEdit(BasePermission):
"""Check that this user has the permission to edit the object of this route.
Wrap the `user.can_edit(obj)` method.
To see an example, look at the exemple in the module docstring.
To see an example, look at the example in the module docstring.
"""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
@ -110,7 +112,7 @@ class IsOwner(BasePermission):
"""Check that this user owns the object of this route.
Wrap the `user.is_owner(obj)` method.
To see an example, look at the exemple in the module docstring.
To see an example, look at the example in the module docstring.
"""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
@ -120,3 +122,15 @@ class IsOwner(BasePermission):
self, request: HttpRequest, controller: ControllerBase, obj: Any
) -> bool:
return request.user.is_owner(obj)
class IsLoggedInCounter(BasePermission):
"""Check that a user is logged in a counter."""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
if "/counter/" not in request.META["HTTP_REFERER"]:
return False
token = request.session.get("counter_token")
if not token:
return False
return Counter.objects.filter(token=token).exists()

125
core/fields.py Normal file
View File

@ -0,0 +1,125 @@
from pathlib import Path
from django.core.exceptions import FieldError
from django.db import models
from django.db.models.fields.files import ImageFieldFile
from PIL import Image
from core.utils import resize_image_explicit
class ResizedImageFieldFile(ImageFieldFile):
def get_resized_dimensions(self, image: Image.Image) -> tuple[int, int]:
"""Get the dimensions of the resized image.
If the width and height are given, they are used.
If only one is given, the other is calculated to keep the same ratio.
Returns:
Tuple of width and height
"""
width = self.field.width
height = self.field.height
if width is not None and height is not None:
return self.field.width, self.field.height
if width is None:
width = int(image.width * height / image.height)
elif height is None:
height = int(image.height * width / image.width)
return width, height
def get_name(self) -> str:
"""Get the name of the resized image.
If the field has a force_format attribute,
the extension of the file will be changed to match it.
Otherwise, the name is left unchanged.
Raises:
ValueError: If the image format is unknown
"""
if not self.field.force_format:
return self.name
formats = {val: key for key, val in Image.registered_extensions().items()}
new_format = self.field.force_format
if new_format in formats:
extension = formats[new_format]
else:
raise ValueError(f"Unknown format {new_format}")
return str(Path(self.file.name).with_suffix(extension))
def save(self, name, content, save=True): # noqa FBT002
content.file.seek(0)
img = Image.open(content.file)
width, height = self.get_resized_dimensions(img)
img_format = self.field.force_format or img.format
new_content = resize_image_explicit(img, (width, height), img_format)
name = self.get_name()
return super().save(name, new_content, save)
class ResizedImageField(models.ImageField):
"""A field that automatically resizes images to a given size.
This field is useful for profile pictures or product icons, for example.
The final size of the image is determined by the width and height parameters :
- If both are given, the image will be resized
to fit in a rectangle of width x height
- If only one is given, the other will be calculated to keep the same ratio
If the force_format parameter is given, the image will be converted to this format.
Examples:
To resize an image with a height of 100px, without changing the ratio,
and a format of WEBP :
```python
class Product(models.Model):
icon = ResizedImageField(height=100, force_format="WEBP")
```
To explicitly resize an image to 100x100px (but possibly change the ratio) :
```python
class Product(models.Model):
icon = ResizedImageField(width=100, height=100)
```
Raises:
FieldError: If neither width nor height is given
Args:
width: If given, the width of the resized image
height: If given, the height of the resized image
force_format: If given, the image will be converted to this format
"""
attr_class = ResizedImageFieldFile
def __init__(
self,
width: int | None = None,
height: int | None = None,
force_format: str | None = None,
**kwargs,
):
if width is None and height is None:
raise FieldError(
f"{self.__class__.__name__} requires "
"width, height or both, but got neither"
)
self.width = width
self.height = height
self.force_format = force_format
super().__init__(**kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if self.width is not None:
kwargs["width"] = self.width
if self.height is not None:
kwargs["height"] = self.height
kwargs["force_format"] = self.force_format
return name, path, args, kwargs

View File

@ -49,8 +49,7 @@ class CustomerLookup(RightManagedLookupChannel):
model = Customer
def get_query(self, q, request):
users = search_user(q)
return [user.customer for user in users]
return list(Customer.objects.filter(user__in=search_user(q)))
def format_match(self, obj):
return obj.user.get_mini_item()

View File

@ -288,7 +288,7 @@ class Command(BaseCommand):
since=Subquery(
Subscription.objects.filter(member__customer=OuterRef("pk"))
.annotate(res=Min("subscription_start"))
.values("res")
.values("res")[:1]
)
)
)

View File

@ -57,6 +57,7 @@ from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from pydantic.v1 import NonNegativeInt
if TYPE_CHECKING:
from club.models import Club
@ -606,6 +607,41 @@ class User(AbstractBaseUser):
today.year - born.year - ((today.month, today.day) < (born.month, born.day))
)
def get_family(
self,
godfathers_depth: NonNegativeInt = 4,
godchildren_depth: NonNegativeInt = 4,
) -> set[User.godfathers.through]:
"""Get the family of the user, with the given depth.
Args:
godfathers_depth: The number of generations of godfathers to fetch
godchildren_depth: The number of generations of godchildren to fetch
Returns:
A list of family relationships in this user's family
"""
res = []
for depth, key, reverse_key in [
(godfathers_depth, "from_user_id", "to_user_id"),
(godchildren_depth, "to_user_id", "from_user_id"),
]:
if depth == 0:
continue
links = list(User.godfathers.through.objects.filter(**{key: self.id}))
res.extend(links)
for _ in range(1, depth):
ids = [getattr(c, reverse_key) for c in links]
links = list(
User.godfathers.through.objects.filter(
**{f"{key}__in": ids}
).exclude(id__in=[r.id for r in res])
)
if not links:
break
res.extend(links)
return set(res)
def email_user(self, subject, message, from_email=None, **kwargs):
"""Sends an email to this User."""
if from_email is None:
@ -955,8 +991,8 @@ class SithFile(models.Model):
return user.is_board_member
if user.is_com_admin:
return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return True
if self.is_in_sas:
return user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
return user.id == self.owner_id
def can_be_viewed_by(self, user):

View File

@ -1,4 +1,12 @@
from ninja import ModelSchema, Schema
from typing import Annotated
from annotated_types import MinLen
from django.contrib.staticfiles.storage import staticfiles_storage
from django.db.models import Q
from django.utils.text import slugify
from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import AliasChoices, Field
from core.models import User
@ -11,5 +19,75 @@ class SimpleUserSchema(ModelSchema):
fields = ["id", "nick_name", "first_name", "last_name"]
class UserProfileSchema(ModelSchema):
"""The necessary information to show a user profile"""
class Meta:
model = User
fields = ["id", "nick_name", "first_name", "last_name"]
display_name: str
profile_url: str
profile_pict: str
@staticmethod
def resolve_display_name(obj: User) -> str:
return obj.get_display_name()
@staticmethod
def resolve_profile_url(obj: User) -> str:
return obj.get_absolute_url()
@staticmethod
def resolve_profile_pict(obj: User) -> str:
if obj.profile_pict_id is None:
return staticfiles_storage.url("core/img/unknown.jpg")
return obj.profile_pict.get_download_url()
class UserFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)]
exclude: list[int] | None = Field(
None, validation_alias=AliasChoices("exclude", "exclude[]")
)
def filter_search(self, value: str | None) -> Q:
if not value:
return Q()
if len(value) < 3:
# For small queries, full text search isn't necessary
return (
Q(first_name__istartswith=value)
| Q(last_name__istartswith=value)
| Q(nick_name__istartswith=value)
)
return Q(
id__in=list(
SearchQuerySet()
.models(User)
.autocomplete(auto=slugify(value).replace("-", " "))
.order_by("-last_update")
.values_list("pk", flat=True)
)
)
def filter_exclude(self, value: set[int] | None) -> Q:
if not value:
return Q()
return ~Q(id__in=value)
class MarkdownSchema(Schema):
text: str
class FamilyGodfatherSchema(Schema):
godfather: int
godchild: int
class UserFamilySchema(Schema):
"""Represent a graph of a user's family"""
users: list[UserProfileSchema]
relationships: list[FamilyGodfatherSchema]

View File

@ -89,7 +89,7 @@ function update_query_string(key, value, action = History.REPLACE, url = null) {
if (!url){
url = new URL(window.location.href);
}
if (!value) {
if (value === undefined || value === null || value === "") {
// If the value is null, undefined or empty => delete it
url.searchParams.delete(key)
} else if (Array.isArray(value)) {
@ -107,3 +107,35 @@ function update_query_string(key, value, action = History.REPLACE, url = null) {
return url;
}
/**
* Given a paginated endpoint, fetch all the items of this endpoint,
* performing multiple API calls if necessary.
* @param {string} url The paginated endpoint to fetch
* @return {Promise<Object[]>}
*/
async function fetch_paginated(url) {
const max_per_page = 199;
const paginated_url = new URL(url, document.location.origin);
paginated_url.searchParams.set("page_size", max_per_page.toString());
paginated_url.searchParams.set("page", "1");
let first_page = (await ( await fetch(paginated_url)).json());
let results = first_page.results;
const nb_pictures = first_page.count
const nb_pages = Math.ceil(nb_pictures / max_per_page);
if (nb_pages > 1) {
let promises = [];
for (let i = 2; i <= nb_pages; i++) {
paginated_url.searchParams.set("page", i.toString());
promises.push(
fetch(paginated_url).then(res => res.json().then(json => json.results))
);
}
results.push(...await Promise.all(promises))
}
return results;
}

View File

@ -0,0 +1,261 @@
/**
* Builders to use Select2 in our templates.
*
* This comes with two flavours : local data or remote data.
*
* # Local data source
*
* To use local data source, you must define an array
* in your JS code, having the fields `id` and `text`.
*
* ```js
* const data = [
* {id: 1, text: "foo"},
* {id: 2, text: "bar"},
* ];
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* data_source: local_data_source(data)
* }));
* ```
*
* You can also define a callback that return ids to exclude :
*
* ```js
* const data = [
* {id: 1, text: "foo"},
* {id: 2, text: "bar"},
* {id: 3, text: "to exclude"},
* ];
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* data_source: local_data_source(data, {
* excluded: () => data.filter((i) => i.text === "to exclude").map((i) => parseInt(i))
* })
* }));
* ```
*
* # Remote data source
*
* Select2 with remote data sources are similar to those with local
* data, but with some more parameters, like `result_converter`,
* which takes a callback that must return a `Select2Object`.
*
* ```js
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* data_source: remote_data_source("/api/user/search", {
* excluded: () => [1, 2], // exclude users 1 and 2 from the search
* result_converter: (user) => Object({id: user.id, text: user.first_name})
* })
* }));
* ```
*
* # Overrides
*
* Dealing with a select2 may be complex.
* That's why, when defining a select,
* you may add an override parameter,
* in which you can declare any parameter defined in the
* Select2 documentation.
*
* ```js
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* data_source: remote_data_source("/api/user/search", {
* result_converter: (user) => Object({id: user.id, text: user.first_name}),
* overrides: {
* delay: 500
* }
* })
* }));
* ```
*
* # Caveats with exclude
*
* With local data source, select2 evaluates the data only once.
* Thus, modify the exclude after the initialisation is a no-op.
*
* With remote data source, the exclude list will be evaluated
* after each api response.
* It makes it possible to bind the data returned by the callback
* to some reactive data, thus making the exclude list dynamic.
*
* # Images
*
* Sometimes, you would like to display an image besides
* the text on the select items.
* In this case, fill the `picture_getter` option :
*
* ```js
* document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"),
* data_source: remote_data_source("/api/user/search", {
* result_converter: (user) => Object({id: user.id, text: user.first_name})
* })
* picture_getter: (user) => user.profile_pict,
* }));
* ```
*
* # Binding with alpine
*
* You can declare your select2 component in an Alpine data.
*
* ```html
* <body>
* <div x-data="select2_test">
* <select x-ref="search" x-ref="select"></select>
* <p x-text="current_selection.id"></p>
* <p x-text="current_selection.text"></p>
* </div>
* </body>
*
* <script>
* document.addEventListener("alpine:init", () => {
* Alpine.data("select2_test", () => ({
* selector: undefined,
* current_select: {id: "", text: ""},
*
* init() {
* this.selector = sithSelect2({
* element: $(this.$refs.select),
* data_source: local_data_source(
* [{id: 1, text: "foo"}, {id: 2, text: "bar"}]
* ),
* });
* this.selector.on("select2:select", (event) => {
* // select2 => Alpine signals here
* this.current_select = this.selector.select2("data")
* });
* this.$watch("current_selected" (value) => {
* // Alpine => select2 signals here
* });
* },
* }));
* })
* </script>
*/
/**
* @typedef Select2Object
* @property {number} id
* @property {string} text
*/
/**
* @typedef Select2Options
* @property {Element} element
* @property {Object} data_source
* the data source, built with `local_data_source` or `remote_data_source`
* @property {number[]} excluded A list of ids to exclude from search
* @property {undefined | function(Object): string} picture_getter
* A callback to get the picture field from the API response
* @property {Object | undefined} overrides
* Any other select2 parameter to apply on the config
*/
/**
* @param {Select2Options} options
*/
function sithSelect2(options) {
const elem = $(options.element);
return elem.select2({
theme: elem[0].multiple ? "classic" : "default",
minimumInputLength: 2,
templateResult: select_item_builder(options.picture_getter),
...options.data_source,
...(options.overrides || {}),
});
}
/**
* @typedef LocalSourceOptions
* @property {undefined | function(): number[]} excluded
* A callback to the ids to exclude from the search
*/
/**
* Build a data source for a Select2 from a local array
* @param {Select2Object[]} source The array containing the data
* @param {RemoteSourceOptions} options
*/
function local_data_source(source, options) {
if (!!options.excluded) {
const ids = options.excluded();
return { data: source.filter((i) => !ids.includes(i.id)) };
}
return { data: source };
}
/**
* @typedef RemoteSourceOptions
* @property {undefined | function(): number[]} excluded
* A callback to the ids to exclude from the search
* @property {undefined | function(): Select2Object} result_converter
* A converter for a value coming from the remote api
* @property {undefined | Object} overrides
* Any other select2 parameter to apply on the config
*/
/**
* Build a data source for a Select2 from a remote url
* @param {string} source The url of the endpoint
* @param {RemoteSourceOptions} options
*/
function remote_data_source(source, options) {
jQuery.ajaxSettings.traditional = true;
let params = {
url: source,
dataType: "json",
cache: true,
delay: 250,
data: function (params) {
return {
search: params.term,
exclude: [
...(this.val() || []).map((i) => parseInt(i)),
...(options.excluded ? options.excluded() : []),
],
};
},
};
if (!!options.result_converter) {
params["processResults"] = function (data) {
return { results: data.results.map(options.result_converter) };
};
}
if (!!options.overrides) {
Object.assign(params, options.overrides);
}
return { ajax: params };
}
function item_formatter(user) {
if (user.loading) {
return user.text;
}
}
/**
* Build a function to display the results
* @param {null | function(Object):string} picture_getter
* @return {function(string): jQuery|HTMLElement}
*/
function select_item_builder(picture_getter) {
return (item) => {
const picture =
typeof picture_getter === "function" ? picture_getter(item) : null;
const img_html = picture
? `<img
src="${picture_getter(item)}"
alt="${item.text}"
onerror="this.src = '/static/core/img/unknown.jpg'"
/>`
: "";
return $(`<div class="select-item">
${img_html}
<span class="select-item-text">${item.text}</span>
</div>`);
};
}

View File

@ -239,6 +239,7 @@ a:not(.button) {
padding: 9px 13px;
border: none;
text-decoration: none;
border-radius: 5px;
&.btn-blue {
background-color: $deepblue;
@ -614,6 +615,38 @@ a:not(.button) {
}
}
.select2 {
margin: 10px 0!important;
max-width: 100%;
min-width: 100%;
ul {
margin: 0;
}
textarea {
background-color: inherit;
}
.select2-container--default {
color: black;
}
}
.select2-results {
.select-item {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
img {
max-height: 40px;
border-radius: 50%;
}
}
}
#news_details {
display: inline-block;
margin-top: 20px;
@ -1169,13 +1202,6 @@ u,
}
}
/* XXX This seems to be used in the SAS */
#pict {
display: inline-block;
width: 80%;
background: hsl(0, 0%, 20%);
border: solid hsl(0, 0%, 20%) 2px;
}
/*--------------------------------MATMAT-------------------------------*/
.matmat_results {
display: flex;

View File

@ -0,0 +1,267 @@
async function get_graph_data(url, godfathers_depth, godchildren_depth) {
let data = await (
await fetch(
`${url}?godfathers_depth=${godfathers_depth}&godchildren_depth=${godchildren_depth}`,
)
).json();
return [
...data.users.map((user) => {
return { data: user };
}),
...data.relationships.map((rel) => {
return {
data: { source: rel.godfather, target: rel.godchild },
};
}),
];
}
function create_graph(container, data, active_user_id) {
let cy = cytoscape({
boxSelectionEnabled: false,
autounselectify: true,
container: container,
elements: data,
minZoom: 0.5,
style: [
// the stylesheet for the graph
{
selector: "node",
style: {
label: "data(display_name)",
"background-image": "data(profile_pict)",
width: "100%",
height: "100%",
"background-fit": "cover",
"background-repeat": "no-repeat",
shape: "ellipse",
},
},
{
selector: "edge",
style: {
width: 5,
"line-color": "#ccc",
"target-arrow-color": "#ccc",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
},
},
{
selector: ".traversed",
style: {
"border-width": "5px",
"border-style": "solid",
"border-color": "red",
"target-arrow-color": "red",
"line-color": "red",
},
},
{
selector: ".not-traversed",
style: {
"line-opacity": "0.5",
"background-opacity": "0.5",
"background-image-opacity": "0.5",
},
},
],
layout: {
name: "klay",
nodeDimensionsIncludeLabels: true,
fit: true,
klay: {
addUnnecessaryBendpoints: true,
direction: 'DOWN',
nodePlacement: 'INTERACTIVE',
layoutHierarchy: true
}
}
});
let active_user = cy
.getElementById(active_user_id)
.style("shape", "rectangle");
/* Reset graph */
let reset_graph = () => {
cy.elements((element) => {
if (element.hasClass("traversed")) {
element.removeClass("traversed");
}
if (element.hasClass("not-traversed")) {
element.removeClass("not-traversed");
}
});
};
let on_node_tap = (el) => {
reset_graph();
/* Create path on graph if selected isn't the targeted user */
if (el === active_user) {
return;
}
cy.elements((element) => {
element.addClass("not-traversed");
});
cy.elements()
.aStar({
root: el,
goal: active_user,
})
.path.forEach((el) => {
el.removeClass("not-traversed");
el.addClass("traversed");
});
};
cy.on("tap", "node", (tapped) => {
on_node_tap(tapped.target);
});
cy.zoomingEnabled(false);
/* Add context menu */
if (cy.cxtmenu === undefined) {
console.error(
"ctxmenu isn't loaded, context menu won't be available on graphs",
);
return cy;
}
cy.cxtmenu({
selector: "node",
commands: [
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: function (el) {
window.open(el.data().profile_url, "_blank").focus();
},
},
{
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
select: function (el) {
on_node_tap(el);
},
},
{
content: '<i class="fa fa-eraser fa-2x"></i>',
select: function (el) {
reset_graph();
},
},
],
});
return cy;
}
document.addEventListener("alpine:init", () => {
/*
This needs some constants to be set before the document has been loaded
api_url: base url for fetching the tree as a string
active_user: id of the user to fetch the tree from
depth_min: minimum tree depth for godfathers and godchildren as an int
depth_max: maximum tree depth for godfathers and godchildren as an int
*/
const default_depth = 2;
if (
typeof api_url === "undefined" ||
typeof active_user === "undefined" ||
typeof depth_min === "undefined" ||
typeof depth_max === "undefined"
) {
console.error("Some constants are not set before using the family_graph script, please look at the documentation");
return;
}
function get_initial_depth(prop) {
let value = parseInt(initialUrlParams.get(prop));
if (isNaN(value) || value < depth_min || value > depth_max) {
return default_depth;
}
return value;
}
Alpine.data("graph", () => ({
loading: false,
godfathers_depth: get_initial_depth("godfathers_depth"),
godchildren_depth: get_initial_depth("godchildren_depth"),
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === 'true',
graph: undefined,
graph_data: {},
async init() {
let delayed_fetch = Alpine.debounce(async () => {
this.fetch_graph_data();
}, 100);
["godfathers_depth", "godchildren_depth"].forEach((param) => {
this.$watch(param, async (value) => {
if (value < depth_min || value > depth_max) {
return;
}
update_query_string(param, value, History.REPLACE);
delayed_fetch();
});
});
this.$watch("reverse", async (value) => {
update_query_string("reverse", value, History.REPLACE);
this.reverse_graph();
});
this.$watch("graph_data", async () => {
await this.generate_graph();
if (this.reverse) {
await this.reverse_graph();
}
});
this.fetch_graph_data();
},
async screenshot() {
const link = document.createElement("a");
link.href = this.graph.jpg();
link.download = gettext("family_tree.%(extension)s", "jpg");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
async reset() {
this.reverse = false;
this.godfathers_depth = default_depth;
this.godchildren_depth = default_depth;
},
async reverse_graph() {
this.graph.elements((el) => {
el.position(new Object({ x: -el.position().x, y: -el.position().y }));
});
this.graph.center(this.graph.elements());
},
async fetch_graph_data() {
this.graph_data = await get_graph_data(
api_url,
this.godfathers_depth,
this.godchildren_depth,
);
},
async generate_graph() {
this.loading = true;
this.graph = create_graph(
$(this.$refs.graph),
this.graph_data,
active_user,
);
this.loading = false;
},
}));
});

View File

@ -1,3 +1,85 @@
.graph {
width: 100%;
height: 70vh;
display: block;
}
.graph-toolbar {
margin-top: 10px;
margin-bottom: 10px;
display: flex;
flex-direction: row;
justify-content: space-around;
gap: 30px;
.toolbar-column{
display: flex;
flex-direction: column;
gap: 20px;
min-width: 30%;
}
.toolbar-input {
display: flex;
flex-direction: row;
gap: 20px;
align-items: center;
width: 100%;
label {
max-width: 70%;
text-align: left;
margin-bottom: 0;
}
.depth-choice {
white-space: nowrap;
input[type="number"] {
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
}
}
button {
background: none;
& > .fa {
border-radius: 50%;
font-size: 12px;
padding: 5px;
}
&:enabled > .fa {
background-color: #354a5f;
color: white;
}
&:enabled:hover > .fa {
color: white;
background-color: #35405f; // just a bit darker
}
&:disabled > .fa {
background-color: gray;
color: white;
}
}
}
input {
align-self: center;
max-width: 40px;
}
}
@media screen and (max-width: 500px) {
flex-direction: column;
gap: 20px;
.toolbar-column {
min-width: 100%;
}
}
}
.container {
display: flex;
flex-direction: column;
@ -9,6 +91,14 @@
margin: 0;
}
}
#family-tree-link {
display: inline-block;
margin-top: 10px;
text-align: center;
@media (min-width: 450px) {
margin-right: auto;
}
}
.users {
display: flex;
@ -90,7 +180,7 @@
}
}
&:last-of-type {
&.delete {
margin-top: 10px;
display: block;
text-align: center;
@ -98,7 +188,7 @@
@media (max-width: 375px) {
position: absolute;
bottom: 0%;
bottom: 0;
right: 0;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -11,6 +11,7 @@
<link rel="stylesheet" href="{{ scss('core/markdown.scss') }}">
<link rel="stylesheet" href="{{ scss('core/header.scss') }}">
<link rel="stylesheet" href="{{ scss('core/navbar.scss') }}">
<link rel="stylesheet" href="{{ static('vendored/select2/select2.min.css') }}">
{% block jquery_css %}
{# Thile file is quite heavy (around 250kb), so declaring it in a block allows easy removal #}
@ -24,6 +25,9 @@
<script src="{{ static('vendored/jquery/jquery-3.6.2.min.js') }}"></script>
<!-- Put here to always have acces to those functions on django widgets -->
<script src="{{ static('core/js/script.js') }}"></script>
<script defer src="{{ static('vendored/select2/select2.min.js') }}"></script>
<script defer src="{{ static('core/js/sith-select2.js') }}"></script>
{% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %}

View File

@ -112,7 +112,7 @@
{% macro delete_godfather(user, profile, godfather, is_father) %}
{% if user == profile or user.is_root or user.is_board_member %}
<a href="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=godfather.id, is_father=is_father) }}">{% trans %}Delete{% endtrans %}</a>
<a class="delete" href="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=godfather.id, is_father=is_father) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
{% endmacro %}

View File

@ -11,14 +11,20 @@
{% block content %}
<div class="container">
<a href="{{ url('core:user_godfathers_tree_pict', user_id=profile.id) }}?family">
{% trans %}Show family picture{% endtrans %}
{% if godchildren or godfathers %}
<a
href="{{ url('core:user_godfathers_tree', user_id=profile.id) }}"
class="btn btn-blue"
id="family-tree-link"
>
{% trans %}Show family tree{% endtrans %}
</a>
{% endif %}
<h4>{% trans %}Godfathers / Godmothers{% endtrans %}</h4>
{% if profile.godfathers.exists() %}
{% if godfathers %}
<ul class="users">
{% for u in profile.godfathers.all() %}
{% for u in godfathers %}
<li class="users-card">
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
{{ u.get_mini_item() | safe }}
@ -28,17 +34,14 @@
{% endfor %}
</ul>
<a href="{{ url('core:user_godfathers_tree', user_id=profile.id) }}">
{% trans %}Show ancestors tree{% endtrans %}
</a>
{% else %}
<p>{% trans %}No godfathers / godmothers{% endtrans %}
{% endif %}
<h4>{% trans %}Godchildren{% endtrans %}</h4>
{% if profile.godchildren.exists() %}
{% if godchildren %}
<ul class="users">
{% for u in profile.godchildren.all() %}
{% for u in godchildren %}
<li class="users-card">
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
{{ u.get_mini_item()|safe }}
@ -47,10 +50,6 @@
</li>
{% endfor %}
</ul>
<a href="{{ url('core:user_godfathers_tree', user_id=profile.id) }}?descent">
{% trans %}Show descent tree{% endtrans %}
</a>
{% else %}
<p>{% trans %}No godchildren{% endtrans %}
{% endif %}

View File

@ -1,54 +1,105 @@
{% extends "core/base.jinja" %}
{% set depth_min=0 %}
{% set depth_max=10 %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ scss('user/user_godfathers.scss') }}">
{%- endblock -%}
{% block additional_js %}
<script src="{{ static("vendored/cytoscape/cytoscape.min.js") }}" defer></script>
<script src="{{ static("vendored/cytoscape/cytoscape-cxtmenu.min.js") }}" defer></script>
<script src="{{ static("vendored/cytoscape/klay.min.js") }}" defer></script>
<script src="{{ static("vendored/cytoscape/cytoscape-klay.min.js") }}" defer></script>
<script src="{{ static("user/js/family_graph.js") }}" defer></script>
{% endblock %}
{% block title %}
{% if param == "godchildren" %}
{% trans user_name=profile.get_display_name() %}{{ user_name }}'s godchildren{% endtrans %}
{% else %}
{% trans user_name=profile.get_display_name() %}{{ user_name }}'s godfathers{% endtrans %}
{% endif %}
{% trans user_name=profile.get_display_name() %}{{ user_name }}'s family tree{% endtrans %}
{% endblock %}
{% macro display_members_list(user) %}
{% if user.__getattribute__(param).exists() %}
<ul>
{% for u in user.__getattribute__(param).all() %}
<li>
<a href="{{ url("core:user_godfathers", user_id=u.id) }}">
{{ u.get_short_name() }}
</a>
{% if u in members_set %}
{% trans %}Already seen (check above){% endtrans %}
{% else %}
{{ members_set.add(u) or "" }}
{{ display_members_list(u) }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
{% endmacro %}
{% block content %}
<p><a href="{{ url("core:user_godfathers", user_id=profile.id) }}">
{% trans %}Back to family{% endtrans %}</a></p>
{% if profile.__getattribute__(param).exists() %}
{% if param == "godchildren" %}
<p><a href="{{ url("core:user_godfathers_tree_pict", user_id=profile.id) }}?descent">
{% trans %}Show a picture of the tree{% endtrans %}</a></p>
<h4>{% trans u=profile.get_short_name() %}Descent tree of {{ u }}{% endtrans %}</h4>
{% else %}
<p><a href="{{ url("core:user_godfathers_tree_pict", user_id=profile.id) }}?ancestors">
{% trans %}Show a picture of the tree{% endtrans %}</a></p>
<h4>{% trans u=profile.get_short_name() %}Ancestors tree of {{ u }}{% endtrans %}</h4>
{% endif %}
{{ members_set.add(profile) or "" }}
{{ display_members_list(profile) }}
{% else %}
{% if param == "godchildren" %}
<p>{% trans %}No godchildren{% endtrans %}
{% else %}
<p>{% trans %}No godfathers / godmothers{% endtrans %}
{% endif %}
{% endif %}
<div x-data="graph" :aria-busy="loading">
<div class="graph-toolbar">
<div class="toolbar-column">
<div class="toolbar-input">
<label for="godfather-depth-input">
{% trans min=depth_min, max=depth_max %}Max godfather depth between {{ min }} and {{ max }}{% endtrans %}
</label>
<span class="depth-choice">
<button
@click="godfathers_depth--"
:disabled="godfathers_depth <= {{ depth_min }}"
><i class="fa fa-minus fa-xs"></i></button>
<input
x-model="godfathers_depth"
x-ref="godfather_depth_input"
type="number"
name="godfathers_depth"
id="godfather-depth-input"
min="{{ depth_min }}"
max="{{ depth_max }}"
/>
<button
@click="godfathers_depth++"
:disabled="godfathers_depth >= {{ depth_max }}"
><i class="fa fa-plus"
></i></button>
</span>
</div>
<div class="toolbar-input">
<label for="godchild-depth-input">
{% trans min=depth_min, max=depth_max %}Max godchildren depth between {{ min }} and {{ max }}{% endtrans %}
</label>
<span class="depth-choice">
<button
@click="godchildren_depth--"
:disabled="godchildren_depth <= {{ depth_min }}"
><i
class="fa fa-minus fa-xs"
></i></button>
<input
x-model="godchildren_depth"
type="number"
name="godchildren_depth"
id="godchild-depth-input"
min="{{ depth_min }}"
max="{{ depth_max }}"
/>
<button
@click="godchildren_depth++"
:disabled="godchildren_depth >= {{ depth_max }}"
><i class="fa fa-plus"
></i></button>
</span>
</div>
</div>
<div class="toolbar-column">
<div class="toolbar-input">
<label for="reverse-checkbox">{% trans %}Reverse{% endtrans %}</label>
<input x-model="reverse" type="checkbox" name="reverse" id="reverse-checkbox">
</div>
<button class="btn btn-grey" @click="reset">
{% trans %}Reset{% endtrans %}
</button>
<button class="btn btn-grey" @click="screenshot">
<i class="fa fa-camera"></i>
{% trans %}Save{% endtrans %}
</button>
</div>
</div>
<div x-ref="graph" class="graph"></div>
</div>
<script>
const api_url = "{{ api_url }}";
const active_user = "{{ object.id }}"
const depth_min = {{ depth_min }};
const depth_max = {{ depth_max }};
</script>
{% endblock %}

View File

@ -65,17 +65,29 @@
{{ super() }}
<script>
/**
* @typedef UserProfile
* @property {number} id
* @property {string} first_name
* @property {string} last_name
* @property {string} nick_name
* @property {string} display_name
* @property {string} profile_url
* @property {string} profile_pict
*/
/**
* @typedef Picture
* @property {number} id
* @property {string} name
* @property {number} size
* @property {string} date
* @property {Object} author
* @property {UserProfile} owner
* @property {string} full_size_url
* @property {string} compressed_url
* @property {string} thumb_url
* @property {string} album
* @property {boolean} is_moderated
* @property {boolean} asked_for_removal
*/
document.addEventListener("alpine:init", () => {
@ -86,7 +98,7 @@
albums: {},
async init() {
this.pictures = await this.get_pictures();
this.pictures = await fetch_paginated("{{ url("api:pictures") }}" + "?users_identified={{ object.id }}");
this.albums = this.pictures.reduce((acc, picture) => {
if (!acc[picture.album]){
acc[picture.album] = [];
@ -97,34 +109,6 @@
this.loading = false;
},
/**
* @return {Promise<Picture[]>}
*/
async get_pictures() {
{# The API forbids to get more than 199 items at once
from paginated routes.
In order to download all the user pictures, it may be needed
to performs multiple requests #}
const max_per_page = 199;
const url = "{{ url("api:pictures") }}"
+ "?users_identified={{ object.id }}"
+ `&page_size=${max_per_page}`;
let first_page = (await ( await fetch(url)).json());
let promises = [first_page.results];
const nb_pictures = first_page.count
const nb_pages = Math.ceil(nb_pictures / max_per_page);
for (let i = 2; i <= nb_pages; i++) {
promises.push(
fetch(url + `&page=${i}`).then(res => res.json().then(json => json.results))
);
}
return (await Promise.all(promises)).flat()
},
async download_zip(){
this.is_downloading = true;
const bar = this.$refs.progress;

0
core/tests/__init__.py Normal file
View File

187
core/tests/test_family.py Normal file
View File

@ -0,0 +1,187 @@
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from core.baker_recipes import subscriber_user
from core.models import User
class TestFetchFamilyApi(TestCase):
@classmethod
def setUpTestData(cls):
# Relations (A -> B means A is the godchild of B):
# main_user -> user0 -> user3
# -> user1 -> user6 -> user7 -> user8 -> user9
# -> user2 -> user10
#
# main_user <- user3 <- user11
# <- user12
# <- user4 <- user13
# <- user14
# <- user15 <- user16
# <- user5
cls.main_user = baker.make(User)
cls.users = baker.make(User, _quantity=17)
cls.main_user.godfathers.add(*cls.users[0:3])
cls.main_user.godchildren.add(*cls.users[3:6])
cls.users[1].godfathers.add(cls.users[6])
cls.users[6].godfathers.add(cls.users[7])
cls.users[7].godfathers.add(cls.users[8])
cls.users[8].godfathers.add(cls.users[9])
cls.users[2].godfathers.add(cls.users[10])
cls.users[3].godchildren.add(cls.users[11], cls.users[12])
cls.users[4].godchildren.add(*cls.users[13:16])
cls.users[15].godchildren.add(cls.users[16])
cls.root_user = baker.make(User, is_superuser=True)
cls.subscriber_user = subscriber_user.make()
def setUp(self):
self.maxDiff = None
def test_fetch_family_forbidden(self):
# Anonymous user
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
)
assert response.status_code == 403
self.client.force_login(baker.make(User)) # unsubscribed user
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
)
assert response.status_code == 403
def test_fetch_family_hidden_user(self):
self.main_user.is_subscriber_viewable = False
self.main_user.save()
for user_to_login, error_code in [
(self.main_user, 200),
(self.subscriber_user, 403),
(self.root_user, 200),
]:
self.client.force_login(user_to_login)
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
)
assert response.status_code == error_code
def test_fetch_family_with_zero_depth(self):
"""Fetch the family with a depth of 0."""
self.client.force_login(self.main_user)
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
+ f"?godfathers_depth=0&godchildren_depth=0"
)
assert response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [self.main_user.id]
assert response.json()["relationships"] == []
def test_fetch_empty_family(self):
empty_user = baker.make(User)
self.client.force_login(empty_user)
response = self.client.get(reverse("api:family_graph", args=[empty_user.id]))
assert response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [empty_user.id]
assert response.json()["relationships"] == []
def test_fetch_whole_family(self):
self.client.force_login(self.main_user)
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
+ f"?godfathers_depth=10&godchildren_depth=10"
)
assert response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [
self.main_user.id,
*[u.id for u in self.users],
]
self.assertCountEqual(
response.json()["relationships"],
[
{"godfather": self.users[0].id, "godchild": self.main_user.id},
{"godfather": self.users[1].id, "godchild": self.main_user.id},
{"godfather": self.users[2].id, "godchild": self.main_user.id},
{"godfather": self.main_user.id, "godchild": self.users[3].id},
{"godfather": self.main_user.id, "godchild": self.users[4].id},
{"godfather": self.main_user.id, "godchild": self.users[5].id},
{"godfather": self.users[6].id, "godchild": self.users[1].id},
{"godfather": self.users[7].id, "godchild": self.users[6].id},
{"godfather": self.users[8].id, "godchild": self.users[7].id},
{"godfather": self.users[9].id, "godchild": self.users[8].id},
{"godfather": self.users[10].id, "godchild": self.users[2].id},
{"godfather": self.users[3].id, "godchild": self.users[11].id},
{"godfather": self.users[3].id, "godchild": self.users[12].id},
{"godfather": self.users[4].id, "godchild": self.users[13].id},
{"godfather": self.users[4].id, "godchild": self.users[14].id},
{"godfather": self.users[4].id, "godchild": self.users[15].id},
{"godfather": self.users[15].id, "godchild": self.users[16].id},
],
)
def test_fetch_family_first_level(self):
"""Fetch only the first level of the family."""
self.client.force_login(self.main_user)
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
+ f"?godfathers_depth=1&godchildren_depth=1"
)
assert response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [
self.main_user.id,
*[u.id for u in self.users[:6]],
]
self.assertCountEqual(
response.json()["relationships"],
[
{"godfather": self.users[0].id, "godchild": self.main_user.id},
{"godfather": self.users[1].id, "godchild": self.main_user.id},
{"godfather": self.users[2].id, "godchild": self.main_user.id},
{"godfather": self.main_user.id, "godchild": self.users[3].id},
{"godfather": self.main_user.id, "godchild": self.users[4].id},
{"godfather": self.main_user.id, "godchild": self.users[5].id},
],
)
def test_fetch_family_only_godfathers(self):
"""Fetch only the godfathers."""
self.client.force_login(self.main_user)
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
+ f"?godfathers_depth=10&godchildren_depth=0"
)
assert response.status_code == 200
assert [u["id"] for u in response.json()["users"]] == [
self.main_user.id,
*[u.id for u in self.users[:3]],
*[u.id for u in self.users[6:11]],
]
self.assertCountEqual(
response.json()["relationships"],
[
{"godfather": self.users[0].id, "godchild": self.main_user.id},
{"godfather": self.users[1].id, "godchild": self.main_user.id},
{"godfather": self.users[2].id, "godchild": self.main_user.id},
{"godfather": self.users[6].id, "godchild": self.users[1].id},
{"godfather": self.users[7].id, "godchild": self.users[6].id},
{"godfather": self.users[8].id, "godchild": self.users[7].id},
{"godfather": self.users[9].id, "godchild": self.users[8].id},
{"godfather": self.users[10].id, "godchild": self.users[2].id},
],
)
def test_nb_queries(self):
# The number of queries should be 1 per level of existing depth.
with self.assertNumQueries(0):
self.main_user.get_family(godfathers_depth=0, godchildren_depth=0)
with self.assertNumQueries(3):
self.main_user.get_family(godfathers_depth=3, godchildren_depth=0)
with self.assertNumQueries(3):
self.main_user.get_family(godfathers_depth=0, godchildren_depth=3)
with self.assertNumQueries(6):
self.main_user.get_family(godfathers_depth=3, godchildren_depth=3)
with self.assertNumQueries(4):
# If a level is empty, the next ones should not be queried.
self.main_user.get_family(godfathers_depth=0, godchildren_depth=10)

View File

@ -112,11 +112,6 @@ urlpatterns = [
UserGodfathersTreeView.as_view(),
name="user_godfathers_tree",
),
path(
"user/<int:user_id>/godfathers/tree/pict/",
UserGodfathersTreePictureView.as_view(),
name="user_godfathers_tree_pict",
),
path(
"user/<int:user_id>/godfathers/<int:godfather_id>/<bool:is_father>/delete/",
delete_user_godfather,

View File

@ -25,7 +25,7 @@ from django.core.files.base import ContentFile
from django.http import HttpRequest
from django.utils import timezone
from PIL import ExifTags
from PIL.Image import Resampling
from PIL.Image import Image, Resampling
def get_start_of_semester(today: Optional[date] = None) -> date:
@ -82,18 +82,20 @@ def get_semester_code(d: Optional[date] = None) -> str:
return "P" + str(start.year)[-2:]
def scale_dimension(width, height, long_edge):
ratio = long_edge / max(width, height)
return int(width * ratio), int(height * ratio)
def resize_image(im, edge, img_format):
def resize_image(im: Image, edge: int, img_format: str):
"""Resize an image to fit the given edge length and format."""
(w, h) = im.size
(width, height) = scale_dimension(w, h, long_edge=edge)
ratio = edge / max(w, h)
(width, height) = int(w * ratio), int(h * ratio)
return resize_image_explicit(im, (width, height), img_format)
def resize_image_explicit(im: Image, size: tuple[int, int], img_format: str):
"""Resize an image to the given size and format."""
img_format = img_format.upper()
content = BytesIO()
# use the lanczos filter for antialiasing and discard the alpha channel
im = im.resize((width, height), Resampling.LANCZOS)
im = im.resize((size[0], size[1]), Resampling.LANCZOS)
if img_format == "JPEG":
# converting an image with an alpha channel to jpeg would cause a crash
im = im.convert("RGB")

View File

@ -335,9 +335,40 @@ class UserGodfathersForm(forms.Form):
label=_("Add"),
)
user = AutoCompleteSelectField(
"users", required=True, label=_("Select user"), help_text=None
"users", required=True, label=_("Select user"), help_text=""
)
def __init__(self, *args, user: User, **kwargs):
super().__init__(*args, **kwargs)
self.target_user = user
def clean_user(self):
other_user = self.cleaned_data.get("user")
if not other_user:
raise ValidationError(_("This user does not exist"))
if other_user == self.target_user:
raise ValidationError(_("You cannot be related to yourself"))
return other_user
def clean(self):
super().clean()
if not self.is_valid():
return self.cleaned_data
other_user = self.cleaned_data["user"]
if self.cleaned_data["type"] == "godfather":
if self.target_user.godfathers.contains(other_user):
self.add_error(
"user",
_("%s is already your godfather") % (other_user.get_short_name()),
)
else:
if self.target_user.godchildren.contains(other_user):
self.add_error(
"user",
_("%s is already your godchild") % (other_user.get_short_name()),
)
return self.cleaned_data
class PagePropForm(forms.ModelForm):
error_css_class = "error"

View File

@ -34,7 +34,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory
from django.http import Http404, HttpResponse
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
from django.template.response import TemplateResponse
@ -323,7 +323,7 @@ def delete_user_godfather(request, user_id, godfather_id, is_father):
return redirect("core:user_godfathers", user_id=user_id)
class UserGodfathersView(UserTabsMixin, CanViewMixin, DetailView):
class UserGodfathersView(UserTabsMixin, CanViewMixin, DetailView, FormView):
"""Display a user's godfathers."""
model = User
@ -331,27 +331,23 @@ class UserGodfathersView(UserTabsMixin, CanViewMixin, DetailView):
context_object_name = "profile"
template_name = "core/user_godfathers.jinja"
current_tab = "godfathers"
form_class = UserGodfathersForm
def post(self, request, *args, **kwargs):
self.object = self.get_object()
self.form = UserGodfathersForm(request.POST)
if self.form.is_valid() and self.form.cleaned_data["user"] != self.object:
if self.form.cleaned_data["type"] == "godfather":
self.object.godfathers.add(self.form.cleaned_data["user"])
self.object.save()
def get_form_kwargs(self):
return super().get_form_kwargs() | {"user": self.object}
def form_valid(self, form):
if form.cleaned_data["type"] == "godfather":
self.object.godfathers.add(form.cleaned_data["user"])
else:
self.object.godchildren.add(self.form.cleaned_data["user"])
self.object.save()
self.form = UserGodfathersForm()
return super().get(request, *args, **kwargs)
self.object.godchildren.add(form.cleaned_data["user"])
return redirect("core:user_godfathers", user_id=self.object.id)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
try:
kwargs["form"] = self.form
except:
kwargs["form"] = UserGodfathersForm()
return kwargs
return super().get_context_data(**kwargs) | {
"godfathers": list(self.object.godfathers.select_related("profile_pict")),
"godchildren": list(self.object.godchildren.select_related("profile_pict")),
}
class UserGodfathersTreeView(UserTabsMixin, CanViewMixin, DetailView):
@ -365,86 +361,12 @@ class UserGodfathersTreeView(UserTabsMixin, CanViewMixin, DetailView):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
if "descent" in self.request.GET:
kwargs["param"] = "godchildren"
else:
kwargs["param"] = "godfathers"
kwargs["members_set"] = set()
kwargs["api_url"] = reverse(
"api:family_graph", kwargs={"user_id": self.object.id}
)
return kwargs
class UserGodfathersTreePictureView(CanViewMixin, DetailView):
"""Display a user's tree as a picture."""
model = User
pk_url_kwarg = "user_id"
def build_complex_graph(self):
import pygraphviz as pgv
self.depth = int(self.request.GET.get("depth", 4))
if self.param == "godfathers":
self.graph = pgv.AGraph(strict=False, directed=True, rankdir="BT")
else:
self.graph = pgv.AGraph(strict=False, directed=True)
family = set()
self.level = 1
# Since the tree isn't very deep, we can build it recursively
def crawl_family(user):
if self.level > self.depth:
return
self.level += 1
for u in user.__getattribute__(self.param).all():
self.graph.add_edge(user.get_short_name(), u.get_short_name())
if u not in family:
family.add(u)
crawl_family(u)
self.level -= 1
self.graph.add_node(self.object.get_short_name())
family.add(self.object)
crawl_family(self.object)
def build_family_graph(self):
import pygraphviz as pgv
self.graph = pgv.AGraph(strict=False, directed=True)
self.graph.add_node(self.object.get_short_name())
for u in self.object.godfathers.all():
self.graph.add_edge(u.get_short_name(), self.object.get_short_name())
for u in self.object.godchildren.all():
self.graph.add_edge(self.object.get_short_name(), u.get_short_name())
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if "descent" in self.request.GET:
self.param = "godchildren"
elif "ancestors" in self.request.GET:
self.param = "godfathers"
else:
self.param = "family"
if self.param == "family":
self.build_family_graph()
else:
self.build_complex_graph()
# Pimp the graph before display
self.graph.node_attr["color"] = "lightblue"
self.graph.node_attr["style"] = "filled"
main_node = self.graph.get_node(self.object.get_short_name())
main_node.attr["color"] = "sandybrown"
main_node.attr["shape"] = "rect"
if self.param == "godchildren":
self.graph.graph_attr["label"] = _("Godchildren")
elif self.param == "godfathers":
self.graph.graph_attr["label"] = _("Family")
else:
self.graph.graph_attr["label"] = _("Family")
img = self.graph.draw(format="png", prog="dot")
return HttpResponse(img, content_type="image/png")
class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
"""Display a user's stats."""

View File

@ -0,0 +1,37 @@
# Generated by Django 4.2.16 on 2024-09-14 18:02
from django.db import migrations
import core.fields
class Migration(migrations.Migration):
dependencies = [
("counter", "0021_rename_check_cashregistersummaryitem_is_checked"),
]
operations = [
migrations.AlterField(
model_name="product",
name="icon",
field=core.fields.ResizedImageField(
blank=True,
height=70,
force_format="WEBP",
null=True,
upload_to="products",
verbose_name="icon",
),
),
migrations.AlterField(
model_name="producttype",
name="icon",
field=core.fields.ResizedImageField(
blank=True,
force_format="WEBP",
height=70,
null=True,
upload_to="products",
),
),
]

View File

@ -37,6 +37,7 @@ from django_countries.fields import CountryField
from accounting.models import CurrencyField
from club.models import Club
from core.fields import ResizedImageField
from core.models import Group, Notification, User
from core.utils import get_start_of_semester
from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
@ -208,7 +209,9 @@ class ProductType(models.Model):
name = models.CharField(_("name"), max_length=30)
description = models.TextField(_("description"), null=True, blank=True)
comment = models.TextField(_("comment"), null=True, blank=True)
icon = models.ImageField(upload_to="products", null=True, blank=True)
icon = ResizedImageField(
height=70, force_format="WEBP", upload_to="products", null=True, blank=True
)
# priority holds no real backend logic but helps to handle the order in which
# the items are to be shown to the user
@ -250,8 +253,13 @@ class Product(models.Model):
purchase_price = CurrencyField(_("purchase price"))
selling_price = CurrencyField(_("selling price"))
special_selling_price = CurrencyField(_("special selling price"))
icon = models.ImageField(
upload_to="products", null=True, blank=True, verbose_name=_("icon")
icon = ResizedImageField(
height=70,
force_format="WEBP",
upload_to="products",
null=True,
blank=True,
verbose_name=_("icon"),
)
club = models.ForeignKey(
Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE

View File

View File

@ -0,0 +1,33 @@
from io import BytesIO
from uuid import uuid4
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from model_bakery import baker
from PIL import Image
from counter.models import Product, ProductType
@pytest.mark.django_db
@pytest.mark.parametrize("model", [Product, ProductType])
def test_resize_product_icon(model):
"""Test that the product icon is resized when saved."""
# Product and ProductType icons have a height of 70px
# so this image should be resized to 50x70
img = Image.new("RGB", (100, 140))
content = BytesIO()
img.save(content, format="JPEG")
name = str(uuid4())
product = baker.make(
model,
icon=SimpleUploadedFile(
f"{name}.jpg", content.getvalue(), content_type="image/jpeg"
),
)
assert product.icon.width == 50
assert product.icon.height == 70
assert product.icon.name == f"products/{name}.webp"
assert Image.open(product.icon).format == "WEBP"

View File

@ -31,7 +31,8 @@ Il faut d'abord générer un fichier de traductions,
l'éditer et enfin le compiler au format binaire pour qu'il soit lu par le serveur.
```bash
./manage.py makemessages --locale=fr --ignore "env/*" -e py,jinja
./manage.py makemessages --locale=fr -e py,jinja # Pour le backend
./manage.py makemessages --locale=fr -d djangojs # Pour le frontend
```
## Éditer le fichier django.po

View File

@ -0,0 +1,6 @@
::: core.fields
handler: python
options:
members:
- ResizedImageFieldFile
- ResizedImageField

View File

@ -8,7 +8,6 @@ Certaines dépendances sont nécessaires niveau système :
- zlib1g-dev
- python
- gettext
- graphviz
### Installer WSL
@ -71,8 +70,8 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
Puis installez les autres dépendances :
```bash
sudo apt install build-essentials libssl-dev libjpeg-dev zlib1g-dev python-dev \
libffi-dev python-dev-is-python3 libgraphviz-dev pkg-config \
sudo apt install build-essential libssl-dev libjpeg-dev zlib1g-dev python-dev \
libffi-dev python-dev-is-python3 pkg-config \
gettext git pipx
pipx install poetry
@ -85,7 +84,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
sudo pacman -S python
sudo pacman -S gcc git graphviz gettext graphviz pkgconf python-poetry
sudo pacman -S gcc git gettext pkgconf python-poetry
```
=== "macOS"
@ -94,11 +93,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
Il est également nécessaire d'avoir installé xcode
```bash
echo 'export PATH="$(brew --prefix graphviz)/bin:$PATH"' >> ~/.zshrc
echo 'export CFLAGS="-isysroot /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk -I $(brew --prefix graphviz)/include"' >> ~/.zshrc
echo 'export LDFLAGS="-L /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib -L $(brew --prefix graphviz)/lib"' >> ~/.zshrc
brew install git python graphviz pipx
brew install git python pipx
pipx install poetry
# Pour bien configurer gettext

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
# Sith AE french frontend translation file
# Copyright (C) 2024
# This file is distributed under the same license as the Sith package.
# ae@utbm.fr / ae.info@utbm.fr
#
#, fuzzy
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-03 15:22+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: core/static/user/js/family_graph.js:230
msgid "family_tree.%(extension)s"
msgstr "arbre_genealogique.%(extension)s"
#: sas/static/sas/js/picture.js:52
msgid "Couldn't delete picture"
msgstr "Echec de la suppression de la photo"

View File

@ -92,6 +92,7 @@ nav:
- reference/com/views.md
- core:
- reference/core/models.md
- Champs de modèle: reference/core/model_fields.md
- reference/core/views.md
- reference/core/schemas.md
- reference/core/api_permissions.md

513
poetry.lock generated
View File

@ -92,78 +92,78 @@ files = [
[[package]]
name = "cffi"
version = "1.17.0"
version = "1.17.1"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
files = [
{file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"},
{file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"},
{file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"},
{file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"},
{file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"},
{file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"},
{file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"},
{file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"},
{file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"},
{file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"},
{file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"},
{file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"},
{file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"},
{file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"},
{file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"},
{file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"},
{file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"},
{file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"},
{file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"},
{file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"},
{file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"},
{file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"},
{file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"},
{file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"},
{file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"},
{file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"},
{file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"},
{file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"},
{file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"},
{file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"},
{file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"},
{file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"},
{file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"},
{file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"},
{file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"},
{file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"},
{file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"},
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"},
{file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"},
{file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"},
{file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"},
{file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"},
{file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"},
{file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"},
{file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"},
{file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"},
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"},
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"},
{file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"},
{file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"},
{file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"},
{file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"},
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"},
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"},
{file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"},
{file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"},
{file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"},
{file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"},
{file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"},
{file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"},
{file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"},
{file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"},
{file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
{file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
]
[package.dependencies]
@ -412,38 +412,38 @@ toml = ["tomli"]
[[package]]
name = "cryptography"
version = "43.0.0"
version = "43.0.1"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = ">=3.7"
files = [
{file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"},
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"},
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"},
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"},
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"},
{file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"},
{file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"},
{file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"},
{file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"},
{file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"},
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"},
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"},
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"},
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"},
{file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"},
{file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"},
{file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"},
{file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"},
{file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"},
{file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"},
{file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"},
{file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"},
{file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"},
{file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"},
{file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"},
{file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"},
{file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"},
{file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"},
{file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"},
{file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"},
{file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"},
{file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"},
{file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"},
{file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"},
{file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"},
{file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"},
{file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"},
{file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"},
{file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"},
{file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"},
{file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"},
{file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"},
{file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"},
{file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"},
{file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"},
{file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"},
{file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"},
{file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"},
{file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"},
{file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"},
{file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"},
{file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"},
{file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"},
{file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"},
]
[package.dependencies]
@ -456,7 +456,7 @@ nox = ["nox"]
pep8test = ["check-sdist", "click", "mypy", "ruff"]
sdist = ["build"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
test-randomorder = ["pytest-randomly"]
[[package]]
@ -497,13 +497,13 @@ files = [
[[package]]
name = "django"
version = "4.2.15"
version = "4.2.16"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
optional = false
python-versions = ">=3.8"
files = [
{file = "Django-4.2.15-py3-none-any.whl", hash = "sha256:61ee4a130efb8c451ef3467c67ca99fdce400fedd768634efc86a68c18d80d30"},
{file = "Django-4.2.15.tar.gz", hash = "sha256:c77f926b81129493961e19c0e02188f8d07c112a1162df69bfab178ae447f94a"},
{file = "Django-4.2.16-py3-none-any.whl", hash = "sha256:1ddc333a16fc139fd253035a1606bb24261951bbc3a6ca256717fa06cc41a898"},
{file = "Django-4.2.16.tar.gz", hash = "sha256:6f1616c2786c408ce86ab7e10f792b8f15742f7b7b7460243929cb371e7f1dad"},
]
[package.dependencies]
@ -761,19 +761,19 @@ python-dateutil = ">=2.4"
[[package]]
name = "filelock"
version = "3.15.4"
version = "3.16.0"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.8"
files = [
{file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"},
{file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"},
{file = "filelock-3.16.0-py3-none-any.whl", hash = "sha256:f6ed4c963184f4c84dd5557ce8fece759a3724b37b80c6c4f20a2f63a4dc6609"},
{file = "filelock-3.16.0.tar.gz", hash = "sha256:81de9eb8453c769b63369f87f11131a7ab04e367f8d97ad39dc230daa07e3bec"},
]
[package.extras]
docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"]
typing = ["typing-extensions (>=4.8)"]
docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.1.1)", "pytest (>=8.3.2)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.3)"]
typing = ["typing-extensions (>=4.12.2)"]
[[package]]
name = "freezegun"
@ -1450,13 +1450,13 @@ ptyprocess = ">=0.5"
[[package]]
name = "phonenumbers"
version = "8.13.44"
version = "8.13.45"
description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers."
optional = false
python-versions = "*"
files = [
{file = "phonenumbers-8.13.44-py2.py3-none-any.whl", hash = "sha256:52cd02865dab1428ca9e89d442629b61d407c7dc687cfb80a3e8d068a584513c"},
{file = "phonenumbers-8.13.44.tar.gz", hash = "sha256:2175021e84ee4e41b43c890f2d0af51f18c6ca9ad525886d6d6e4ea882e46fac"},
{file = "phonenumbers-8.13.45-py2.py3-none-any.whl", hash = "sha256:bf05ec20fcd13f0d53e43a34ed7bd1c8be26a72b88fce4b8c64fca5b4641987a"},
{file = "phonenumbers-8.13.45.tar.gz", hash = "sha256:53679a95b6060fd5e15467759252c87933d8566d6a5be00995a579eb0e02435b"},
]
[[package]]
@ -1558,19 +1558,19 @@ xmp = ["defusedxml"]
[[package]]
name = "platformdirs"
version = "4.2.2"
version = "4.3.2"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.8"
files = [
{file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
{file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"},
{file = "platformdirs-4.3.2-py3-none-any.whl", hash = "sha256:eb1c8582560b34ed4ba105009a4badf7f6f85768b30126f351328507b2beb617"},
{file = "platformdirs-4.3.2.tar.gz", hash = "sha256:9e5e27a08aa095dd127b9f2e764d74254f482fef22b0970773bfba79d091ab8c"},
]
[package.extras]
docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
type = ["mypy (>=1.8)"]
docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
type = ["mypy (>=1.11.2)"]
[[package]]
name = "pluggy"
@ -1691,18 +1691,18 @@ files = [
[[package]]
name = "pydantic"
version = "2.8.2"
version = "2.9.1"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"},
{file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"},
{file = "pydantic-2.9.1-py3-none-any.whl", hash = "sha256:7aff4db5fdf3cf573d4b3c30926a510a10e19a0774d38fc4967f78beb6deb612"},
{file = "pydantic-2.9.1.tar.gz", hash = "sha256:1363c7d975c7036df0db2b4a61f2e062fbc0aa5ab5f2772e0ffc7191a4f4bce2"},
]
[package.dependencies]
annotated-types = ">=0.4.0"
pydantic-core = "2.20.1"
annotated-types = ">=0.6.0"
pydantic-core = "2.23.3"
typing-extensions = [
{version = ">=4.12.2", markers = "python_version >= \"3.13\""},
{version = ">=4.6.1", markers = "python_version < \"3.13\""},
@ -1710,103 +1710,104 @@ typing-extensions = [
[package.extras]
email = ["email-validator (>=2.0.0)"]
timezone = ["tzdata"]
[[package]]
name = "pydantic-core"
version = "2.20.1"
version = "2.23.3"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"},
{file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"},
{file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"},
{file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"},
{file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"},
{file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"},
{file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"},
{file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"},
{file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"},
{file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"},
{file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"},
{file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"},
{file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"},
{file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"},
{file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"},
{file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"},
{file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"},
{file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"},
{file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"},
{file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"},
{file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"},
{file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"},
{file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"},
{file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"},
{file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"},
{file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"},
{file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"},
{file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"},
{file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"},
{file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"},
{file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"},
{file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"},
{file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"},
{file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"},
{file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"},
{file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"},
{file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"},
{file = "pydantic_core-2.23.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7f10a5d1b9281392f1bf507d16ac720e78285dfd635b05737c3911637601bae6"},
{file = "pydantic_core-2.23.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3c09a7885dd33ee8c65266e5aa7fb7e2f23d49d8043f089989726391dd7350c5"},
{file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6470b5a1ec4d1c2e9afe928c6cb37eb33381cab99292a708b8cb9aa89e62429b"},
{file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9172d2088e27d9a185ea0a6c8cebe227a9139fd90295221d7d495944d2367700"},
{file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86fc6c762ca7ac8fbbdff80d61b2c59fb6b7d144aa46e2d54d9e1b7b0e780e01"},
{file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0cb80fd5c2df4898693aa841425ea1727b1b6d2167448253077d2a49003e0ed"},
{file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03667cec5daf43ac4995cefa8aaf58f99de036204a37b889c24a80927b629cec"},
{file = "pydantic_core-2.23.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:047531242f8e9c2db733599f1c612925de095e93c9cc0e599e96cf536aaf56ba"},
{file = "pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5499798317fff7f25dbef9347f4451b91ac2a4330c6669821c8202fd354c7bee"},
{file = "pydantic_core-2.23.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bbb5e45eab7624440516ee3722a3044b83fff4c0372efe183fd6ba678ff681fe"},
{file = "pydantic_core-2.23.3-cp310-none-win32.whl", hash = "sha256:8b5b3ed73abb147704a6e9f556d8c5cb078f8c095be4588e669d315e0d11893b"},
{file = "pydantic_core-2.23.3-cp310-none-win_amd64.whl", hash = "sha256:2b603cde285322758a0279995b5796d64b63060bfbe214b50a3ca23b5cee3e83"},
{file = "pydantic_core-2.23.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c889fd87e1f1bbeb877c2ee56b63bb297de4636661cc9bbfcf4b34e5e925bc27"},
{file = "pydantic_core-2.23.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea85bda3189fb27503af4c45273735bcde3dd31c1ab17d11f37b04877859ef45"},
{file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7f7f72f721223f33d3dc98a791666ebc6a91fa023ce63733709f4894a7dc611"},
{file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b2b55b0448e9da68f56b696f313949cda1039e8ec7b5d294285335b53104b61"},
{file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c24574c7e92e2c56379706b9a3f07c1e0c7f2f87a41b6ee86653100c4ce343e5"},
{file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2b05e6ccbee333a8f4b8f4d7c244fdb7a979e90977ad9c51ea31261e2085ce0"},
{file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2c409ce1c219c091e47cb03feb3c4ed8c2b8e004efc940da0166aaee8f9d6c8"},
{file = "pydantic_core-2.23.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d965e8b325f443ed3196db890d85dfebbb09f7384486a77461347f4adb1fa7f8"},
{file = "pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f56af3a420fb1ffaf43ece3ea09c2d27c444e7c40dcb7c6e7cf57aae764f2b48"},
{file = "pydantic_core-2.23.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5b01a078dd4f9a52494370af21aa52964e0a96d4862ac64ff7cea06e0f12d2c5"},
{file = "pydantic_core-2.23.3-cp311-none-win32.whl", hash = "sha256:560e32f0df04ac69b3dd818f71339983f6d1f70eb99d4d1f8e9705fb6c34a5c1"},
{file = "pydantic_core-2.23.3-cp311-none-win_amd64.whl", hash = "sha256:c744fa100fdea0d000d8bcddee95213d2de2e95b9c12be083370b2072333a0fa"},
{file = "pydantic_core-2.23.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e0ec50663feedf64d21bad0809f5857bac1ce91deded203efc4a84b31b2e4305"},
{file = "pydantic_core-2.23.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db6e6afcb95edbe6b357786684b71008499836e91f2a4a1e55b840955b341dbb"},
{file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98ccd69edcf49f0875d86942f4418a4e83eb3047f20eb897bffa62a5d419c8fa"},
{file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a678c1ac5c5ec5685af0133262103defb427114e62eafeda12f1357a12140162"},
{file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01491d8b4d8db9f3391d93b0df60701e644ff0894352947f31fff3e52bd5c801"},
{file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fcf31facf2796a2d3b7fe338fe8640aa0166e4e55b4cb108dbfd1058049bf4cb"},
{file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7200fd561fb3be06827340da066df4311d0b6b8eb0c2116a110be5245dceb326"},
{file = "pydantic_core-2.23.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc1636770a809dee2bd44dd74b89cc80eb41172bcad8af75dd0bc182c2666d4c"},
{file = "pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:67a5def279309f2e23014b608c4150b0c2d323bd7bccd27ff07b001c12c2415c"},
{file = "pydantic_core-2.23.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:748bdf985014c6dd3e1e4cc3db90f1c3ecc7246ff5a3cd4ddab20c768b2f1dab"},
{file = "pydantic_core-2.23.3-cp312-none-win32.whl", hash = "sha256:255ec6dcb899c115f1e2a64bc9ebc24cc0e3ab097775755244f77360d1f3c06c"},
{file = "pydantic_core-2.23.3-cp312-none-win_amd64.whl", hash = "sha256:40b8441be16c1e940abebed83cd006ddb9e3737a279e339dbd6d31578b802f7b"},
{file = "pydantic_core-2.23.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6daaf5b1ba1369a22c8b050b643250e3e5efc6a78366d323294aee54953a4d5f"},
{file = "pydantic_core-2.23.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d015e63b985a78a3d4ccffd3bdf22b7c20b3bbd4b8227809b3e8e75bc37f9cb2"},
{file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3fc572d9b5b5cfe13f8e8a6e26271d5d13f80173724b738557a8c7f3a8a3791"},
{file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6bd91345b5163ee7448bee201ed7dd601ca24f43f439109b0212e296eb5b423"},
{file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc379c73fd66606628b866f661e8785088afe2adaba78e6bbe80796baf708a63"},
{file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbdce4b47592f9e296e19ac31667daed8753c8367ebb34b9a9bd89dacaa299c9"},
{file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3cf31edf405a161a0adad83246568647c54404739b614b1ff43dad2b02e6d5"},
{file = "pydantic_core-2.23.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e22b477bf90db71c156f89a55bfe4d25177b81fce4aa09294d9e805eec13855"},
{file = "pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0a0137ddf462575d9bce863c4c95bac3493ba8e22f8c28ca94634b4a1d3e2bb4"},
{file = "pydantic_core-2.23.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:203171e48946c3164fe7691fc349c79241ff8f28306abd4cad5f4f75ed80bc8d"},
{file = "pydantic_core-2.23.3-cp313-none-win32.whl", hash = "sha256:76bdab0de4acb3f119c2a4bff740e0c7dc2e6de7692774620f7452ce11ca76c8"},
{file = "pydantic_core-2.23.3-cp313-none-win_amd64.whl", hash = "sha256:37ba321ac2a46100c578a92e9a6aa33afe9ec99ffa084424291d84e456f490c1"},
{file = "pydantic_core-2.23.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d063c6b9fed7d992bcbebfc9133f4c24b7a7f215d6b102f3e082b1117cddb72c"},
{file = "pydantic_core-2.23.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6cb968da9a0746a0cf521b2b5ef25fc5a0bee9b9a1a8214e0a1cfaea5be7e8a4"},
{file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edbefe079a520c5984e30e1f1f29325054b59534729c25b874a16a5048028d16"},
{file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cbaaf2ef20d282659093913da9d402108203f7cb5955020bd8d1ae5a2325d1c4"},
{file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb539d7e5dc4aac345846f290cf504d2fd3c1be26ac4e8b5e4c2b688069ff4cf"},
{file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e6f33503c5495059148cc486867e1d24ca35df5fc064686e631e314d959ad5b"},
{file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04b07490bc2f6f2717b10c3969e1b830f5720b632f8ae2f3b8b1542394c47a8e"},
{file = "pydantic_core-2.23.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03795b9e8a5d7fda05f3873efc3f59105e2dcff14231680296b87b80bb327295"},
{file = "pydantic_core-2.23.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c483dab0f14b8d3f0df0c6c18d70b21b086f74c87ab03c59250dbf6d3c89baba"},
{file = "pydantic_core-2.23.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b2682038e255e94baf2c473dca914a7460069171ff5cdd4080be18ab8a7fd6e"},
{file = "pydantic_core-2.23.3-cp38-none-win32.whl", hash = "sha256:f4a57db8966b3a1d1a350012839c6a0099f0898c56512dfade8a1fe5fb278710"},
{file = "pydantic_core-2.23.3-cp38-none-win_amd64.whl", hash = "sha256:13dd45ba2561603681a2676ca56006d6dee94493f03d5cadc055d2055615c3ea"},
{file = "pydantic_core-2.23.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:82da2f4703894134a9f000e24965df73cc103e31e8c31906cc1ee89fde72cbd8"},
{file = "pydantic_core-2.23.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dd9be0a42de08f4b58a3cc73a123f124f65c24698b95a54c1543065baca8cf0e"},
{file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89b731f25c80830c76fdb13705c68fef6a2b6dc494402987c7ea9584fe189f5d"},
{file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6de1ec30c4bb94f3a69c9f5f2182baeda5b809f806676675e9ef6b8dc936f28"},
{file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb68b41c3fa64587412b104294b9cbb027509dc2f6958446c502638d481525ef"},
{file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c3980f2843de5184656aab58698011b42763ccba11c4a8c35936c8dd6c7068c"},
{file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94f85614f2cba13f62c3c6481716e4adeae48e1eaa7e8bac379b9d177d93947a"},
{file = "pydantic_core-2.23.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:510b7fb0a86dc8f10a8bb43bd2f97beb63cffad1203071dc434dac26453955cd"},
{file = "pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1eba2f7ce3e30ee2170410e2171867ea73dbd692433b81a93758ab2de6c64835"},
{file = "pydantic_core-2.23.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b259fd8409ab84b4041b7b3f24dcc41e4696f180b775961ca8142b5b21d0e70"},
{file = "pydantic_core-2.23.3-cp39-none-win32.whl", hash = "sha256:40d9bd259538dba2f40963286009bf7caf18b5112b19d2b55b09c14dde6db6a7"},
{file = "pydantic_core-2.23.3-cp39-none-win_amd64.whl", hash = "sha256:5a8cd3074a98ee70173a8633ad3c10e00dcb991ecec57263aacb4095c5efb958"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f399e8657c67313476a121a6944311fab377085ca7f490648c9af97fc732732d"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b5547d098c76e1694ba85f05b595720d7c60d342f24d5aad32c3049131fa5c4"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0dda0290a6f608504882d9f7650975b4651ff91c85673341789a476b1159f211"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b6e5da855e9c55a0c67f4db8a492bf13d8d3316a59999cfbaf98cc6e401961"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:09e926397f392059ce0afdcac920df29d9c833256354d0c55f1584b0b70cf07e"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:87cfa0ed6b8c5bd6ae8b66de941cece179281239d482f363814d2b986b79cedc"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e61328920154b6a44d98cabcb709f10e8b74276bc709c9a513a8c37a18786cc4"},
{file = "pydantic_core-2.23.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce3317d155628301d649fe5e16a99528d5680af4ec7aa70b90b8dacd2d725c9b"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e89513f014c6be0d17b00a9a7c81b1c426f4eb9224b15433f3d98c1a071f8433"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:4f62c1c953d7ee375df5eb2e44ad50ce2f5aff931723b398b8bc6f0ac159791a"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2718443bc671c7ac331de4eef9b673063b10af32a0bb385019ad61dcf2cc8f6c"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d90e08b2727c5d01af1b5ef4121d2f0c99fbee692c762f4d9d0409c9da6541"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b676583fc459c64146debea14ba3af54e540b61762dfc0613dc4e98c3f66eeb"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:50e4661f3337977740fdbfbae084ae5693e505ca2b3130a6d4eb0f2281dc43b8"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:68f4cf373f0de6abfe599a38307f4417c1c867ca381c03df27c873a9069cda25"},
{file = "pydantic_core-2.23.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:59d52cf01854cb26c46958552a21acb10dd78a52aa34c86f284e66b209db8cab"},
{file = "pydantic_core-2.23.3.tar.gz", hash = "sha256:3cb0f65d8b4121c1b015c60104a685feb929a29d7cf204387c7f2688c7974690"},
]
[package.dependencies]
@ -1826,16 +1827,6 @@ files = [
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pygraphviz"
version = "1.13"
description = "Python interface to Graphviz"
optional = false
python-versions = ">=3.10"
files = [
{file = "pygraphviz-1.13.tar.gz", hash = "sha256:6ad8aa2f26768830a5a1cfc8a14f022d13df170a8f6fdfd68fd1aa1267000964"},
]
[[package]]
name = "pymdown-extensions"
version = "10.9"
@ -1894,13 +1885,13 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytest-django"
version = "4.8.0"
version = "4.9.0"
description = "A Django plugin for pytest."
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-django-4.8.0.tar.gz", hash = "sha256:5d054fe011c56f3b10f978f41a8efb2e5adfc7e680ef36fb571ada1f24779d90"},
{file = "pytest_django-4.8.0-py3-none-any.whl", hash = "sha256:ca1ddd1e0e4c227cf9e3e40a6afc6d106b3e70868fd2ac5798a22501271cd0c7"},
{file = "pytest_django-4.9.0-py3-none-any.whl", hash = "sha256:1d83692cb39188682dbb419ff0393867e9904094a549a7d38a3154d5731b2b99"},
{file = "pytest_django-4.9.0.tar.gz", hash = "sha256:8bf7bc358c9ae6f6fc51b6cebb190fe20212196e6807121f11bd6a3b03428314"},
]
[package.dependencies]
@ -2232,13 +2223,13 @@ files = [
[[package]]
name = "sentry-sdk"
version = "2.13.0"
version = "2.14.0"
description = "Python client for Sentry (https://sentry.io)"
optional = false
python-versions = ">=3.6"
files = [
{file = "sentry_sdk-2.13.0-py2.py3-none-any.whl", hash = "sha256:6beede8fc2ab4043da7f69d95534e320944690680dd9a963178a49de71d726c6"},
{file = "sentry_sdk-2.13.0.tar.gz", hash = "sha256:8d4a576f7a98eb2fdb40e13106e41f330e5c79d72a68be1316e7852cf4995260"},
{file = "sentry_sdk-2.14.0-py2.py3-none-any.whl", hash = "sha256:b8bc3dc51d06590df1291b7519b85c75e2ced4f28d9ea655b6d54033503b5bf4"},
{file = "sentry_sdk-2.14.0.tar.gz", hash = "sha256:1e0e2eaf6dad918c7d1e0edac868a7bf20017b177f242cefe2a6bcd47955961d"},
]
[package.dependencies]
@ -2532,13 +2523,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "virtualenv"
version = "20.26.3"
version = "20.26.4"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.7"
files = [
{file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"},
{file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"},
{file = "virtualenv-20.26.4-py3-none-any.whl", hash = "sha256:48f2695d9809277003f30776d155615ffc11328e6a0a8c1f0ec80188d7874a55"},
{file = "virtualenv-20.26.4.tar.gz", hash = "sha256:c17f4e0f3e6036e9f26700446f85c76ab11df65ff6d8a9cbfad9f71aabfcf23c"},
]
[package.dependencies]
@ -2552,41 +2543,41 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
[[package]]
name = "watchdog"
version = "5.0.0"
version = "5.0.2"
description = "Filesystem events monitoring"
optional = false
python-versions = ">=3.9"
files = [
{file = "watchdog-5.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3216ec994eabb2212df9861f19056ca0d4cd3516d56cb95801933876519bfe"},
{file = "watchdog-5.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb59ad83a1700304fc1ac7bc53ae9e5cbe9d60a52ed9bba8e2e2d782a201bb2b"},
{file = "watchdog-5.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1228cb097e855d1798b550be8f0e9f0cfbac4384f9a3e91f66d250d03e11294e"},
{file = "watchdog-5.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3c177085c3d210d1c73cb4569442bdaef706ebebc423bd7aed9e90fc12b2e553"},
{file = "watchdog-5.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:01ab36cddc836a0f202c66267daaef92ba5c17c7d6436deff0587bb61234c5c9"},
{file = "watchdog-5.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0834c21efa3e767849b09e667274604c7cdfe30b49eb95d794565c53f4db3c1e"},
{file = "watchdog-5.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1e26f570dd7f5178656affb24d6f0e22ce66c8daf88d4061a27bfb9ac866b40d"},
{file = "watchdog-5.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d146331e6b206baa9f6dd40f72b5783ad2302c240df68e7fce196d30588ccf7b"},
{file = "watchdog-5.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6c96b1706430839872a3e33b9370ee3f7a0079f6b828129d88498ad1f96a0f45"},
{file = "watchdog-5.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:663b096368ed7831ac42259919fdb9e0a1f0a8994d972675dfbcca0225e74de1"},
{file = "watchdog-5.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:685931412978d00a91a193d9018fc9e394e565e8e7a0c275512a80e59c6e85f8"},
{file = "watchdog-5.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:109daafc5b0f2a98d1fa9475ff9737eb3559d57b18129a36495e20c71de0b44f"},
{file = "watchdog-5.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c2b4d90962639ae7cee371ea3a8da506831945d4418eee090c53bc38e6648dc6"},
{file = "watchdog-5.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e58eafe9cc5ceebe1562cdb89bacdcd0ef470896e8b0139fe677a5abec243da"},
{file = "watchdog-5.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b8d747bf6d8fe5ce89cb1a36c3724d1599bd4cde3f90fcba518e6260c7058a52"},
{file = "watchdog-5.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:bc16d448a74a929b896ed9578c25756b2125400b19b3258be8d9a681c7ae8e71"},
{file = "watchdog-5.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:7e6b0e9b8a9dc3865d65888b5f5222da4ba9c4e09eab13cff5e305e7b7e7248f"},
{file = "watchdog-5.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4fe6780915000743074236b21b6c37419aea71112af62237881bc265589fe463"},
{file = "watchdog-5.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:0710e9502727f688a7e06d48078545c54485b3d6eb53b171810879d8223c362a"},
{file = "watchdog-5.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:d76efab5248aafbf8a2c2a63cd7b9545e6b346ad1397af8b862a3bb3140787d8"},
{file = "watchdog-5.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:ff4e957c45c446de34c513eadce01d0b65da7eee47c01dce472dd136124552c9"},
{file = "watchdog-5.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:16c1aa3377bb1f82c5e24277fcbf4e2cac3c4ce46aaaf7212d53caa9076eb7b7"},
{file = "watchdog-5.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:22fcad6168fc43cf0e709bd854be5b8edbb0b260f0a6f28f1ea9baa53c6907f7"},
{file = "watchdog-5.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:0120b2fa65732797ffa65fa8ee5540c288aa861d91447df298626d6385a24658"},
{file = "watchdog-5.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2aa59fab7ff75281778c649557275ca3085eccbdf825a0e2a5ca3810e977afe5"},
{file = "watchdog-5.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:78db0fe0336958fc0e1269545c980b6f33d04d184ba191b2800a8b71d3e971a9"},
{file = "watchdog-5.0.0-py3-none-win32.whl", hash = "sha256:d1acef802916083f2ad7988efc7decf07e46e266916c0a09d8fb9d387288ea12"},
{file = "watchdog-5.0.0-py3-none-win_amd64.whl", hash = "sha256:3c2d50fdb86aa6df3973313272f5a17eb26eab29ff5a0bf54b6d34597b4dc4e4"},
{file = "watchdog-5.0.0-py3-none-win_ia64.whl", hash = "sha256:1d17ec7e022c34fa7ddc72aa41bf28c9d1207ffb193df18ba4f6fde453725b3c"},
{file = "watchdog-5.0.0.tar.gz", hash = "sha256:990aedb9e2f336b45a70aed9c014450e7c4a70fd99c5f5b1834d57e1453a177e"},
{file = "watchdog-5.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d961f4123bb3c447d9fcdcb67e1530c366f10ab3a0c7d1c0c9943050936d4877"},
{file = "watchdog-5.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72990192cb63872c47d5e5fefe230a401b87fd59d257ee577d61c9e5564c62e5"},
{file = "watchdog-5.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bec703ad90b35a848e05e1b40bf0050da7ca28ead7ac4be724ae5ac2653a1a0"},
{file = "watchdog-5.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae7a1879918f6544201d33666909b040a46421054a50e0f773e0d870ed7438d"},
{file = "watchdog-5.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4a440f725f3b99133de610bfec93d570b13826f89616377715b9cd60424db6e"},
{file = "watchdog-5.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f8b2918c19e0d48f5f20df458c84692e2a054f02d9df25e6c3c930063eca64c1"},
{file = "watchdog-5.0.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:aa9cd6e24126d4afb3752a3e70fce39f92d0e1a58a236ddf6ee823ff7dba28ee"},
{file = "watchdog-5.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f627c5bf5759fdd90195b0c0431f99cff4867d212a67b384442c51136a098ed7"},
{file = "watchdog-5.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7594a6d32cda2b49df3fd9abf9b37c8d2f3eab5df45c24056b4a671ac661619"},
{file = "watchdog-5.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba32efcccfe2c58f4d01115440d1672b4eb26cdd6fc5b5818f1fb41f7c3e1889"},
{file = "watchdog-5.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:963f7c4c91e3f51c998eeff1b3fb24a52a8a34da4f956e470f4b068bb47b78ee"},
{file = "watchdog-5.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8c47150aa12f775e22efff1eee9f0f6beee542a7aa1a985c271b1997d340184f"},
{file = "watchdog-5.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:14dd4ed023d79d1f670aa659f449bcd2733c33a35c8ffd88689d9d243885198b"},
{file = "watchdog-5.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b84bff0391ad4abe25c2740c7aec0e3de316fdf7764007f41e248422a7760a7f"},
{file = "watchdog-5.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e8d5ff39f0a9968952cce548e8e08f849141a4fcc1290b1c17c032ba697b9d7"},
{file = "watchdog-5.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fb223456db6e5f7bd9bbd5cd969f05aae82ae21acc00643b60d81c770abd402b"},
{file = "watchdog-5.0.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9814adb768c23727a27792c77812cf4e2fd9853cd280eafa2bcfa62a99e8bd6e"},
{file = "watchdog-5.0.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:901ee48c23f70193d1a7bc2d9ee297df66081dd5f46f0ca011be4f70dec80dab"},
{file = "watchdog-5.0.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:638bcca3d5b1885c6ec47be67bf712b00a9ab3d4b22ec0881f4889ad870bc7e8"},
{file = "watchdog-5.0.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:5597c051587f8757798216f2485e85eac583c3b343e9aa09127a3a6f82c65ee8"},
{file = "watchdog-5.0.2-py3-none-manylinux2014_armv7l.whl", hash = "sha256:53ed1bf71fcb8475dd0ef4912ab139c294c87b903724b6f4a8bd98e026862e6d"},
{file = "watchdog-5.0.2-py3-none-manylinux2014_i686.whl", hash = "sha256:29e4a2607bd407d9552c502d38b45a05ec26a8e40cc7e94db9bb48f861fa5abc"},
{file = "watchdog-5.0.2-py3-none-manylinux2014_ppc64.whl", hash = "sha256:b6dc8f1d770a8280997e4beae7b9a75a33b268c59e033e72c8a10990097e5fde"},
{file = "watchdog-5.0.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:d2ab34adc9bf1489452965cdb16a924e97d4452fcf88a50b21859068b50b5c3b"},
{file = "watchdog-5.0.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:7d1aa7e4bb0f0c65a1a91ba37c10e19dabf7eaaa282c5787e51371f090748f4b"},
{file = "watchdog-5.0.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:726eef8f8c634ac6584f86c9c53353a010d9f311f6c15a034f3800a7a891d941"},
{file = "watchdog-5.0.2-py3-none-win32.whl", hash = "sha256:bda40c57115684d0216556671875e008279dea2dc00fcd3dde126ac8e0d7a2fb"},
{file = "watchdog-5.0.2-py3-none-win_amd64.whl", hash = "sha256:d010be060c996db725fbce7e3ef14687cdcc76f4ca0e4339a68cc4532c382a73"},
{file = "watchdog-5.0.2-py3-none-win_ia64.whl", hash = "sha256:3960136b2b619510569b90f0cd96408591d6c251a75c97690f4553ca88889769"},
{file = "watchdog-5.0.2.tar.gz", hash = "sha256:dcebf7e475001d2cdeb020be630dc5b687e9acdd60d16fea6bb4508e7b94cf76"},
]
[package.extras]
@ -2635,4 +2626,4 @@ filelock = ">=3.4"
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "0041e2ec8f5a4ff1f0fca78bb0d6c5865d0a12a92b9869940c389fd524569ba2"
content-hash = "b6202203d272cecdb607ea8ebc1ba12dd8369e4f387f65692c4a9681915e6f48"

View File

@ -39,7 +39,6 @@ django-ordered-model = "^3.7"
django-simple-captcha = "^0.6.0"
python-dateutil = "^2.8.2"
sentry-sdk = "^2.12.0"
pygraphviz = "^1.1"
Jinja2 = "^3.1"
django-countries = "^7.5.1"
dict2xml = "^1.7.3"

View File

@ -1,16 +1,18 @@
from django.conf import settings
from django.db.models import F
from django.urls import reverse
from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied
from ninja_extra.exceptions import NotFound, PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt
from core.models import User
from core.api_permissions import CanView, IsOwner
from core.models import Notification, User
from sas.models import PeoplePictureRelation, Picture
from sas.schemas import PictureFilterSchema, PictureSchema
from sas.schemas import IdentifiedUserSchema, PictureFilterSchema, PictureSchema
@api_controller("/sas/picture")
@ -45,9 +47,56 @@ class PicturesController(ControllerBase):
filters.filter(Picture.objects.viewable_by(user))
.distinct()
.order_by("-parent__date", "date")
.select_related("owner")
.annotate(album=F("parent__name"))
)
@route.get(
"/{picture_id}/identified",
permissions=[IsAuthenticated, CanView],
response=list[IdentifiedUserSchema],
)
def fetch_identifications(self, picture_id: int):
"""Fetch the users that have been identified on the given picture."""
picture = self.get_object_or_exception(Picture, pk=picture_id)
return picture.people.select_related("user")
@route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView])
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):
picture = self.get_object_or_exception(Picture, pk=picture_id)
db_users = list(User.objects.filter(id__in=users))
if len(users) != len(db_users):
raise NotFound
already_identified = set(
picture.people.filter(user_id__in=users).values_list("user_id", flat=True)
)
identified = [u for u in db_users if u.pk not in already_identified]
relations = [
PeoplePictureRelation(user=u, picture_id=picture_id) for u in identified
]
PeoplePictureRelation.objects.bulk_create(relations)
for u in identified:
Notification.objects.get_or_create(
user=u,
viewed=False,
type="NEW_PICTURES",
defaults={
"url": reverse("core:user_pictures", kwargs={"user_id": u.id})
},
)
@route.delete("/{picture_id}", permissions=[IsOwner])
def delete_picture(self, picture_id: int):
self.get_object_or_exception(Picture, pk=picture_id).delete()
@route.patch("/{picture_id}/moderate", permissions=[IsOwner])
def moderate_picture(self, picture_id: int):
picture = self.get_object_or_exception(Picture, pk=picture_id)
picture.is_moderated = True
picture.moderator = self.context.request.user
picture.asked_for_removal = False
picture.save()
@api_controller("/sas/relation", tags="User identification on SAS pictures")
class UsersIdentifiedController(ControllerBase):

View File

@ -16,6 +16,7 @@
from __future__ import annotations
from io import BytesIO
from pathlib import Path
from typing import ClassVar, Self
from django.conf import settings
@ -123,7 +124,7 @@ class Picture(SasFile):
self.file.delete()
self.thumbnail.delete()
self.compressed.delete()
new_extension_name = self.name.removesuffix(extension) + "webp"
new_extension_name = str(Path(self.name).with_suffix(".webp"))
self.file = file
self.file.name = self.name
self.thumbnail = thumb

View File

@ -3,7 +3,8 @@ from datetime import datetime
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, NonNegativeInt
from sas.models import PeoplePictureRelation, Picture
from core.schemas import UserProfileSchema
from sas.models import Picture
class PictureFilterSchema(FilterSchema):
@ -16,8 +17,9 @@ class PictureFilterSchema(FilterSchema):
class PictureSchema(ModelSchema):
class Meta:
model = Picture
fields = ["id", "name", "date", "size", "is_moderated"]
fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"]
owner: UserProfileSchema
full_size_url: str
compressed_url: str
thumb_url: str
@ -36,12 +38,11 @@ class PictureSchema(ModelSchema):
return obj.get_download_thumb_url()
class PictureCreateRelationSchema(Schema):
user_id: NonNegativeInt
picture_id: NonNegativeInt
class PictureRelationCreationSchema(Schema):
picture: NonNegativeInt
users: list[NonNegativeInt]
class CreatedPictureRelationSchema(ModelSchema):
class Meta:
model = PeoplePictureRelation
fields = ["id", "user", "picture"]
class IdentifiedUserSchema(Schema):
id: int
user: UserProfileSchema

View File

@ -72,45 +72,31 @@
aspect-ratio: 16/9;
background: #333333;
> a {
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 70%;
}
.overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 40px;
}
> div {
display: flex;
position: relative;
width: 100%;
height: 100%;
> div {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
font-size: 30px;
color: white;
background-repeat: no-repeat;
background-position: center center;
background-size: cover;
&::before {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, .3);
}
}
}
}
> #prev > a > div::before {
content: '';
}
> #next > a > div::before {
content: '';
}
}
> .tags {
@media (min-width: 1001px) {
@ -140,6 +126,11 @@
width: 100%;
justify-content: space-between;
&.loader {
margin-top: 10px;
--loading-size: 20px
}
@media (max-width: 1000px) {
max-width: calc(50% - 5px);
}
@ -299,20 +290,3 @@
}
}
}
.moderation {
box-sizing: border-box;
width: 100%;
border: 2px solid coral;
border-radius: 2px;
padding: 10px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
> div:last-child {
display: flex;
gap: 20px;
}
}

260
sas/static/sas/js/viewer.js Normal file
View File

@ -0,0 +1,260 @@
/**
* @typedef PictureIdentification
* @property {number} id The actual id of the identification
* @property {UserProfile} user The identified user
*/
/**
* A container for a picture with the users identified on it
* able to prefetch its data.
*/
class PictureWithIdentifications {
identifications = null;
image_loading = false;
identifications_loading = false;
/**
* @param {Picture} picture
*/
constructor(picture) {
Object.assign(this, picture);
}
/**
* @param {Picture} picture
*/
static from_picture(picture) {
return new PictureWithIdentifications(picture);
}
/**
* If not already done, fetch the users identified on this picture and
* populate the identifications field
* @param {?Object=} options
* @return {Promise<void>}
*/
async load_identifications(options) {
if (this.identifications_loading) {
return; // The users are already being fetched.
}
if (!!this.identifications && !options?.force_reload) {
// The users are already fetched
// and the user does not want to force the reload
return;
}
this.identifications_loading = true;
const url = `/api/sas/picture/${this.id}/identified`;
this.identifications = await (await fetch(url)).json();
this.identifications_loading = false;
}
/**
* Preload the photo and the identifications
* @return {Promise<void>}
*/
async preload() {
const img = new Image();
img.src = this.compressed_url;
if (!img.complete) {
this.image_loading = true;
img.addEventListener("load", () => {
this.image_loading = false;
});
}
await this.load_identifications();
}
}
document.addEventListener("alpine:init", () => {
Alpine.data("picture_viewer", () => ({
/**
* All the pictures that can be displayed on this picture viewer
* @type PictureWithIdentifications[]
**/
pictures: [],
/**
* The currently displayed picture
* Default dummy data are pre-loaded to avoid javascript error
* when loading the page at the beginning
* @type PictureWithIdentifications
**/
current_picture: {
is_moderated: true,
id: null,
name: "",
display_name: "",
compressed_url: "",
profile_url: "",
full_size_url: "",
owner: "",
date: new Date(),
identifications: [],
},
/**
* The picture which will be displayed next if the user press the "next" button
* @type ?PictureWithIdentifications
**/
next_picture: null,
/**
* The picture which will be displayed next if the user press the "previous" button
* @type ?PictureWithIdentifications
**/
previous_picture: null,
/**
* The select2 component used to identify users
**/
selector: undefined,
/**
* true if the page is in a loading state, else false
**/
/**
* Error message when a moderation operation fails
* @type string
**/
moderation_error: "",
/**
* Method of pushing new url to the browser history
* Used by popstate event and always reset to it's default value when used
* @type History
**/
pushstate: History.PUSH,
async init() {
this.pictures = (await fetch_paginated(picture_endpoint)).map(
PictureWithIdentifications.from_picture,
);
this.selector = sithSelect2({
element: $(this.$refs.search),
data_source: remote_data_source("/api/user/search", {
excluded: () => [
...(this.current_picture.identifications || []).map(
(i) => i.user.id,
),
],
result_converter: (obj) => Object({ ...obj, text: obj.display_name }),
}),
picture_getter: (user) => user.profile_pict,
});
this.current_picture = this.pictures.find(
(i) => i.id === first_picture_id,
);
this.$watch("current_picture", () => this.update_picture());
window.addEventListener("popstate", async (event) => {
if (!event.state || event.state.sas_picture_id === undefined) {
return;
}
this.pushstate = History.REPLACE;
this.current_picture = this.pictures.find(
(i) => i.id === parseInt(event.state.sas_picture_id),
);
});
this.pushstate = History.REPLACE; /* Avoid first url push */
await this.update_picture();
},
/**
* Update the page.
* Called when the `current_picture` property changes.
*
* The url is modified without reloading the page,
* and the previous picture, the next picture and
* the list of identified users are updated.
*/
async update_picture() {
const update_args = [
{ sas_picture_id: this.current_picture.id },
"",
`/sas/picture/${this.current_picture.id}/`,
];
if (this.pushstate === History.REPLACE) {
window.history.replaceState(...update_args);
this.pushstate = History.PUSH;
} else {
window.history.pushState(...update_args);
}
this.moderation_error = "";
const index = this.pictures.indexOf(this.current_picture);
this.previous_picture = this.pictures[index - 1] || null;
this.next_picture = this.pictures[index + 1] || null;
await this.current_picture.load_identifications();
this.$refs.main_picture?.addEventListener("load", () => {
// once the current picture is loaded,
// start preloading the next and previous pictures
this.next_picture?.preload();
this.previous_picture?.preload();
});
},
async moderate_picture() {
const res = await fetch(
`/api/sas/picture/${this.current_picture.id}/moderate`,
{
method: "PATCH",
},
);
if (!res.ok) {
this.moderation_error = `${gettext("Couldn't moderate picture")} : ${res.statusText}`;
return;
}
this.current_picture.is_moderated = true;
this.current_picture.asked_for_removal = false;
},
async delete_picture() {
const res = await fetch(`/api/sas/picture/${this.current_picture.id}/`, {
method: "DELETE",
});
if (!res.ok) {
this.moderation_error =
gettext("Couldn't delete picture") + " : " + res.statusText;
return;
}
this.pictures.splice(this.pictures.indexOf(this.current_picture), 1);
if (this.pictures.length === 0) {
// The deleted picture was the only one in the list.
// As the album is now empty, go back to the parent page
document.location.href = album_url;
}
this.current_picture = this.next_picture || this.previous_picture;
},
/**
* Send the identification request and update the list of identified users.
*/
async submit_identification() {
const url = `/api/sas/picture/${this.current_picture.id}/identified`;
await fetch(url, {
method: "PUT",
body: JSON.stringify(this.selector.val().map((i) => parseInt(i))),
});
// refresh the identified users list
await this.current_picture.load_identifications({ force_reload: true });
this.selector.empty().trigger("change");
},
/**
* Check if an identification can be removed by the currently logged user
* @param {PictureIdentification} identification
* @return {boolean}
*/
can_be_removed(identification) {
return user_is_sas_admin || identification.user.id === user_id;
},
/**
* Untag a user from the current picture
* @param {PictureIdentification} identification
*/
async remove_identification(identification) {
const res = await fetch(`/api/sas/relation/${identification.id}`, {
method: "DELETE",
});
if (res.ok && Array.isArray(this.current_picture.identifications)) {
this.current_picture.identifications =
this.current_picture.identifications.filter(
(i) => i.id !== identification.id,
);
}
},
}));
});

View File

@ -4,6 +4,10 @@
<link rel="stylesheet" href="{{ scss('sas/css/picture.scss') }}">
{%- endblock -%}
{%- block additional_js -%}
<script defer src="{{ static("sas/js/viewer.js") }}"></script>
{%- endblock -%}
{% block title %}
{% trans %}SAS{% endtrans %}
{% endblock %}
@ -11,51 +15,56 @@
{% from "sas/macros.jinja" import print_path %}
{% block content %}
<main x-data="picture_viewer">
<code>
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(picture.parent) }} {{ picture.get_display_name() }}
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="current_picture.name"></span>
</code>
<br>
<div class="title">
<h3>{{ picture.get_display_name() }}</h3>
<h4>{{ picture.parent.children.filter(id__lte=picture.id).count() }}
/ {{ picture.parent.children.count() }}</h4>
<h3 x-text="current_picture.name"></h3>
<h4 x-text="`${pictures.indexOf(current_picture) + 1 } / ${pictures.length}`"></h4>
</div>
<br>
{% if not picture.is_moderated %}
{% set next = picture.get_next() %}
{% if not next %}
{% set next = url('sas:moderation') %}
{% else %}
{% set next = next.get_absolute_url() + "#pict" %}
{% endif %}
<div class="moderation">
<div>
{% if picture.asked_for_removal %}
<template x-if="!current_picture.is_moderated">
<div class="alert alert-red">
<div class="alert-main">
<template x-if="current_picture.asked_for_removal">
<span class="important">{% trans %}Asked for removal{% endtrans %}</span>
{% else %}
&nbsp;
{% endif %}
</template>
<p>
{% trans trimmed %}
This picture can be viewed only by root users and by SAS admins.
It will be hidden to other users until it has been moderated.
{% endtrans %}
</p>
</div>
<div>
<a href="{{ url('core:file_moderate', file_id=picture.id) }}?next={{ next }}">
<div>
<button class="btn btn-blue" @click="moderate_picture()">
{% trans %}Moderate{% endtrans %}
</a>
<a href="{{ url('core:file_delete', file_id=picture.id) }}?next={{ next }}">
</button>
<button class="btn btn-red" @click.prevent="delete_picture()">
{% trans %}Delete{% endtrans %}
</a>
</button>
</div>
<p x-show="!!moderation_error" x-text="moderation_error"></p>
</div>
</div>
{% endif %}
</template>
<div class="container">
<div class="container" id="pict">
<div class="main">
<div class="photo">
<img src="{{ picture.get_download_compressed_url() }}" alt="{{ picture.get_display_name() }}"/>
<div class="photo" :aria-busy="current_picture.image_loading">
<img
:src="current_picture.compressed_url"
:alt="current_picture.name"
id="main-picture"
x-ref="main_picture"
/>
</div>
<div class="general">
@ -64,19 +73,17 @@
<div>
<div>
<span>{% trans %}Date: {% endtrans %}</span>
<span>{{ picture.date|date(DATETIME_FORMAT) }}</span>
<span
x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
).format(new Date(current_picture.date))"
>
</span>
</div>
<div>
<span>{% trans %}Owner: {% endtrans %}</span>
<a href="{{ picture.owner.get_absolute_url() }}">{{ picture.owner.get_short_name() }}</a>
<a :href="current_picture.owner.profile_url" x-text="current_picture.owner.display_name"></a>
</div>
{% if picture.moderator %}
<div>
<span>{% trans %}Moderator: {% endtrans %}</span>
<a href="{{ picture.moderator.get_absolute_url() }}">{{ picture.moderator.get_short_name() }}</a>
</div>
{% endif %}
</div>
</div>
@ -84,14 +91,14 @@
<h5>{% trans %}Tools{% endtrans %}</h5>
<div>
<div>
<a class="text" href="{{ picture.get_download_url() }}">
<a class="text" :href="current_picture.full_size_url">
{% trans %}HD version{% endtrans %}
</a>
<br>
<a class="text danger" href="?ask_removal">{% trans %}Ask for removal{% endtrans %}</a>
</div>
<div class="buttons">
<a class="button" href="{{ url('sas:picture_edit', picture_id=picture.id) }}">✏️</a>
<a class="button" :href="`/sas/picture/${current_picture.id}/edit/`">✏️</a>
<a class="button" href="?rotate_left">↺</a>
<a class="button" href="?rotate_right">↻</a>
</div>
@ -102,99 +109,72 @@
<div class="subsection">
<div class="navigation">
<div id="prev">
{% if previous_pict %}
<a href="{{ url( 'sas:picture', picture_id=previous_pict.id) }}#pict">
<div style="background-image: url('{{ previous_pict.get_download_thumb_url() }}');"></div>
</a>
{% endif %}
<div id="prev" class="clickable">
<template x-if="previous_picture">
<div
@keyup.left.window="current_picture = previous_picture"
@click="current_picture = previous_picture"
>
<img :src="previous_picture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
<div class="overlay">←</div>
</div>
<div id="next">
{% if next_pict %}
<a href="{{ url( 'sas:picture', picture_id=next_pict.id) }}#pict">
<div style="background-image: url('{{ next_pict.get_download_thumb_url() }}');"></div>
</a>
{% endif %}
</template>
</div>
<div id="next" class="clickable">
<template x-if="next_picture">
<div
@keyup.right.window="current_picture = next_picture"
@click="current_picture = next_picture"
>
<img :src="next_picture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
<div class="overlay">→</div>
</div>
</template>
</div>
</div>
<div class="tags">
<h5>{% trans %}People{% endtrans %}</h5>
{% if user.was_subscribed %}
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p() }}
<input type="submit" value="{% trans %}Go{% endtrans %}" />
<form @submit.prevent="submit_identification" x-show="!!selector">
<select x-ref="search" multiple="multiple"></select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form>
{% endif %}
<ul x-data="user_identification">
<template x-for="item in items" :key="item.id">
<ul>
<template
x-for="identification in (current_picture.identifications || [])"
:key="identification.id"
>
<li>
<a class="user" :href="item.user.url">
<img class="profile-pic" :src="item.user.picture" alt="image de profil"/>
<span x-text="item.user.name"></span>
<a class="user" :href="identification.user.profile_url">
<img class="profile-pic" :src="identification.user.profile_pict" alt="image de profil"/>
<span x-text="identification.user.display_name"></span>
</a>
<template x-if="can_be_removed(item)">
<a class="delete clickable" @click="remove(item)">❌</a>
<template x-if="can_be_removed(identification)">
<a class="delete clickable" @click="remove_identification(identification)">❌</a>
</template>
</li>
</template>
<template x-if="current_picture.identifications_loading">
{# shadow element that exists only to put the loading wheel below
the list of identified people #}
<li class="loader" aria-busy="true"></li>
</template>
</ul>
</div>
</div>
</div>
</main>
{% endblock %}
{% block script %}
{{ super() }}
<script>
document.addEventListener("alpine:init", () => {
Alpine.data("user_identification", () => ({
items: [
{%- for r in picture.people.select_related("user", "user__profile_pict") -%}
{
id: {{ r.id }},
user: {
id: {{ r.user.id }},
name: "{{ r.user.get_short_name()|safe }}",
url: "{{ r.user.get_absolute_url() }}",
{% if r.user.profile_pict %}
picture: "{{ r.user.profile_pict.get_download_url() }}",
{% else %}
picture: "{{ static('core/img/unknown.jpg') }}",
{% endif %}
},
},
{%- endfor -%}
],
can_be_removed(item) {
{# If user is root or sas admin, he has the right, at "compile" time.
If not, he can delete only its own identification. #}
{% if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
return true;
{% else %}
return item.user.id === {{ user.id }};
{% endif %}
},
async remove(item) {
const res = await fetch(`/api/sas/relation/${item.id}`, {method: "DELETE"});
if (res.ok) {
this.items = this.items.filter((i) => i.id !== item.id)
}
},
}))
});
$(() => {
$(document).keydown((e) => {
switch (e.keyCode) {
case 37:
$('#prev a')[0].click();
break;
case 39:
$('#next a')[0].click();
break;
}
});
});
const picture_endpoint = "{{ url("api:pictures") + "?album_id=" + album.id|string }}";
const album_url = "{{ album.get_absolute_url() }}";
const first_picture_id = {{ picture.id }}; {# id of the first picture to show after page load #}
const user_id = {{ user.id }};
const user_is_sas_admin = {{ (user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID))|tojson }}
</script>
{% endblock %}

View File

@ -25,7 +25,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, TemplateView
from django.views.generic.edit import FormMixin, FormView, UpdateView
from core.models import Notification, SithFile, User
from core.models import SithFile, User
from core.views import CanEditMixin, CanViewMixin
from core.views.files import FileView, MultipleImageField, send_file
from core.views.forms import SelectDate
@ -127,18 +127,13 @@ class SASMainView(FormView):
return kwargs
class PictureView(CanViewMixin, DetailView, FormMixin):
class PictureView(CanViewMixin, DetailView):
model = Picture
form_class = RelationForm
pk_url_kwarg = "picture_id"
template_name = "sas/picture.jinja"
def get_initial(self):
return {"picture": self.object}
def get(self, request, *args, **kwargs):
self.object = self.get_object()
self.form = self.get_form()
if "rotate_right" in request.GET:
self.object.rotate(270)
if "rotate_left" in request.GET:
@ -150,51 +145,10 @@ class PictureView(CanViewMixin, DetailView, FormMixin):
return redirect("sas:album", album_id=self.object.parent.id)
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
self.form = self.get_form()
if request.user.is_authenticated and request.user.was_subscribed:
if self.form.is_valid():
for uid in self.form.cleaned_data["users"]:
u = User.objects.filter(id=uid).first()
if not u: # Don't use a non existing user
continue
if PeoplePictureRelation.objects.filter(
user=u, picture=self.form.cleaned_data["picture"]
).exists(): # Avoid existing relation
continue
PeoplePictureRelation(
user=u, picture=self.form.cleaned_data["picture"]
).save()
if not u.notifications.filter(
type="NEW_PICTURES", viewed=False
).exists():
Notification(
user=u,
url=reverse("core:user_pictures", kwargs={"user_id": u.id}),
type="NEW_PICTURES",
).save()
return super().form_valid(self.form)
else:
self.form.add_error(None, _("You do not have the permission to do that"))
return self.form_invalid(self.form)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
pictures_qs = Picture.objects.filter(
parent_id=self.object.parent_id
).viewable_by(self.request.user)
kwargs["form"] = self.form
kwargs["next_pict"] = (
pictures_qs.filter(id__gt=self.object.id).order_by("id").first()
)
kwargs["previous_pict"] = (
pictures_qs.filter(id__lt=self.object.id).order_by("-id").first()
)
return kwargs
def get_success_url(self):
return reverse("sas:picture", kwargs={"picture_id": self.object.id})
return super().get_context_data(**kwargs) | {
"album": Album.objects.get(children=self.object)
}
def send_album(request, album_id):

View File

@ -751,8 +751,13 @@ SITH_FRONT_DEP_VERSIONS = {
"https://github.com/getsentry/sentry-javascript/": "8.26.0",
"https://github.com/jhuckaby/webcamjs/": "1.0.0",
"https://github.com/alpinejs/alpine": "3.14.1",
"https://github.com/cytoscape/cytoscape.js": "3.30.2 ",
"https://github.com/cytoscape/cytoscape.js-cxtmenu": "3.5.0",
"https://github.com/cytoscape/cytoscape.js-klay": "3.1.4",
"https://github.com/kieler/klayjs": "0.4.1", # Deprecated, elk should be used but cytoscape-elk is broken
"https://github.com/mrdoob/three.js/": "r148",
"https://github.com/vasturiano/three-spritetext": "1.6.5",
"https://github.com/vasturiano/3d-force-graph/": "1.70.19",
"https://github.com/vasturiano/d3-force-3d": "3.0.3",
"https://github.com/select2/select2/": "4.0.13",
}