Compare commits

2 Commits

Author SHA1 Message Date
Nell 00ac38d126 vpn integration 2026-04-11 22:07:59 +02:00
Nell c4d27e9842 vpn integration 2026-04-11 21:51:30 +02:00
69 changed files with 3501 additions and 1638 deletions
+87
View File
@@ -0,0 +1,87 @@
FROM node:24-alpine AS frontend-builder
WORKDIR /frontend
COPY ./frontend/package.json ./frontend/yarn.lock ./
RUN yarn install --frozen-lockfile
COPY ./frontend/ ./
RUN yarn build
FROM python:3.14-slim
####################################################################
# ARG and ENV
####################################################################
ARG USER_ID=1000
ARG GROUP_ID=1000
ARG USER_NAME=oxpanel
ARG GROUP_NAME=oxpanel
ARG NODE_VERSION=24
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
USER_DIR="/app"
####################################################################
# System dependencies
####################################################################
#RUN apt update && apt install -y gcc graphviz graphviz-dev pkg-config libpq-dev g++ supervisor
RUN apt-get update && apt-get install -y \
ca-certificates \
curl \
gnupg \
inotify-tools \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# install yarn
RUN npm install -g yarn
####################################################################
# Setup app dependencies and switch user
####################################################################
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv export --no-dev --locked --format requirements-txt | uv pip install --system -r -
####################################################################
# User and Group
####################################################################
RUN mkdir -p ${USER_DIR} \
&& groupadd --gid ${GROUP_ID} ${USER_NAME} \
&& useradd -m -d ${USER_DIR} -u ${USER_ID} -g ${GROUP_ID} -o -s /bin/bash ${USER_NAME}
USER ${USER_NAME}
WORKDIR ${USER_DIR}
####################################################################
# Runtime
####################################################################
COPY --from=frontend-builder --chown=${USER_ID}:${GROUP_ID} /frontend/dist ${USER_DIR}/app/static/frontend
# we used sqlite, so we need access to the host files and mount volumes from the host ...
#COPY --chown=${USER_ID}:${GROUP_ID} ./app ${APP_DIR}
RUN python manage.py collectstatic --noinput
#RUN mkdir -p ~/.ipython/profile_default/
#RUN echo "c.InteractiveShellApp.extensions = ['autoreload']\nc.InteractiveShellApp.exec_lines = ['%autoreload 2']" > ~/.ipython/profile_default/ipython_config.py
EXPOSE 8000
#CMD bash -c "sleep 2 && python manage.py collectstatic --noinput && python manage.py migrate && uvicorn oxpanel.asgi:application --workers 5 --host 0.0.0.0 --port 8000 --lifespan off --loop asyncio --ws websockets"
CMD ["uvicorn", "oxpanel.asgi:application", "--config", "uvicorn_config.py"]
+81
View File
@@ -0,0 +1,81 @@
FROM python:3.14-slim
####################################################################
# ARG and ENV
####################################################################
ARG USER_ID=1000
ARG GROUP_ID=1000
ARG USER_NAME=oxpanel
ARG GROUP_NAME=oxpanel
ARG NODE_VERSION=24
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
USER_DIR="/oxpanel" \
APP_DIR="/oxpanel/app"
####################################################################
# System dependencies
####################################################################
#RUN apt update && apt install -y gcc graphviz graphviz-dev pkg-config libpq-dev g++ supervisor
RUN apt-get update && apt-get install -y \
ca-certificates \
curl \
gnupg \
inotify-tools \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# install yarn
RUN npm install -g yarn
####################################################################
# Setup app dependencies
####################################################################
RUN --mount=from=ghcr.io/astral-sh/uv,source=/uv,target=/bin/uv \
--mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv export --locked --format requirements-txt | uv pip install --system -r -
####################################################################
# User and Group
####################################################################
RUN mkdir -p ${USER_DIR} \
&& groupadd --gid ${GROUP_ID} ${USER_NAME} \
&& useradd -m -d ${USER_DIR} -u ${USER_ID} -g ${GROUP_ID} -o -s /bin/bash ${USER_NAME} \
&& chown ${USER_ID}:${GROUP_ID} ${USER_DIR} -R
USER ${USER_NAME}
WORKDIR ${APP_DIR}
####################################################################
# Runtime
####################################################################
# setup node dependencies
COPY --chown=${USER_ID}:${GROUP_ID} ./app/frontend/package.json ./app/frontend/yarn.lock ${APP_DIR}/frontend/
RUN cd ${APP_DIR}/frontend \
&& yarn install \
&& cd -
#COPY --chown=${USER_ID}:${GROUP_ID} ./app ${APP_DIR}
#RUN mkdir -p ~/.ipython/profile_default/
#RUN echo "c.InteractiveShellApp.extensions = ['autoreload']\nc.InteractiveShellApp.exec_lines = ['%autoreload 2']" > ~/.ipython/profile_default/ipython_config.py
EXPOSE 8000
#CMD bash -c "sleep 2 && python manage.py collectstatic --noinput && python manage.py migrate && uvicorn oxpanel.asgi:application --workers 5 --host 0.0.0.0 --port 8000 --lifespan off --loop asyncio --ws websockets"
CMD ["python", "run_uvicorn.py"]
+1
View File
@@ -3,3 +3,4 @@
*.pyc *.pyc
__pycache__ __pycache__
node_modules node_modules
db.sqlite3
+4
View File
@@ -35,3 +35,7 @@ TRANSMISSION_PASSWORD=
UPDATE_TRANSMISSION_DELAY=5 UPDATE_TRANSMISSION_DELAY=5
TORRENT_TTL=2592000 TORRENT_TTL=2592000
# uncomment to enable protonvpn
#PROTON_PKEY=''
#PROTON_COUNTRIES=''
+2
View File
@@ -1,6 +1,8 @@
.idea/ .idea/
.env .env
docker-compose.override.yml
transmission/config/* transmission/config/*
!transmission/config/settings.json !transmission/config/settings.json
!transmission/config/blocklists !transmission/config/blocklists
View File
-66
View File
@@ -1,66 +0,0 @@
FROM python:3.13-slim
ARG puid=1000
ARG pgid=1000
ARG debug=false
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8
RUN groupadd -g ${pgid} -o custom_user
RUN useradd -m -u ${puid} -g ${pgid} -o -s /bin/bash custom_user
#RUN apt update && apt install -y gcc graphviz graphviz-dev pkg-config libpq-dev g++ supervisor
RUN apt update && apt install -y curl inotify-tools
# install nodejs 20
#RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
#RUN apt update && apt install -y nodejs
RUN apt-get update && apt-get install -y \
ca-certificates \
curl \
gnupg \
inotify-tools \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
RUN apt-get update
RUN apt-get install nodejs -y
# install yarn
RUN npm install -g yarn
# setup node dependencies
WORKDIR /app/frontend
COPY ./frontend/package.json ./package.json
RUN yarn install
WORKDIR /app
RUN pip install --upgrade pip
COPY requirements*.txt ./
RUN if [ "$debug" = "true" ] ; then \
pip install --no-cache-dir -r requirements-dev.txt ; \
else \
pip install --no-cache-dir -r requirements-prod.txt ; \
fi
#COPY . .
RUN chown ${puid}:${pgid} /app -R
USER custom_user
RUN mkdir -p ~/.ipython/profile_default/
RUN echo "c.InteractiveShellApp.extensions = ['autoreload']\nc.InteractiveShellApp.exec_lines = ['%autoreload 2']" > ~/.ipython/profile_default/ipython_config.py
EXPOSE 8000
#HEALTHCHECK --interval=30s --timeout=3s \
# CMD curl -f http://127.0.0.1:8000/health/ || exit 1
CMD bash -c "sleep 2 && python manage.py collectstatic --noinput && python manage.py migrate && uvicorn oxpanel.asgi:application --workers 5 --host 0.0.0.0 --port 8000 --lifespan off --loop asyncio --ws websockets"
-2
View File
@@ -1,3 +1 @@
from django.contrib import admin
# Register your models here. # Register your models here.
+2 -2
View File
@@ -2,5 +2,5 @@ from django.apps import AppConfig
class ApiConfig(AppConfig): class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = "django.db.models.BigAutoField"
name = 'api' name = "api"
-2
View File
@@ -1,3 +1 @@
from django.db import models
# Create your models here. # Create your models here.
+7 -7
View File
@@ -1,10 +1,10 @@
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from user.views import UserViewSet
from torrent.views import TorrentViewSet, FileViewSet from torrent.views import FileViewSet, TorrentViewSet
from user.views import FriendRequestViewSet from user.views import FriendRequestViewSet, UserViewSet
router = DefaultRouter() router = DefaultRouter()
router.register(r'users', UserViewSet, basename='user') router.register(r"users", UserViewSet, basename="user")
router.register(r'torrents', TorrentViewSet, basename='torrent') router.register(r"torrents", TorrentViewSet, basename="torrent")
router.register(r'torrent/files', FileViewSet, basename='file') router.register(r"torrent/files", FileViewSet, basename="file")
router.register(r'friend_requests', FriendRequestViewSet, basename='friend-request') router.register(r"friend_requests", FriendRequestViewSet, basename="friend-request")
+10 -10
View File
@@ -1,7 +1,7 @@
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from rest_framework.test import APITestCase, APIClient
from rest_framework import status from rest_framework import status
from rest_framework.test import APIClient, APITestCase
from .routers import router from .routers import router
@@ -12,8 +12,10 @@ class RouterTestCase(TestCase):
url_patterns = router.urls url_patterns = router.urls
# Check that all expected viewsets are registered # Check that all expected viewsets are registered
expected_basenames = ['user', 'torrent', 'file', 'friend-request'] expected_basenames = ["user", "torrent", "file", "friend-request"]
registered_basenames = [url.name.split('-')[0] for url in url_patterns if '-list' in url.name] registered_basenames = [
url.name.split("-")[0] for url in url_patterns if "-list" in url.name
]
for basename in expected_basenames: for basename in expected_basenames:
self.assertIn(basename, registered_basenames) self.assertIn(basename, registered_basenames)
@@ -33,9 +35,7 @@ class APIEndpointsTestCase(APITestCase):
# Create a test user # Create a test user
self.user = User.objects.create_user( self.user = User.objects.create_user(
username='testuser', username="testuser", email="test@example.com", password="testpassword"
email='test@example.com',
password='testpassword'
) )
# Authenticate the client # Authenticate the client
@@ -44,24 +44,24 @@ class APIEndpointsTestCase(APITestCase):
def test_user_endpoint(self): def test_user_endpoint(self):
"""Test that the users endpoint is accessible""" """Test that the users endpoint is accessible"""
url = reverse('user-list') url = reverse("user-list")
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_torrent_endpoint(self): def test_torrent_endpoint(self):
"""Test that the torrents endpoint is accessible""" """Test that the torrents endpoint is accessible"""
url = reverse('torrent-list') url = reverse("torrent-list")
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_file_endpoint(self): def test_file_endpoint(self):
"""Test that the files endpoint is accessible""" """Test that the files endpoint is accessible"""
url = reverse('file-list') url = reverse("file-list")
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_friend_request_endpoint(self): def test_friend_request_endpoint(self):
"""Test that the friend requests endpoint is accessible""" """Test that the friend requests endpoint is accessible"""
url = reverse('friendrequest-list') url = reverse("friendrequest-list")
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
+3 -5
View File
@@ -1,5 +1,4 @@
from django.urls import include, path
from django.urls import path, include
# Ajout du package manquant dans requirements.txt ou installez-le avec: # Ajout du package manquant dans requirements.txt ou installez-le avec:
# pip install djangorestframework-simplejwt # pip install djangorestframework-simplejwt
@@ -8,12 +7,11 @@ from rest_framework_simplejwt.views import (
TokenRefreshView, TokenRefreshView,
) )
from .routers import router from .routers import router
app_name = "api" app_name = "api"
urlpatterns = [ urlpatterns = [
path("", include(router.urls)), path("", include(router.urls)),
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
] ]
-2
View File
@@ -1,3 +1 @@
from django.shortcuts import render
# Create your views here. # Create your views here.
+11 -10
View File
@@ -1,21 +1,22 @@
import os import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
django_asgi_app = get_asgi_application() django_asgi_app = get_asgi_application()
from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter
from channels.sessions import SessionMiddlewareStack from channels.sessions import SessionMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from app.channels_middleware import JwtOrSessionAuthMiddleware from app.channels_middleware import JwtOrSessionAuthMiddleware
from .ws_urls import websocket_urlpatterns from .ws_urls import websocket_urlpatterns
application = ProtocolTypeRouter(
application = ProtocolTypeRouter({ {
"http": django_asgi_app, "http": django_asgi_app,
"websocket": SessionMiddlewareStack(JwtOrSessionAuthMiddleware(websocket_urlpatterns)) "websocket": SessionMiddlewareStack(
}) JwtOrSessionAuthMiddleware(websocket_urlpatterns)
),
}
)
-9
View File
@@ -1,9 +0,0 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
app = Celery("app")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
+20 -22
View File
@@ -1,16 +1,14 @@
from django.contrib.auth.models import AnonymousUser from urllib.parse import parse_qs
from django.db import close_old_connections
from channels.db import database_sync_to_async from channels.db import database_sync_to_async
from channels.middleware import BaseMiddleware from channels.middleware import BaseMiddleware
from channels.auth import AuthMiddlewareStack
from rest_framework_simplejwt.tokens import UntypedToken
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from jwt import decode as jwt_decode
from django.conf import settings from django.conf import settings
from urllib.parse import parse_qs
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.db import close_old_connections
from jwt import decode as jwt_decode
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework_simplejwt.tokens import UntypedToken
User = get_user_model() User = get_user_model()
@@ -23,12 +21,12 @@ def get_user_from_token(token):
# Décoder le token et obtenir l'ID de l'utilisateur # Décoder le token et obtenir l'ID de l'utilisateur
decoded_data = jwt_decode(token, settings.SECRET_KEY, algorithms=["HS256"]) decoded_data = jwt_decode(token, settings.SECRET_KEY, algorithms=["HS256"])
user_id = decoded_data.get('user_id') user_id = decoded_data.get("user_id")
if user_id: if user_id:
return User.objects.get(id=user_id) return User.objects.get(id=user_id)
return AnonymousUser() return AnonymousUser()
except (InvalidToken, TokenError, User.DoesNotExist): except InvalidToken, TokenError, User.DoesNotExist:
return AnonymousUser() return AnonymousUser()
@@ -51,29 +49,29 @@ class JwtOrSessionAuthMiddleware(BaseMiddleware):
close_old_connections() close_old_connections()
# Par défaut, définir un utilisateur anonyme # Par défaut, définir un utilisateur anonyme
scope['user'] = AnonymousUser() scope["user"] = AnonymousUser()
# Essayer d'abord l'authentification par session # Essayer d'abord l'authentification par session
if "session" in scope: if "session" in scope:
scope['user'] = await get_user(scope) scope["user"] = await get_user(scope)
if not scope['user'].is_anonymous: if not scope["user"].is_anonymous:
return await super().__call__(scope, receive, send) return await super().__call__(scope, receive, send)
# Si l'utilisateur est toujours anonyme, essayer JWT # Si l'utilisateur est toujours anonyme, essayer JWT
if scope['user'].is_anonymous and 'query_string' in scope: if scope["user"].is_anonymous and "query_string" in scope:
# Extraire token des query parameters # Extraire token des query parameters
query_params = parse_qs(scope['query_string'].decode('utf-8')) query_params = parse_qs(scope["query_string"].decode("utf-8"))
token = query_params.get('token', [None])[0] token = query_params.get("token", [None])[0]
# Si aucun token dans les query params, chercher dans les headers # Si aucun token dans les query params, chercher dans les headers
if not token and 'headers' in scope: if not token and "headers" in scope:
headers = dict(scope['headers']) headers = dict(scope["headers"])
auth_header = headers.get(b'authorization', b'') auth_header = headers.get(b"authorization", b"")
if auth_header.startswith(b'Bearer '): if auth_header.startswith(b"Bearer "):
token = auth_header.decode('utf-8')[7:] token = auth_header.decode("utf-8")[7:]
# Authentifier avec le token si présent # Authentifier avec le token si présent
if token: if token:
scope['user'] = await get_user_from_token(token) scope["user"] = await get_user_from_token(token)
return await super().__call__(scope, receive, send) return await super().__call__(scope, receive, send)
+69 -74
View File
@@ -10,10 +10,10 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/ https://docs.djangoproject.com/en/5.1/ref/settings/
""" """
from pathlib import Path
from os import getenv
import ast import ast
from datetime import timedelta from datetime import timedelta
from os import getenv
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -41,57 +41,53 @@ CORS_ALLOW_ALL_ORIGINS = True
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', "django.contrib.admin",
'django.contrib.auth', "django.contrib.auth",
'django.contrib.contenttypes', "django.contrib.contenttypes",
'django.contrib.sessions', "django.contrib.sessions",
'django.contrib.messages', "django.contrib.messages",
'django.contrib.staticfiles', "django.contrib.staticfiles",
"django_vite",
'django_vite', "rest_framework",
'rest_framework', "corsheaders",
'corsheaders', "channels",
'channels', "django_filters",
'django_filters', "rest_framework_simplejwt",
'rest_framework_simplejwt', "user",
"api",
'user', "torrent",
'api', "watch_party",
'torrent',
'watch_party'
] ]
if DEBUG:
INSTALLED_APPS = ["daphne"] + INSTALLED_APPS
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', "django.middleware.security.SecurityMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'django.middleware.common.CommonMiddleware', "django.middleware.common.CommonMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', "django.contrib.messages.middleware.MessageMiddleware",
'django.middleware.clickjacking.XFrameOptionsMiddleware', "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
ROOT_URLCONF = 'app.urls' ROOT_URLCONF = "app.urls"
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [BASE_DIR / 'templates'], "DIRS": [BASE_DIR / "templates"],
'APP_DIRS': True, "APP_DIRS": True,
'OPTIONS': { "OPTIONS": {
'context_processors': [ "context_processors": [
'django.template.context_processors.debug', "django.template.context_processors.debug",
'django.template.context_processors.request', "django.template.context_processors.request",
'django.contrib.auth.context_processors.auth', "django.contrib.auth.context_processors.auth",
'django.contrib.messages.context_processors.messages', "django.contrib.messages.context_processors.messages",
], ],
}, },
}, },
] ]
WSGI_APPLICATION = 'app.wsgi.application' WSGI_APPLICATION = "app.wsgi.application"
ASGI_APPLICATION = "app.asgi.application" ASGI_APPLICATION = "app.asgi.application"
@@ -99,9 +95,9 @@ ASGI_APPLICATION = "app.asgi.application"
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases # https://docs.djangoproject.com/en/5.1/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { "default": {
'ENGINE': 'django.db.backends.sqlite3', "ENGINE": "django.db.backends.sqlite3",
'NAME': BASE_DIR / 'db.sqlite3', "NAME": BASE_DIR / "db.sqlite3",
} }
} }
@@ -111,16 +107,16 @@ DATABASES = {
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
}, },
] ]
@@ -128,9 +124,9 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/5.1/topics/i18n/ # https://docs.djangoproject.com/en/5.1/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = "en-us"
TIME_ZONE = 'Europe/Paris' TIME_ZONE = "Europe/Paris"
USE_I18N = True USE_I18N = True
@@ -140,7 +136,7 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/ # https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = '/static/' STATIC_URL = "/static/"
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / "static", BASE_DIR / "static",
BASE_DIR / "frontend/dist", BASE_DIR / "frontend/dist",
@@ -153,7 +149,7 @@ MEDIA_ROOT = BASE_DIR / "media"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# User related # User related
AUTH_USER_MODEL = "user.User" AUTH_USER_MODEL = "user.User"
@@ -173,13 +169,12 @@ DJANGO_VITE = {
} }
} }
REDIS_HOST = { REDIS_HOST = {"host": getenv("REDIS_HOST"), "port": int(getenv("REDIS_PORT", 6379))}
"host": getenv("REDIS_HOST"),
"port": int(getenv("REDIS_PORT", 6379))
}
# Email related # Email related
EMAIL_BACKEND = getenv("EMAIL_BACKEND", 'django.core.mail.backends.console.EmailBackend') EMAIL_BACKEND = getenv(
"EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend"
)
EMAIL_HOST = getenv("EMAIL_HOST", None) EMAIL_HOST = getenv("EMAIL_HOST", None)
EMAIL_PORT = getenv("EMAIL_PORT", None) EMAIL_PORT = getenv("EMAIL_PORT", None)
EMAIL_HOST_USER = getenv("EMAIL_USER", None) EMAIL_HOST_USER = getenv("EMAIL_USER", None)
@@ -196,12 +191,12 @@ CELERY_TIMEZONE = "Europe/Paris"
CELERY_TASK_TRACK_STARTED = True CELERY_TASK_TRACK_STARTED = True
# CELERY_TASK_TIME_LIMIT = 30 * 60 # CELERY_TASK_TIME_LIMIT = 30 * 60
CELERY_TASK_SERIALIZER = "json" CELERY_TASK_SERIALIZER = "json"
CELERY_ACCEPT_CONTENT = ['json'] CELERY_ACCEPT_CONTENT = ["json"]
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
CELERY_BEAT_SCHEDULE = { CELERY_BEAT_SCHEDULE = {
"update_transmission_data": { "update_transmission_data": {
"task": "torrent.tasks.update_transmission_data", "task": "torrent.tasks.update_transmission_data",
"schedule": int(getenv("UPDATE_TRANSMISSION_DELAY", 5)) "schedule": int(getenv("UPDATE_TRANSMISSION_DELAY", 5)),
} }
} }
@@ -217,31 +212,31 @@ CHANNEL_LAYERS = {
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_FILTER_BACKENDS": [ "DEFAULT_FILTER_BACKENDS": [
'django_filters.rest_framework.DjangoFilterBackend', "django_filters.rest_framework.DjangoFilterBackend",
'rest_framework.filters.OrderingFilter' "rest_framework.filters.OrderingFilter",
], ],
"DEFAULT_AUTHENTICATION_CLASSES": [ "DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.BasicAuthentication", "rest_framework.authentication.BasicAuthentication",
'rest_framework_simplejwt.authentication.JWTAuthentication', "rest_framework_simplejwt.authentication.JWTAuthentication",
], ],
"DEFAULT_RENDERER_CLASSES": [ "DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer", "rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer" "rest_framework.renderers.BrowsableAPIRenderer",
] ],
} }
SIMPLE_JWT = { SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1), "REFRESH_TOKEN_LIFETIME": timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False, "ROTATE_REFRESH_TOKENS": False,
'BLACKLIST_AFTER_ROTATION': True, "BLACKLIST_AFTER_ROTATION": True,
'ALGORITHM': 'HS256', "ALGORITHM": "HS256",
'SIGNING_KEY': SECRET_KEY, "SIGNING_KEY": SECRET_KEY,
'VERIFYING_KEY': None, "VERIFYING_KEY": None,
'AUTH_HEADER_TYPES': ('Bearer',), "AUTH_HEADER_TYPES": ("Bearer",),
'USER_ID_FIELD': 'id', "USER_ID_FIELD": "id",
'USER_ID_CLAIM': 'user_id', "USER_ID_CLAIM": "user_id",
} }
# Torrent related # Torrent related
@@ -252,6 +247,6 @@ TRANSMISSION = {
"host": getenv("TRANSMISSION_HOST", "127.0.0.1"), "host": getenv("TRANSMISSION_HOST", "127.0.0.1"),
"port": getenv("TRANSMISSION_PORT", 9091), "port": getenv("TRANSMISSION_PORT", 9091),
"username": getenv("TRANSMISSION_USERNAME"), "username": getenv("TRANSMISSION_USERNAME"),
"password": getenv("TRANSMISSION_PASSWORD") "password": getenv("TRANSMISSION_PASSWORD"),
} }
TORRENT_TTL = int(getenv("TORRENT_TTL", 90 * 24 * 60 * 60)) # 90 jours TORRENT_TTL = int(getenv("TORRENT_TTL", 90 * 24 * 60 * 60)) # 90 jours
+37 -14
View File
@@ -14,31 +14,54 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path, include # Added include for including app URLs
from django.http import HttpResponse
from django.views.generic import RedirectView
from django.contrib.auth.views import ( from django.contrib.auth.views import (
PasswordResetView, PasswordResetDoneView, PasswordResetConfirmView, PasswordResetCompleteView, PasswordChangeView, LogoutView,
PasswordChangeDoneView, LogoutView PasswordChangeDoneView,
PasswordChangeView,
PasswordResetCompleteView,
PasswordResetConfirmView,
PasswordResetDoneView,
PasswordResetView,
) )
from django.http import HttpResponse
from django.urls import include, path # Added include for including app URLs
from django.views.generic import RedirectView
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path("admin/", admin.site.urls),
path("health/", lambda request: HttpResponse("OK")), path("health/", lambda request: HttpResponse("OK")),
path("", include("torrent.urls", "torrent")), path("", include("torrent.urls", "torrent")),
path("user/", include("user.urls", "user")), path("user/", include("user.urls", "user")),
path("api/", include("api.urls", "api")), path("api/", include("api.urls", "api")),
path("home", RedirectView.as_view(url="/", permanent=False), name="home"), path("home", RedirectView.as_view(url="/", permanent=False), name="home"),
# reset password related # reset password related
path("password_reset/", PasswordResetView.as_view(), name="password_reset"), path("password_reset/", PasswordResetView.as_view(), name="password_reset"),
path("password_reset_done/", PasswordResetDoneView.as_view(), name="password_reset_done"), path(
path("reset/<str:uidb64>/<str:token>/", PasswordResetConfirmView.as_view(), name="password_reset_confirm"), "password_reset_done/",
path("reset/done/", PasswordResetCompleteView.as_view(), name="password_reset_complete"), PasswordResetDoneView.as_view(),
path("password_change/", PasswordChangeView.as_view( name="password_reset_done",
success_url="/" ),
), name="password_change"), path(
path("password_change_done/", PasswordChangeDoneView.as_view(), name="password_change_done"), "reset/<str:uidb64>/<str:token>/",
PasswordResetConfirmView.as_view(),
name="password_reset_confirm",
),
path(
"reset/done/",
PasswordResetCompleteView.as_view(),
name="password_reset_complete",
),
path(
"password_change/",
PasswordChangeView.as_view(success_url="/"),
name="password_change",
),
path(
"password_change_done/",
PasswordChangeDoneView.as_view(),
name="password_change_done",
),
path("logout/", LogoutView.as_view(), name="logout"), path("logout/", LogoutView.as_view(), name="logout"),
] ]
+18 -17
View File
@@ -1,29 +1,30 @@
from django.http import StreamingHttpResponse
import zlib
import datetime import datetime
import os import os
import anyio import zlib
from stat import S_IFREG from stat import S_IFREG
from stream_zip import ZIP_64, stream_zip, async_stream_zip
from channels.layers import get_channel_layer import anyio
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.http import StreamingHttpResponse
from stream_zip import ZIP_64, async_stream_zip, stream_zip
def send_sync_channel_message(channel_name, context, data): def send_sync_channel_message(channel_name, context, data):
async_to_sync(get_channel_layer().group_send)(channel_name, { async_to_sync(get_channel_layer().group_send)(
"type": context, channel_name, {"type": context, "data": data}
"data": data )
})
class StreamingZipFileResponse(StreamingHttpResponse): class StreamingZipFileResponse(StreamingHttpResponse):
# https://stream-zip.docs.trade.gov.uk/ # https://stream-zip.docs.trade.gov.uk/
# https://github.com/sandes/zipfly/tree/master # https://github.com/sandes/zipfly/tree/master
def __init__(self, filename, file_list, compression_level=0, is_async=False, *args, **kwargs): def __init__(
self, filename, file_list, compression_level=0, is_async=False, *args, **kwargs
):
self.file_list = file_list self.file_list = file_list
super().__init__(content_type='application/octet-stream', *args, **kwargs) super().__init__(content_type="application/octet-stream", *args, **kwargs)
self['Content-Disposition'] = f'attachment; filename="{filename}"' self["Content-Disposition"] = f'attachment; filename="{filename}"'
# self['Cache-Control'] = "no-cache" # self['Cache-Control'] = "no-cache"
# self['X-Accel-Buffering'] = "no" # self['X-Accel-Buffering'] = "no"
@@ -32,12 +33,12 @@ class StreamingZipFileResponse(StreamingHttpResponse):
if is_async: if is_async:
self.zipped = async_stream_zip( self.zipped = async_stream_zip(
self._async_local_files(), self._async_local_files(),
get_compressobj=lambda: zlib.compressobj(wbits=-zlib.MAX_WBITS, level=compression_level) get_compressobj=lambda: zlib.compressobj(
wbits=-zlib.MAX_WBITS, level=compression_level
),
) )
else: else:
self.zipped = stream_zip( self.zipped = stream_zip(self._sync_local_files())
self._sync_local_files()
)
self.streaming_content = self.zipped self.streaming_content = self.zipped
def _get_total_length(self): def _get_total_length(self):
+5 -4
View File
@@ -1,9 +1,10 @@
from channels.routing import URLRouter
from django.urls import path from django.urls import path
from channels.routing import ProtocolTypeRouter, URLRouter
from torrent.consumers import TorrentEventConsumer from torrent.consumers import TorrentEventConsumer
websocket_urlpatterns = URLRouter(
websocket_urlpatterns = URLRouter([ [
path("ws/torrent_event/", TorrentEventConsumer.as_asgi()), path("ws/torrent_event/", TorrentEventConsumer.as_asgi()),
]) ]
)
+1 -1
View File
@@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
application = get_wsgi_application() application = get_wsgi_application()
Regular → Executable
+4 -2
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
APP_DIR="${APP_DIR:-/oxpanel/app}"
CORES=$(nproc) CORES=$(nproc)
WORKERS=$((2 * CORES + 1)) WORKERS=$((2 * CORES + 1))
cd /app/frontend && yarn dev & cd "${APP_DIR}/frontend" && yarn dev &
cd /app && python manage.py runserver 0.0.0.0:8000 & cd "${APP_DIR}" && python run_uvicorn.py &
wait -n wait -n
+1
View File
@@ -0,0 +1 @@
nodeLinker: node-modules
+10 -10
View File
@@ -10,21 +10,21 @@
}, },
"devDependencies": { "devDependencies": {
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^6.0.5",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.27",
"postcss": "^8.5.2", "postcss": "^8.5.8",
"postcss-import": "^16.1.0", "postcss-import": "^16.1.1",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"video.js": "^8.21.0", "video.js": "^8.23.7",
"vite": "^6.1.0" "vite": "^8.0.5"
}, },
"dependencies": { "dependencies": {
"events": "^3.3.0", "events": "^3.3.0",
"filesize": "^10.1.6", "filesize": "^11.0.15",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"moment": "^2.30.1", "moment": "^2.30.1",
"vite-plugin-vuetify": "^2.1.0", "vite-plugin-vuetify": "^2.1.3",
"vue": "^3.5.13", "vue": "^3.5.32",
"vuetify": "^3.7.12" "vuetify": "^3.12.5"
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
export default { export default {
plugins: { plugins: {
'postcss-import': {}, 'postcss-import': {},
'postcss-simple-vars': {}, // 'postcss-simple-vars': {},
'autoprefixer': {} 'autoprefixer': {}
} }
} }
+5 -1
View File
@@ -4,5 +4,9 @@ import Ws from "./ws";
import QtWC from "./qtwebchannel.js" import QtWC from "./qtwebchannel.js"
export function createVue(component, dom_id){ export function createVue(component, dom_id){
return createApp(component).use(Vuetify).use(Ws).use(QtWC).mount(dom_id) return createApp(component)
.use(Vuetify)
.use(Ws)
.use(QtWC)
.mount(dom_id)
} }
+1664 -747
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env python #!/usr/bin/env python
"""Django's command-line utility for administrative tasks.""" """Django's command-line utility for administrative tasks."""
import os import os
import sys import sys
def main(): def main():
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:
@@ -18,5 +19,5 @@ def main():
execute_from_command_line(sys.argv) execute_from_command_line(sys.argv)
if __name__ == '__main__': if __name__ == "__main__":
main() main()
-14
View File
@@ -1,14 +0,0 @@
django
django-vite
django-cors-headers
djangorestframework
django-filter
djangorestframework-simplejwt
channels
channels_redis
pytz
transmission-rpc
stream-zip
anyio
websockets
uvloop
-2
View File
@@ -1,2 +0,0 @@
-r requirements-common.txt
daphne
-2
View File
@@ -1,2 +0,0 @@
-r requirements-common.txt
uvicorn
+39
View File
@@ -0,0 +1,39 @@
import os
import uvicorn
def build_config() -> dict:
debug = os.getenv("DEBUG", "true").lower() == "true"
config = {
"host": os.getenv("UVICORN_HOST", "0.0.0.0"),
"port": int(os.getenv("UVICORN_PORT", "8000")),
"loop": "asyncio",
"ws": "websockets",
"proxy_headers": True,
"forwarded_allow_ips": "*",
"log_level": "debug" if debug else "info",
"access_log": True,
}
if debug:
config.update(
{
"reload": True,
"reload_dirs": ["."],
"workers": 1,
}
)
else:
config.update(
{
"workers": int(os.getenv("UVICORN_WORKERS", "5")),
}
)
return config
if __name__ == "__main__":
uvicorn.run("app.asgi:application", **build_config())
+9 -4
View File
@@ -2,13 +2,18 @@ from django.apps import AppConfig
class TorrentConfig(AppConfig): class TorrentConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = "django.db.models.BigAutoField"
name = 'torrent' name = "torrent"
def ready(self): def ready(self):
from django.db.models.signals import post_save, pre_delete, m2m_changed from django.db.models.signals import m2m_changed, post_save, pre_delete
from .signals import on_post_save_torrent, on_pre_delete_torrent, on_shared_user_changed
from .models import Torrent from .models import Torrent
from .signals import (
on_post_save_torrent,
on_pre_delete_torrent,
on_shared_user_changed,
)
post_save.connect(on_post_save_torrent, sender=Torrent) post_save.connect(on_post_save_torrent, sender=Torrent)
pre_delete.connect(on_pre_delete_torrent, sender=Torrent) pre_delete.connect(on_pre_delete_torrent, sender=Torrent)
+34 -30
View File
@@ -1,11 +1,8 @@
from django.db.models import Q
from django.db.models.functions import Coalesce
from channels.generic.websocket import AsyncJsonWebsocketConsumer from channels.generic.websocket import AsyncJsonWebsocketConsumer
from typing import Optional, Union from django.db.models import Q
import asyncio
from user.models import User from user.models import User
from .models import Torrent from .models import Torrent
@@ -14,17 +11,19 @@ class TorrentEventConsumer(AsyncJsonWebsocketConsumer):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.channel_groups = set() self.channel_groups = set()
self.user: Optional[User] = None self.user: User | None = None
self.follow_user: Optional[User] = None self.follow_user: User | None = None
async def connect(self): async def connect(self):
self.user = self.scope['user'] self.user = self.scope["user"]
if not self.user.is_authenticated: if not self.user.is_authenticated:
await self.close() await self.close()
return return
self.follow_user = self.user self.follow_user = self.user
await self.channel_layer.group_add(f"user_{self.follow_user.id}", self.channel_name) await self.channel_layer.group_add(
f"user_{self.follow_user.id}", self.channel_name
)
# user_id = int(self.scope['url_route']["kwargs"]["user_id"]) # user_id = int(self.scope['url_route']["kwargs"]["user_id"])
# if user_id == self.user.id: # if user_id == self.user.id:
@@ -53,47 +52,52 @@ class TorrentEventConsumer(AsyncJsonWebsocketConsumer):
print("call websocket not supported", content) print("call websocket not supported", content)
async def change_follow_user(self, user_id): async def change_follow_user(self, user_id):
await self.channel_layer.group_discard(f"user_{self.follow_user.id}", self.channel_name) await self.channel_layer.group_discard(
f"user_{self.follow_user.id}", self.channel_name
)
if user_id == self.user.id: if user_id == self.user.id:
self.follow_user = self.user self.follow_user = self.user
await self.channel_layer.group_add(f"user_{self.follow_user.id}", self.channel_name) await self.channel_layer.group_add(
f"user_{self.follow_user.id}", self.channel_name
)
return self.follow_user return self.follow_user
elif await self.user.friends.filter(id=user_id).aexists(): elif await self.user.friends.filter(id=user_id).aexists():
self.follow_user = await User.objects.filter(id=user_id).aget() self.follow_user = await User.objects.filter(id=user_id).aget()
await self.channel_layer.group_add(f"user_{self.follow_user.id}", self.channel_name) await self.channel_layer.group_add(
f"user_{self.follow_user.id}", self.channel_name
)
return self.follow_user return self.follow_user
else: else:
return None return None
async def transmission_data_updated(self, datas): async def transmission_data_updated(self, datas):
torrent_stats = datas["data"] torrent_stats = datas["data"]
qs = (Torrent.objects qs = (
.filter(Q(user_id=self.follow_user.id) | Q(shared_users=self.follow_user.id)) Torrent.objects.filter(
.values_list("id", flat=True).distinct()) Q(user_id=self.follow_user.id) | Q(shared_users=self.follow_user.id)
)
.values_list("id", flat=True)
.distinct()
)
torrent_ids = [i async for i in qs] torrent_ids = [i async for i in qs]
for hash_string, data in torrent_stats.items(): for hash_string, data in torrent_stats.items():
if hash_string in torrent_ids: if hash_string in torrent_ids:
await self.send_json({ await self.send_json(
"context": "transmission_data_updated", {"context": "transmission_data_updated", "data": data}
"data": data )
})
async def add_torrent(self, data): async def add_torrent(self, data):
await self.send_json({ await self.send_json({"context": "add_torrent", "torrent_id": data["data"]})
"context": "add_torrent",
"torrent_id": data["data"]
})
async def remove_torrent(self, data): async def remove_torrent(self, data):
await self.send_json({ await self.send_json({"context": "remove_torrent", "torrent_id": data["data"]})
"context": "remove_torrent",
"torrent_id": data["data"]
})
async def update_torrent(self, data): async def update_torrent(self, data):
await self.send_json({ await self.send_json(
{
"context": "update_torrent", "context": "update_torrent",
"torrent_id": data["data"]["torrent_id"], "torrent_id": data["data"]["torrent_id"],
"updated_fields": data["data"]["updated_fields"] "updated_fields": data["data"]["updated_fields"],
}) }
)
@@ -1,17 +1,16 @@
from django.conf import settings
from django.utils import timezone
from django.core.management.base import BaseCommand
from django.db import close_old_connections
import time
import signal import signal
import sys import time
import traceback import traceback
from datetime import timedelta from datetime import timedelta
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import close_old_connections
from django.utils import timezone
from app.utils import send_sync_channel_message
from torrent.models import Torrent from torrent.models import Torrent
from torrent.utils import transmission_handler from torrent.utils import transmission_handler
from app.utils import send_sync_channel_message
def update_transmission_data(): def update_transmission_data():
@@ -24,10 +23,11 @@ def update_transmission_data():
updated_torrents.append(torrent) updated_torrents.append(torrent)
if updated_torrents: if updated_torrents:
Torrent.objects.bulk_update(updated_torrents, ["transmission_data"]) Torrent.objects.bulk_update(updated_torrents, ["transmission_data"])
send_sync_channel_message("torrent", "transmission_data_updated", { send_sync_channel_message(
torrent.id: torrent.transmission_data "torrent",
for torrent in updated_torrents "transmission_data_updated",
}) {torrent.id: torrent.transmission_data for torrent in updated_torrents},
)
def clean_old_torrents(): def clean_old_torrents():
@@ -38,16 +38,15 @@ def clean_old_torrents():
torrent.delete() torrent.delete()
def update_peer_port():
transmission_handler.update_vpn_port()
class Command(BaseCommand): class Command(BaseCommand):
task_schedule = { task_schedule = {
"update_transmission_data": { "update_transmission_data": {"func": update_transmission_data, "schedule": 5.0},
"func": update_transmission_data, "clean_old_torrents": {"func": clean_old_torrents, "schedule": 5.0},
"schedule": 5.0 "update_peer_port": {"func": update_peer_port, "schedule": 10.0},
},
"clean_old_torrents": {
"func": clean_old_torrents,
"schedule": 5.0
}
} }
histories = {} histories = {}
run = True run = True
@@ -59,7 +58,10 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS("start")) self.stdout.write(self.style.SUCCESS("start"))
while self.run: while self.run:
for name, task in self.task_schedule.items(): for name, task in self.task_schedule.items():
if name not in self.histories or time.time() - self.histories[name] > task["schedule"]: if (
name not in self.histories
or time.time() - self.histories[name] > task["schedule"]
):
self.call_func(name) self.call_func(name)
time.sleep(1) time.sleep(1)
+31 -16
View File
@@ -1,40 +1,55 @@
# Generated by Django 5.1.6 on 2025-03-04 23:41 # Generated by Django 5.1.6 on 2025-03-04 23:41
import uuid import uuid
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = []
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='File', name="File",
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), (
('rel_name', models.TextField()), "id",
('size', models.BigIntegerField()), models.UUIDField(
default=uuid.uuid4, primary_key=True, serialize=False
),
),
("rel_name", models.TextField()),
("size", models.BigIntegerField()),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='SharedUser', name="SharedUser",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('date', models.DateTimeField(auto_now_add=True)), "id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date", models.DateTimeField(auto_now_add=True)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Torrent', name="Torrent",
fields=[ fields=[
('id', models.CharField(max_length=40, primary_key=True, serialize=False)), (
('name', models.CharField(max_length=255)), "id",
('date_added', models.DateTimeField(auto_now_add=True)), models.CharField(max_length=40, primary_key=True, serialize=False),
('size', models.PositiveBigIntegerField()), ),
('transmission_data', models.JSONField(default=dict)), ("name", models.CharField(max_length=255)),
("date_added", models.DateTimeField(auto_now_add=True)),
("size", models.PositiveBigIntegerField()),
("transmission_data", models.JSONField(default=dict)),
], ],
), ),
] ]
+35 -19
View File
@@ -6,42 +6,58 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('torrent', '0001_initial'), ("torrent", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='shareduser', model_name="shareduser",
name='user', name="user",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
), ),
migrations.AddField( migrations.AddField(
model_name='torrent', model_name="torrent",
name='shared_users', name="shared_users",
field=models.ManyToManyField(blank=True, related_name='torrents_shares', through='torrent.SharedUser', to=settings.AUTH_USER_MODEL), field=models.ManyToManyField(
blank=True,
related_name="torrents_shares",
through="torrent.SharedUser",
to=settings.AUTH_USER_MODEL,
),
), ),
migrations.AddField( migrations.AddField(
model_name='torrent', model_name="torrent",
name='user', name="user",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='torrents', to=settings.AUTH_USER_MODEL), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="torrents",
to=settings.AUTH_USER_MODEL,
),
), ),
migrations.AddField( migrations.AddField(
model_name='shareduser', model_name="shareduser",
name='torrent', name="torrent",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='torrent.torrent'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="torrent.torrent"
),
), ),
migrations.AddField( migrations.AddField(
model_name='file', model_name="file",
name='torrent', name="torrent",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='torrent.torrent'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="files",
to="torrent.torrent",
),
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='shareduser', name="shareduser",
unique_together={('user', 'torrent')}, unique_together={("user", "torrent")},
), ),
] ]
@@ -4,15 +4,14 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('torrent', '0002_initial'), ("torrent", "0002_initial"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='torrent', model_name="torrent",
name='date_modified', name="date_modified",
field=models.DateTimeField(auto_now=True), field=models.DateTimeField(auto_now=True),
), ),
] ]
@@ -4,15 +4,14 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('torrent', '0003_torrent_date_modified'), ("torrent", "0003_torrent_date_modified"),
] ]
operations = [ operations = [
migrations.RenameField( migrations.RenameField(
model_name='shareduser', model_name="shareduser",
old_name='date', old_name="date",
new_name='date_created', new_name="date_created",
), ),
] ]
@@ -4,15 +4,14 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('torrent', '0004_rename_date_shareduser_date_created'), ("torrent", "0004_rename_date_shareduser_date_created"),
] ]
operations = [ operations = [
migrations.RenameField( migrations.RenameField(
model_name='torrent', model_name="torrent",
old_name='date_added', old_name="date_added",
new_name='date_created', new_name="date_created",
), ),
] ]
+16 -17
View File
@@ -1,12 +1,11 @@
from django.db import models import mimetypes
from django.conf import settings import uuid
from functools import cached_property from functools import cached_property
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
import mimetypes
import uuid from django.conf import settings
import shlex from django.db import models
class Torrent(models.Model): class Torrent(models.Model):
@@ -14,8 +13,12 @@ class Torrent(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
date_created = models.DateTimeField(auto_now_add=True) date_created = models.DateTimeField(auto_now_add=True)
date_modified = models.DateTimeField(auto_now=True) date_modified = models.DateTimeField(auto_now=True)
user = models.ForeignKey("user.User", on_delete=models.CASCADE, related_name="torrents") user = models.ForeignKey(
shared_users = models.ManyToManyField("user.User", related_name="torrents_shares", blank=True, through="SharedUser") "user.User", on_delete=models.CASCADE, related_name="torrents"
)
shared_users = models.ManyToManyField(
"user.User", related_name="torrents_shares", blank=True, through="SharedUser"
)
size = models.PositiveBigIntegerField() size = models.PositiveBigIntegerField()
transmission_data = models.JSONField(default=dict) transmission_data = models.JSONField(default=dict)
@@ -35,10 +38,7 @@ class Torrent(models.Model):
@cached_property @cached_property
def related_users(self): def related_users(self):
return [ return [self.user_id, *self.shared_users.values_list("id", flat=True)]
self.user_id,
*self.shared_users.values_list("id", flat=True)
]
class SharedUser(models.Model): class SharedUser(models.Model):
@@ -50,7 +50,6 @@ class SharedUser(models.Model):
unique_together = ("user", "torrent") unique_together = ("user", "torrent")
class File(models.Model): class File(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4) id = models.UUIDField(primary_key=True, default=uuid.uuid4)
torrent = models.ForeignKey("Torrent", models.CASCADE, related_name="files") torrent = models.ForeignKey("Torrent", models.CASCADE, related_name="files")
@@ -86,7 +85,7 @@ class File(models.Model):
def is_video(self): def is_video(self):
if self.mime_types.startswith("video/"): if self.mime_types.startswith("video/"):
return True return True
video_extensions = ['.mp4', '.flv', '.webm', '.avi', '.mkv', '.mov', '.wmv'] video_extensions = [".mp4", ".flv", ".webm", ".avi", ".mkv", ".mov", ".wmv"]
return self.pathname.suffix.lower() in video_extensions return self.pathname.suffix.lower() in video_extensions
@property @property
@@ -95,13 +94,13 @@ class File(models.Model):
encoded_parts = [] encoded_parts = []
for part in self.pathname.parts: for part in self.pathname.parts:
# Ignorer un slash initial si présent # Ignorer un slash initial si présent
if part == '/' or part == '\\': if part == "/" or part == "\\":
continue continue
encoded_parts.append(quote(part)) encoded_parts.append(quote(part))
# Construction du chemin final avec le préfixe Nginx # Construction du chemin final avec le préfixe Nginx
if settings.NGINX_ACCEL_BASE.endswith('/'): if settings.NGINX_ACCEL_BASE.endswith("/"):
base = settings.NGINX_ACCEL_BASE.rstrip('/') base = settings.NGINX_ACCEL_BASE.rstrip("/")
else: else:
base = settings.NGINX_ACCEL_BASE base = settings.NGINX_ACCEL_BASE
+3 -4
View File
@@ -1,15 +1,14 @@
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from rest_framework import serializers from rest_framework import serializers
from user.serializers import UserSerializer from .models import File, Torrent
from .models import Torrent, File
class TorrentSerializer(serializers.ModelSerializer): class TorrentSerializer(serializers.ModelSerializer):
count_files = serializers.IntegerField(read_only=True, source="len_files") count_files = serializers.IntegerField(read_only=True, source="len_files")
download_url = serializers.SerializerMethodField(read_only=True) download_url = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
model = Torrent model = Torrent
fields = "__all__" fields = "__all__"
@@ -32,4 +31,4 @@ class FileSerializer(serializers.ModelSerializer):
return reverse("torrent:download_file", kwargs={"file_id": obj.id}) return reverse("torrent:download_file", kwargs={"file_id": obj.id})
def get_flux_url(self, obj: File): def get_flux_url(self, obj: File):
return f'{reverse("torrent:flux_file", kwargs={"file_id": obj.id})}#{slugify(obj.filename)}' return f"{reverse('torrent:flux_file', kwargs={'file_id': obj.id})}#{slugify(obj.filename)}"
+30 -9
View File
@@ -1,11 +1,14 @@
from app.utils import send_sync_channel_message from app.utils import send_sync_channel_message
from .models import Torrent
from .utils import transmission_handler from .utils import transmission_handler
from .models import Torrent, SharedUser
def on_post_save_torrent(instance: Torrent, created, **kwargs): def on_post_save_torrent(instance: Torrent, created, **kwargs):
if created: if created:
send_sync_channel_message(f"user_{instance.user_id}", "add_torrent", instance.id) send_sync_channel_message(
f"user_{instance.user_id}", "add_torrent", instance.id
)
def on_pre_delete_torrent(instance: Torrent, **kwargs): def on_pre_delete_torrent(instance: Torrent, **kwargs):
@@ -25,20 +28,38 @@ def on_shared_user_changed(sender, instance: Torrent, action, pk_set, **kwargs):
for user_id in pk_set: for user_id in pk_set:
send_sync_channel_message(f"user_{user_id}", "add_torrent", instance.id) send_sync_channel_message(f"user_{user_id}", "add_torrent", instance.id)
for user_id in instance.related_users: for user_id in instance.related_users:
send_sync_channel_message(f"user_{user_id}", "update_torrent", { send_sync_channel_message(
f"user_{user_id}",
"update_torrent",
{
"torrent_id": instance.id, "torrent_id": instance.id,
"updated_fields": {"shared_users": list(instance.shared_users.all().values_list("id", flat=True))} "updated_fields": {
}) "shared_users": list(
instance.shared_users.all().values_list("id", flat=True)
)
},
},
)
case "pre_remove": case "pre_remove":
pass pass
case "post_remove": case "post_remove":
for user_id in pk_set: for user_id in pk_set:
send_sync_channel_message(f"user_{user_id}", "remove_torrent", instance.id) send_sync_channel_message(
f"user_{user_id}", "remove_torrent", instance.id
)
for user_id in instance.related_users: for user_id in instance.related_users:
send_sync_channel_message(f"user_{user_id}", "update_torrent", { send_sync_channel_message(
f"user_{user_id}",
"update_torrent",
{
"torrent_id": instance.id, "torrent_id": instance.id,
"updated_fields": {"shared_users": list(instance.shared_users.all().values_list("id", flat=True))} "updated_fields": {
}) "shared_users": list(
instance.shared_users.all().values_list("id", flat=True)
)
},
},
)
case "pre_clear": case "pre_clear":
pass pass
case "post_clear": case "post_clear":
+8 -10
View File
@@ -1,12 +1,10 @@
from django.db import close_old_connections
from celery import shared_task from celery import shared_task
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from .models import Torrent, File
from .utils import transmission_handler
from app.utils import send_sync_channel_message from app.utils import send_sync_channel_message
from .models import Torrent
from .utils import transmission_handler
@shared_task @shared_task
def update_transmission_data(): def update_transmission_data():
@@ -19,8 +17,8 @@ def update_transmission_data():
updated_torrents.append(torrent) updated_torrents.append(torrent)
if updated_torrents: if updated_torrents:
Torrent.objects.bulk_update(updated_torrents, ["transmission_data"]) Torrent.objects.bulk_update(updated_torrents, ["transmission_data"])
send_sync_channel_message("torrent", "transmission_data_updated", { send_sync_channel_message(
torrent.id: torrent.transmission_data "torrent",
for torrent in updated_torrents "transmission_data_updated",
}) {torrent.id: torrent.transmission_data for torrent in updated_torrents},
)
+81 -111
View File
@@ -1,39 +1,34 @@
from unittest.mock import MagicMock, patch
from django.conf import settings
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from rest_framework.test import APITestCase, APIClient
from rest_framework import status from rest_framework import status
from django.conf import settings from rest_framework.test import APIClient, APITestCase
from unittest.mock import patch, MagicMock
from .models import Torrent, SharedUser, File
from user.models import User from user.models import User
from .views import TorrentViewSet, FileViewSet
from .utils import Transmission, torrent_proceed, torrent_share from .models import File, SharedUser, Torrent
from .utils import Transmission, torrent_proceed
class TorrentModelTestCase(TestCase): class TorrentModelTestCase(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(
username='testuser', username="testuser", email="test@example.com", password="testpassword"
email='test@example.com',
password='testpassword'
) )
self.torrent = Torrent.objects.create( self.torrent = Torrent.objects.create(
id='abc123', id="abc123",
name='Test Torrent', name="Test Torrent",
user=self.user, user=self.user,
size=1000, size=1000,
transmission_data={} transmission_data={},
) )
self.shared_user = User.objects.create_user( self.shared_user = User.objects.create_user(
username='shareduser', username="shareduser", email="shared@example.com", password="sharedpassword"
email='shared@example.com',
password='sharedpassword'
) )
self.file = File.objects.create( self.file = File.objects.create(
torrent=self.torrent, torrent=self.torrent, rel_name="test_file.txt", size=100
rel_name='test_file.txt',
size=100
) )
def test_len_files(self): def test_len_files(self):
@@ -41,15 +36,11 @@ class TorrentModelTestCase(TestCase):
self.assertEqual(self.torrent.len_files, 1) self.assertEqual(self.torrent.len_files, 1)
# Add another file and test again # Add another file and test again
File.objects.create( File.objects.create(torrent=self.torrent, rel_name="another_file.txt", size=200)
torrent=self.torrent,
rel_name='another_file.txt',
size=200
)
# Clear cached_property # Clear cached_property
if hasattr(self.torrent, '_len_files'): if hasattr(self.torrent, "_len_files"):
delattr(self.torrent, '_len_files') delattr(self.torrent, "_len_files")
self.assertEqual(self.torrent.len_files, 2) self.assertEqual(self.torrent.len_files, 2)
@@ -62,8 +53,8 @@ class TorrentModelTestCase(TestCase):
self.torrent.shared_users.add(self.shared_user) self.torrent.shared_users.add(self.shared_user)
# Clear cached_property # Clear cached_property
if hasattr(self.torrent, '_related_users'): if hasattr(self.torrent, "_related_users"):
delattr(self.torrent, '_related_users') delattr(self.torrent, "_related_users")
# Should include both users now # Should include both users now
self.assertIn(self.user.id, self.torrent.related_users) self.assertIn(self.user.id, self.torrent.related_users)
@@ -73,30 +64,26 @@ class TorrentModelTestCase(TestCase):
class FileModelTestCase(TestCase): class FileModelTestCase(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(
username='testuser', username="testuser", email="test@example.com", password="testpassword"
email='test@example.com',
password='testpassword'
) )
self.torrent = Torrent.objects.create( self.torrent = Torrent.objects.create(
id='abc123', id="abc123",
name='Test Torrent', name="Test Torrent",
user=self.user, user=self.user,
size=1000, size=1000,
transmission_data={} transmission_data={},
) )
self.file = File.objects.create( self.file = File.objects.create(
torrent=self.torrent, torrent=self.torrent, rel_name="test/path/file.mp4", size=100
rel_name='test/path/file.mp4',
size=100
) )
def test_pathname(self): def test_pathname(self):
"""Test the pathname property returns the correct path""" """Test the pathname property returns the correct path"""
self.assertEqual(str(self.file.pathname), 'test/path/file.mp4') self.assertEqual(str(self.file.pathname), "test/path/file.mp4")
def test_filename(self): def test_filename(self):
"""Test the filename property returns the correct filename""" """Test the filename property returns the correct filename"""
self.assertEqual(self.file.filename, 'file.mp4') self.assertEqual(self.file.filename, "file.mp4")
def test_abs_pathname(self): def test_abs_pathname(self):
"""Test the abs_pathname property returns the correct absolute path""" """Test the abs_pathname property returns the correct absolute path"""
@@ -109,9 +96,7 @@ class FileModelTestCase(TestCase):
# Test non-video file # Test non-video file
non_video_file = File.objects.create( non_video_file = File.objects.create(
torrent=self.torrent, torrent=self.torrent, rel_name="test/path/document.pdf", size=50
rel_name='test/path/document.pdf',
size=50
) )
self.assertFalse(non_video_file.is_video) self.assertFalse(non_video_file.is_video)
@@ -119,29 +104,22 @@ class FileModelTestCase(TestCase):
class SharedUserModelTestCase(TestCase): class SharedUserModelTestCase(TestCase):
def setUp(self): def setUp(self):
self.owner = User.objects.create_user( self.owner = User.objects.create_user(
username='owner', username="owner", email="owner@example.com", password="ownerpassword"
email='owner@example.com',
password='ownerpassword'
) )
self.shared_user = User.objects.create_user( self.shared_user = User.objects.create_user(
username='shareduser', username="shareduser", email="shared@example.com", password="sharedpassword"
email='shared@example.com',
password='sharedpassword'
) )
self.torrent = Torrent.objects.create( self.torrent = Torrent.objects.create(
id='abc123', id="abc123",
name='Test Torrent', name="Test Torrent",
user=self.owner, user=self.owner,
size=1000, size=1000,
transmission_data={} transmission_data={},
) )
def test_shared_user_creation(self): def test_shared_user_creation(self):
"""Test creating a shared user relationship""" """Test creating a shared user relationship"""
shared = SharedUser.objects.create( shared = SharedUser.objects.create(user=self.shared_user, torrent=self.torrent)
user=self.shared_user,
torrent=self.torrent
)
self.assertEqual(shared.user, self.shared_user) self.assertEqual(shared.user, self.shared_user)
self.assertEqual(shared.torrent, self.torrent) self.assertEqual(shared.torrent, self.torrent)
@@ -152,109 +130,99 @@ class SharedUserModelTestCase(TestCase):
class TorrentViewSetTestCase(APITestCase): class TorrentViewSetTestCase(APITestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(
username='testuser', username="testuser", email="test@example.com", password="testpassword"
email='test@example.com',
password='testpassword'
) )
self.client = APIClient() self.client = APIClient()
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
self.torrent = Torrent.objects.create( self.torrent = Torrent.objects.create(
id='abc123', id="abc123",
name='Test Torrent', name="Test Torrent",
user=self.user, user=self.user,
size=1000, size=1000,
transmission_data={} transmission_data={},
) )
self.file = File.objects.create( self.file = File.objects.create(
torrent=self.torrent, torrent=self.torrent, rel_name="test_file.txt", size=100
rel_name='test_file.txt',
size=100
) )
def test_list_torrents(self): def test_list_torrents(self):
"""Test listing torrents""" """Test listing torrents"""
url = reverse('torrent-list') url = reverse("torrent-list")
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1) self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['id'], self.torrent.id) self.assertEqual(response.data[0]["id"], self.torrent.id)
def test_retrieve_torrent(self): def test_retrieve_torrent(self):
"""Test retrieving a specific torrent""" """Test retrieving a specific torrent"""
url = reverse('torrent-detail', args=[self.torrent.id]) url = reverse("torrent-detail", args=[self.torrent.id])
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['id'], self.torrent.id) self.assertEqual(response.data["id"], self.torrent.id)
self.assertEqual(response.data['name'], 'Test Torrent') self.assertEqual(response.data["name"], "Test Torrent")
@patch('torrent.views.torrent_share') @patch("torrent.views.torrent_share")
def test_share_torrent(self, mock_torrent_share): def test_share_torrent(self, mock_torrent_share):
"""Test sharing a torrent with another user""" """Test sharing a torrent with another user"""
mock_torrent_share.return_value = True mock_torrent_share.return_value = True
shared_user = User.objects.create_user( shared_user = User.objects.create_user(
username='shareduser', username="shareduser", email="shared@example.com", password="sharedpassword"
email='shared@example.com',
password='sharedpassword'
) )
url = reverse('torrent-share', args=[self.torrent.id]) url = reverse("torrent-share", args=[self.torrent.id])
response = self.client.post(url, {'user_id': shared_user.id}) response = self.client.post(url, {"user_id": shared_user.id})
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data['success']) self.assertTrue(response.data["success"])
mock_torrent_share.assert_called_once() mock_torrent_share.assert_called_once()
class FileViewSetTestCase(APITestCase): class FileViewSetTestCase(APITestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(
username='testuser', username="testuser", email="test@example.com", password="testpassword"
email='test@example.com',
password='testpassword'
) )
self.client = APIClient() self.client = APIClient()
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
self.torrent = Torrent.objects.create( self.torrent = Torrent.objects.create(
id='abc123', id="abc123",
name='Test Torrent', name="Test Torrent",
user=self.user, user=self.user,
size=1000, size=1000,
transmission_data={} transmission_data={},
) )
self.file = File.objects.create( self.file = File.objects.create(
torrent=self.torrent, torrent=self.torrent, rel_name="test_file.txt", size=100
rel_name='test_file.txt',
size=100
) )
def test_list_files(self): def test_list_files(self):
"""Test listing files""" """Test listing files"""
url = reverse('file-list') url = reverse("file-list")
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_retrieve_file(self): def test_retrieve_file(self):
"""Test retrieving a specific file""" """Test retrieving a specific file"""
url = reverse('file-detail', args=[self.file.id]) url = reverse("file-detail", args=[self.file.id])
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['id'], str(self.file.id)) self.assertEqual(response.data["id"], str(self.file.id))
self.assertEqual(response.data['rel_name'], 'test_file.txt') self.assertEqual(response.data["rel_name"], "test_file.txt")
class TransmissionUtilsTestCase(TestCase): class TransmissionUtilsTestCase(TestCase):
@patch('torrent.utils.Client') @patch("torrent.utils.Client")
def test_transmission_init(self, mock_client): def test_transmission_init(self, mock_client):
"""Test Transmission class initialization""" """Test Transmission class initialization"""
transmission = Transmission() transmission = Transmission()
mock_client.assert_called_once_with(**settings.TRANSMISSION) mock_client.assert_called_once_with(**settings.TRANSMISSION)
@patch('torrent.utils.Client') @patch("torrent.utils.Client")
def test_add_torrent(self, mock_client): def test_add_torrent(self, mock_client):
"""Test adding a torrent""" """Test adding a torrent"""
mock_instance = mock_client.return_value mock_instance = mock_client.return_value
@@ -267,62 +235,64 @@ class TransmissionUtilsTestCase(TestCase):
mock_instance.add_torrent.assert_called_once_with(file_obj) mock_instance.add_torrent.assert_called_once_with(file_obj)
self.assertEqual(result, mock_instance.add_torrent.return_value) self.assertEqual(result, mock_instance.add_torrent.return_value)
@patch('torrent.utils.Client') @patch("torrent.utils.Client")
def test_get_data(self, mock_client): def test_get_data(self, mock_client):
"""Test getting torrent data""" """Test getting torrent data"""
mock_instance = mock_client.return_value mock_instance = mock_client.return_value
mock_torrent = MagicMock() mock_torrent = MagicMock()
mock_torrent.progress = 50 mock_torrent.progress = 50
mock_torrent.fields = {'name': 'Test', 'size': 1000} mock_torrent.fields = {"name": "Test", "size": 1000}
mock_instance.get_torrent.return_value = mock_torrent mock_instance.get_torrent.return_value = mock_torrent
transmission = Transmission() transmission = Transmission()
result = transmission.get_data('hash123') result = transmission.get_data("hash123")
mock_instance.get_torrent.assert_called_once_with('hash123', transmission.trpc_args) mock_instance.get_torrent.assert_called_once_with(
self.assertEqual(result['progress'], 50) "hash123", transmission.trpc_args
self.assertEqual(result['name'], 'Test') )
self.assertEqual(result['size'], 1000) self.assertEqual(result["progress"], 50)
self.assertEqual(result["name"], "Test")
self.assertEqual(result["size"], 1000)
class TorrentProceedTestCase(TestCase): class TorrentProceedTestCase(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(
username='testuser', username="testuser",
email='test@example.com', email="test@example.com",
password='testpassword', password="testpassword",
max_size=10000 max_size=10000,
) )
@patch('torrent.utils.transmission_handler') @patch("torrent.utils.transmission_handler")
def test_torrent_proceed_size_exceed(self, mock_transmission): def test_torrent_proceed_size_exceed(self, mock_transmission):
"""Test torrent_proceed when user size is exceeded""" """Test torrent_proceed when user size is exceeded"""
# Set user's used size to exceed max_size # Set user's used size to exceed max_size
self.user.max_size = 100 self.user.max_size = 100
Torrent.objects.create( Torrent.objects.create(
id='abc123', id="abc123",
name='Test Torrent', name="Test Torrent",
user=self.user, user=self.user,
size=200, # Exceeds max_size size=200, # Exceeds max_size
transmission_data={} transmission_data={},
) )
file_obj = MagicMock() file_obj = MagicMock()
result = torrent_proceed(self.user, file_obj) result = torrent_proceed(self.user, file_obj)
self.assertEqual(result['status'], 'error') self.assertEqual(result["status"], "error")
self.assertEqual(result['message'], 'Size exceed') self.assertEqual(result["message"], "Size exceed")
mock_transmission.add_torrent.assert_not_called() mock_transmission.add_torrent.assert_not_called()
@patch('torrent.utils.transmission_handler') @patch("torrent.utils.transmission_handler")
def test_torrent_proceed_transmission_error(self, mock_transmission): def test_torrent_proceed_transmission_error(self, mock_transmission):
"""Test torrent_proceed when transmission raises an error""" """Test torrent_proceed when transmission raises an error"""
from transmission_rpc.error import TransmissionError from transmission_rpc.error import TransmissionError
mock_transmission.add_torrent.side_effect = TransmissionError('Test error') mock_transmission.add_torrent.side_effect = TransmissionError("Test error")
file_obj = MagicMock() file_obj = MagicMock()
result = torrent_proceed(self.user, file_obj) result = torrent_proceed(self.user, file_obj)
self.assertEqual(result['status'], 'error') self.assertEqual(result["status"], "error")
self.assertEqual(result['message'], 'Transmission Error') self.assertEqual(result["message"], "Transmission Error")
+4 -2
View File
@@ -1,12 +1,14 @@
from django.urls import path from django.urls import path
from .views import HomeView, download_file, download_torrent, pping, flux_file from .views import HomeView, download_file, download_torrent, flux_file, pping
app_name = "torrent" app_name = "torrent"
urlpatterns = [ urlpatterns = [
path("", HomeView.as_view(), name="home"), path("", HomeView.as_view(), name="home"),
path("pping/", pping, name="pping"), path("pping/", pping, name="pping"),
path("download_file/<uuid:file_id>", download_file, name="download_file"), path("download_file/<uuid:file_id>", download_file, name="download_file"),
path("download_torrent/<str:torrent_id>", download_torrent, name="download_torrent"), path(
"download_torrent/<str:torrent_id>", download_torrent, name="download_torrent"
),
path("flux_file/<uuid:file_id>", flux_file, name="flux_file"), path("flux_file/<uuid:file_id>", flux_file, name="flux_file"),
] ]
+100 -26
View File
@@ -1,26 +1,65 @@
from django.conf import settings
import traceback
import base64 import base64
import io import io
import os
import traceback
from django.conf import settings
from transmission_rpc import Client from transmission_rpc import Client
from transmission_rpc.error import TransmissionError from transmission_rpc.error import TransmissionError
# from app.utils import send_sync_channel_message
from .models import Torrent, File
from user.models import User from user.models import User
# from app.utils import send_sync_channel_message
from .models import File, Torrent
class Transmission: class Transmission:
trpc_args = [ trpc_args = [
"id", "percentDone", "uploadRatio", "rateUpload", "rateDownload", "hashString", "status", "sizeWhenDone", "id",
"leftUntilDone", "name", "eta", "totalSize", "uploadedEver", "peersGettingFromUs", "peersSendingToUs", "percentDone",
"tracker", "trackerStats", "activityDate" "uploadRatio",
"rateUpload",
"rateDownload",
"hashString",
"status",
"sizeWhenDone",
"leftUntilDone",
"name",
"eta",
"totalSize",
"uploadedEver",
"peersGettingFromUs",
"peersSendingToUs",
"tracker",
"trackerStats",
"activityDate",
] ]
def __init__(self): def __init__(self):
self.client = Client(**settings.TRANSMISSION) self.client = Client(**settings.TRANSMISSION)
self.update_vpn_port()
def update_vpn_port(self):
"""Lit le port forwarded par Gluetun et met à jour Transmission si nécessaire"""
port_file = "/tmp/gluetun/forwarded_port"
if os.path.exists(port_file):
try:
with open(port_file) as f:
content = f.read().strip()
if (
not content
): # Si le fichier est vide, on attend la prochaine itération
return
vpn_port = int(content)
# Récupère le port actuel configuré dans Transmission
current_settings = self.client.get_session()
if current_settings.peer_port != vpn_port:
self.client.set_session(peer_port=vpn_port)
# Optionnel: loguer le changement
print(f"Transmission peer-port updated to {vpn_port}")
except Exception as e:
print(f"Error updating Transmission port: {e}")
def add_torrent(self, file, file_mode="file_object"): def add_torrent(self, file, file_mode="file_object"):
match file_mode: match file_mode:
@@ -35,15 +74,15 @@ class Transmission:
def get_data(self, hash_string): def get_data(self, hash_string):
data = self.client.get_torrent(hash_string, self.trpc_args) data = self.client.get_torrent(hash_string, self.trpc_args)
return { return {"progress": data.progress, "status_str": data.status, **data.fields}
"progress": data.progress,
"status_str": data.status,
**data.fields
}
def get_all_data(self, hash_strings=None): def get_all_data(self, hash_strings=None):
return { return {
data.hashString: {"progress": data.progress, "status_str": data.status, **data.fields} data.hashString: {
"progress": data.progress,
"status_str": data.status,
**data.fields,
}
for data in self.client.get_torrents(hash_strings, self.trpc_args) for data in self.client.get_torrents(hash_strings, self.trpc_args)
} }
@@ -53,16 +92,40 @@ class Transmission:
def delete(self, hash_string): def delete(self, hash_string):
return self.client.remove_torrent(hash_string, delete_data=True) return self.client.remove_torrent(hash_string, delete_data=True)
def get_diagnostics(self):
"""
Retourne des informations détaillées sur l'état de Transmission et du VPN.
Utile pour le debug via le shell Django.
"""
session = self.client.get_session()
# 1. Vérification du port VPN (Lecture du fichier Gluetun)
port_file = "/tmp/gluetun/forwarded_port"
vpn_port = None
if os.path.exists(port_file):
with open(port_file) as f:
vpn_port = f.read().strip()
# 2. Test de connectivité du port (via l'API Transmission)
# Ceci demande à Transmission de vérifier si son port est ouvert sur internet
port_is_open = self.client.port_test()
return {
"transmission_version": session.version,
"config_peer_port": session.peer_port,
"gluetun_forwarded_port": vpn_port,
"port_test_success": port_is_open,
"download_dir": session.download_dir,
"rpc_version": session.rpc_version,
"peer_port_random_on_start": session.peer_port_random_on_start,
}
transmission_handler = Transmission() transmission_handler = Transmission()
def torrent_proceed(user, file, file_mode="file_object"): def torrent_proceed(user, file, file_mode="file_object"):
r = { r = {"torrent": None, "status": "error", "message": "Unexpected error"}
"torrent": None,
"status": "error",
"message": "Unexpected error"
}
user: User user: User
if user.size_used > user.max_size: if user.size_used > user.max_size:
@@ -101,16 +164,18 @@ def torrent_proceed(user, file, file_mode="file_object"):
name=data["name"], name=data["name"],
user=user, user=user,
size=data["totalSize"], size=data["totalSize"],
transmission_data=data transmission_data=data,
) )
File.objects.bulk_create([ File.objects.bulk_create(
[
File( File(
torrent=torrent, torrent=torrent,
rel_name=file.name, rel_name=file.name,
size=file.size, size=file.size,
) )
for file in transmission_handler.get_files(torrent.id) for file in transmission_handler.get_files(torrent.id)
]) ]
)
r["torrent"] = torrent r["torrent"] = torrent
r["status"] = "success" r["status"] = "success"
@@ -119,13 +184,22 @@ def torrent_proceed(user, file, file_mode="file_object"):
def torrent_share(torrent, current_user, target_user_id): def torrent_share(torrent, current_user, target_user_id):
from .models import Torrent, SharedUser from .models import SharedUser
torrent: Torrent torrent: Torrent
if (torrent.user_id != target_user_id and if (
any([torrent.user == current_user, torrent.shared_users.filter(id=current_user.id)]) and torrent.user_id != target_user_id
not SharedUser.objects.filter(torrent_id=torrent.id, user_id=target_user_id).exists()): and any(
[
torrent.user == current_user,
torrent.shared_users.filter(id=current_user.id),
]
)
and not SharedUser.objects.filter(
torrent_id=torrent.id, user_id=target_user_id
).exists()
):
torrent.shared_users.add(target_user_id) torrent.shared_users.add(target_user_id)
return True return True
return False return False
+35 -29
View File
@@ -1,21 +1,20 @@
import anyio
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, OuterRef, Q, Sum
from django.db.models.functions import Coalesce
from django.http import Http404, HttpResponse, StreamingHttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q, Count, OuterRef, Sum
from django.db.models.functions import Coalesce
from django.http import HttpResponse, Http404, StreamingHttpResponse
from rest_framework.viewsets import GenericViewSet
from rest_framework import mixins from rest_framework import mixins
from rest_framework.response import Response
from rest_framework.decorators import action from rest_framework.decorators import action
import anyio from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from app.utils import StreamingZipFileResponse from app.utils import StreamingZipFileResponse
from user.models import User
from .models import Torrent, File, SharedUser from .models import File, SharedUser, Torrent
from .serializers import TorrentSerializer, FileSerializer from .serializers import FileSerializer, TorrentSerializer
from .utils import torrent_proceed, torrent_share from .utils import torrent_proceed, torrent_share
@@ -35,7 +34,7 @@ async def download_file(request, file_id):
| Q(torrent__user__friends=user) | Q(torrent__user__friends=user)
| Q(torrent__shared_users__friends=user), | Q(torrent__shared_users__friends=user),
torrent__transmission_data__progress__gte=100, torrent__transmission_data__progress__gte=100,
pk=file_id pk=file_id,
).distinct() ).distinct()
try: try:
@@ -44,10 +43,12 @@ async def download_file(request, file_id):
raise Http404() raise Http404()
else: else:
if int(request.GET.get("dl_hotfix", 0)) == 1: if int(request.GET.get("dl_hotfix", 0)) == 1:
async def read_file(): async def read_file():
async with await anyio.open_file(file.abs_pathname, "rb") as f: async with await anyio.open_file(file.abs_pathname, "rb") as f:
while chunk := await f.read(128 * 1024): while chunk := await f.read(128 * 1024):
yield chunk yield chunk
response = StreamingHttpResponse(read_file()) response = StreamingHttpResponse(read_file())
response["Content-Length"] = file.size response["Content-Length"] = file.size
response["Content-Type"] = "application/octet-stream" response["Content-Type"] = "application/octet-stream"
@@ -86,7 +87,7 @@ async def secured_flux_file(request, file_id):
| Q(torrent__user__friends=user) | Q(torrent__user__friends=user)
| Q(torrent__shared_users__friends=user), | Q(torrent__shared_users__friends=user),
torrent__transmission_data__progress__gte=100, torrent__transmission_data__progress__gte=100,
pk=file_id pk=file_id,
).distinct() ).distinct()
try: try:
@@ -105,39 +106,42 @@ async def secured_flux_file(request, file_id):
async def download_torrent(request, torrent_id): async def download_torrent(request, torrent_id):
# py version # py version
user = await request.auser() user = await request.auser()
qs = Torrent.objects.filter( qs = (
Torrent.objects.filter(
Q(user=user) Q(user=user)
| Q(shared_users=user) | Q(shared_users=user)
| Q(user__friends=user) | Q(user__friends=user)
| Q(shared_users__friends=user), | Q(shared_users__friends=user),
transmission_data__progress__gte=100, transmission_data__progress__gte=100,
pk=torrent_id pk=torrent_id,
).annotate(count_files=Count("files")).distinct() )
.annotate(count_files=Count("files"))
.distinct()
)
torrent = await qs.aget() torrent = await qs.aget()
if await torrent.alen_files == 1: if await torrent.alen_files == 1:
file = await torrent.files.afirst() file = await torrent.files.afirst()
return redirect(reverse("torrent:download_file", kwargs={ return redirect(reverse("torrent:download_file", kwargs={"file_id": file.pk}))
"file_id": file.pk
}))
response = StreamingZipFileResponse( response = StreamingZipFileResponse(
filename=f"{torrent.name}.zip", filename=f"{torrent.name}.zip",
file_list=[ file_list=[
(file.abs_pathname, file.rel_name) (file.abs_pathname, file.rel_name) async for file in torrent.files.all()
async for file in torrent.files.all()
], ],
is_async=True is_async=True,
) )
return response return response
class TorrentViewSet(mixins.CreateModelMixin, class TorrentViewSet(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
GenericViewSet): GenericViewSet,
):
queryset = Torrent.objects.all().annotate(count_files=Count("files")) queryset = Torrent.objects.all().annotate(count_files=Count("files"))
serializer_class = TorrentSerializer serializer_class = TorrentSerializer
@@ -158,7 +162,9 @@ class TorrentViewSet(mixins.CreateModelMixin,
else: else:
user_id = self.request.user.id user_id = self.request.user.id
sub = SharedUser.objects.filter(torrent_id=OuterRef("pk"), user_id=user_id).values("date_created") sub = SharedUser.objects.filter(
torrent_id=OuterRef("pk"), user_id=user_id
).values("date_created")
qs = qs.annotate(last_date=Coalesce(sub, "date_created")).order_by("-last_date") qs = qs.annotate(last_date=Coalesce(sub, "date_created")).order_by("-last_date")
search = self.request.query_params.get("search", None) search = self.request.query_params.get("search", None)
@@ -188,7 +194,9 @@ class TorrentViewSet(mixins.CreateModelMixin,
def share(self, request, pk): def share(self, request, pk):
user_id = self.request.data.get("user_id") user_id = self.request.data.get("user_id")
torrent = self.get_object() torrent = self.get_object()
is_share_success = torrent_share(torrent=torrent, current_user=self.request.user, target_user_id=user_id) is_share_success = torrent_share(
torrent=torrent, current_user=self.request.user, target_user_id=user_id
)
return Response({"success": is_share_success}) return Response({"success": is_share_success})
@action(methods=["get"], detail=False) @action(methods=["get"], detail=False)
@@ -196,9 +204,7 @@ class TorrentViewSet(mixins.CreateModelMixin,
Torrent.objects.filter(user=self.request.user).aggregate(total_size=Sum("size")) Torrent.objects.filter(user=self.request.user).aggregate(total_size=Sum("size"))
class FileViewSet(mixins.RetrieveModelMixin, class FileViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, GenericViewSet):
mixins.ListModelMixin,
GenericViewSet):
queryset = File.objects.all() queryset = File.objects.all()
serializer_class = FileSerializer serializer_class = FileSerializer
filterset_fields = ["torrent"] filterset_fields = ["torrent"]
+20 -10
View File
@@ -2,8 +2,8 @@ from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.template.defaultfilters import filesizeformat from django.template.defaultfilters import filesizeformat
from .forms import UserCreationForm, UserChangeForm from .forms import UserChangeForm
from .models import User, FriendRequest, Invitation from .models import FriendRequest, Invitation, User
@admin.register(User) @admin.register(User)
@@ -12,26 +12,36 @@ class UserAdmin(BaseUserAdmin):
# add_form = UserCreationForm # add_form = UserCreationForm
form = UserChangeForm form = UserChangeForm
fieldsets = BaseUserAdmin.fieldsets + ( fieldsets = BaseUserAdmin.fieldsets + (
["Custom Fields", { ["Custom Fields", {"fields": ["max_size", "friends"]}],
"fields": ["max_size", "friends"] )
}] list_display = [
,) "username",
list_display = ["username", "email", "is_superuser", "is_active", "is_staff", "display_max_size", "size_used"] "email",
"is_superuser",
"is_active",
"is_staff",
"display_max_size",
"size_used",
]
add_fieldsets = ( add_fieldsets = (
(None, { (
None,
{
"classes": ("wide",), "classes": ("wide",),
"fields": ("username", "email", "max_size", "password1", "password2"), "fields": ("username", "email", "max_size", "password1", "password2"),
}), },
),
) )
def display_max_size(self, obj: User): def display_max_size(self, obj: User):
return filesizeformat(obj.max_size) return filesizeformat(obj.max_size)
display_max_size.short_description = "Max size" display_max_size.short_description = "Max size"
def size_used(self, obj: User): def size_used(self, obj: User):
return filesizeformat(obj.size_used) return filesizeformat(obj.size_used)
size_used.short_description = "Size used"
size_used.short_description = "Size used"
@admin.register(Invitation) @admin.register(Invitation)
+2 -2
View File
@@ -2,5 +2,5 @@ from django.apps import AppConfig
class UserConfig(AppConfig): class UserConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = "django.db.models.BigAutoField"
name = 'user' name = "user"
+1 -1
View File
@@ -15,6 +15,7 @@ class RegisterForm(base_auth_forms.UserCreationForm):
class UserCreationForm(AdminUserCreationForm): class UserCreationForm(AdminUserCreationForm):
max_size = forms.IntegerField(required=True) max_size = forms.IntegerField(required=True)
email = forms.EmailField(required=True) email = forms.EmailField(required=True)
class Meta(base_auth_forms.UserCreationForm): class Meta(base_auth_forms.UserCreationForm):
model = User model = User
fields = base_auth_forms.BaseUserCreationForm.Meta.fields + ("max_size",) fields = base_auth_forms.BaseUserCreationForm.Meta.fields + ("max_size",)
@@ -24,4 +25,3 @@ class UserChangeForm(base_auth_forms.UserChangeForm):
class Meta: class Meta:
model = User model = User
fields = ["max_size"] fields = ["max_size"]
@@ -1,9 +1,9 @@
from django.core.management.base import BaseCommand
import json
import base64 import base64
import json
import sys import sys
from django.core.management.base import BaseCommand
from user.models import User from user.models import User
@@ -26,7 +26,9 @@ class Command(BaseCommand):
user_data = data["fields"] user_data = data["fields"]
user_pk = data["pk"] user_pk = data["pk"]
if User.objects.filter(username__iexact=user_data["username"]).exists(): if User.objects.filter(username__iexact=user_data["username"]).exists():
old_new_users_maps[user_pk] = User.objects.filter(username__iexact=user_data["username"]).get() old_new_users_maps[user_pk] = User.objects.filter(
username__iexact=user_data["username"]
).get()
else: else:
old_new_users_maps[user_pk] = User.objects.create( old_new_users_maps[user_pk] = User.objects.create(
email=user_data["email"], email=user_data["email"],
@@ -35,12 +37,14 @@ class Command(BaseCommand):
is_superuser=user_data["is_superuser"], is_superuser=user_data["is_superuser"],
username=user_data["username"], username=user_data["username"],
password=user_data["password"], password=user_data["password"],
max_size=user_data["limit_size"] max_size=user_data["limit_size"],
) )
old_friends[user_pk] = user_data["friends"] old_friends[user_pk] = user_data["friends"]
for old_user, friends in old_friends.items(): for old_user, friends in old_friends.items():
current_user = old_new_users_maps[old_user] current_user = old_new_users_maps[old_user]
for friend in friends: for friend in friends:
if not current_user.friends.filter(id=old_new_users_maps[friend].id).exists(): if not current_user.friends.filter(
id=old_new_users_maps[friend].id
).exists():
current_user.friends.add(old_new_users_maps[friend]) current_user.friends.add(old_new_users_maps[friend])
+170 -37
View File
@@ -1,72 +1,205 @@
# Generated by Django 5.1.6 on 2025-03-04 23:41 # Generated by Django 5.1.6 on 2025-03-04 23:41
import uuid
import django.contrib.auth.validators import django.contrib.auth.validators
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
import user.models
import uuid
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import user.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('auth', '0012_alter_user_first_name_max_length'), ("auth", "0012_alter_user_first_name_max_length"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='User', name="User",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('password', models.CharField(max_length=128, verbose_name='password')), "id",
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), models.BigAutoField(
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), auto_created=True,
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), primary_key=True,
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), serialize=False,
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), verbose_name="ID",
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), ),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ("password", models.CharField(max_length=128, verbose_name="password")),
('email', models.EmailField(max_length=254, unique=True)), (
('max_size', models.PositiveBigIntegerField(default=53687091200)), "last_login",
('is_trusted', models.BooleanField(default=False)), models.DateTimeField(
('friends', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), blank=True, null=True, verbose_name="last login"
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), ),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
("email", models.EmailField(max_length=254, unique=True)),
("max_size", models.PositiveBigIntegerField(default=53687091200)),
("is_trusted", models.BooleanField(default=False)),
(
"friends",
models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
], ],
options={ options={
'verbose_name': 'user', "verbose_name": "user",
'verbose_name_plural': 'users', "verbose_name_plural": "users",
'abstract': False, "abstract": False,
}, },
managers=[ managers=[
('objects', user.models.UsernameUserManager()), ("objects", user.models.UsernameUserManager()),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Invitation', name="Invitation",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('token', models.UUIDField(default=uuid.uuid4)), "id",
('date_created', models.DateTimeField(auto_now_add=True)), models.BigAutoField(
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)), auto_created=True,
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitation', to=settings.AUTH_USER_MODEL)), primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("token", models.UUIDField(default=uuid.uuid4)),
("date_created", models.DateTimeField(auto_now_add=True)),
(
"created_by",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="invitations",
to=settings.AUTH_USER_MODEL,
),
),
(
"user",
models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="invitation",
to=settings.AUTH_USER_MODEL,
),
),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='FriendRequest', name="FriendRequest",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('date', models.DateTimeField(auto_now_add=True)), "id",
('receiver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='friend_request_receives', to=settings.AUTH_USER_MODEL)), models.BigAutoField(
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='friend_request_sends', to=settings.AUTH_USER_MODEL)), auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date", models.DateTimeField(auto_now_add=True)),
(
"receiver",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="friend_request_receives",
to=settings.AUTH_USER_MODEL,
),
),
(
"sender",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="friend_request_sends",
to=settings.AUTH_USER_MODEL,
),
),
], ],
options={ options={
'unique_together': {('sender', 'receiver')}, "unique_together": {("sender", "receiver")},
}, },
), ),
] ]
+17 -8
View File
@@ -1,9 +1,10 @@
from django.db import models
from django.contrib.auth.models import AbstractUser, BaseUserManager
from django.db.models import Sum
import uuid import uuid
from functools import cached_property from functools import cached_property
from django.contrib.auth.models import AbstractUser, BaseUserManager
from django.db import models
from django.db.models import Sum
from torrent.models import Torrent from torrent.models import Torrent
@@ -53,7 +54,9 @@ class User(AbstractUser):
if hasattr(self, "total_size"): if hasattr(self, "total_size"):
return self.total_size return self.total_size
else: else:
return Torrent.objects.filter(user=self).aggregate(total_size=Sum("size", default=0))["total_size"] return Torrent.objects.filter(user=self).aggregate(
total_size=Sum("size", default=0)
)["total_size"]
@property @property
def min_infos(self): def min_infos(self):
@@ -61,8 +64,12 @@ class User(AbstractUser):
class FriendRequest(models.Model): class FriendRequest(models.Model):
sender = models.ForeignKey("User", on_delete=models.CASCADE, related_name="friend_request_sends") sender = models.ForeignKey(
receiver = models.ForeignKey("User", on_delete=models.CASCADE, related_name="friend_request_receives") "User", on_delete=models.CASCADE, related_name="friend_request_sends"
)
receiver = models.ForeignKey(
"User", on_delete=models.CASCADE, related_name="friend_request_receives"
)
date = models.DateTimeField(auto_now_add=True) date = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
@@ -72,5 +79,7 @@ class FriendRequest(models.Model):
class Invitation(models.Model): class Invitation(models.Model):
created_by = models.ForeignKey("User", models.CASCADE, related_name="invitations") created_by = models.ForeignKey("User", models.CASCADE, related_name="invitations")
token = models.UUIDField(default=uuid.uuid4) token = models.UUIDField(default=uuid.uuid4)
user = models.OneToOneField("User", models.CASCADE, related_name="invitation", null=True, blank=True) user = models.OneToOneField(
"User", models.CASCADE, related_name="invitation", null=True, blank=True
)
date_created = models.DateTimeField(auto_now_add=True) date_created = models.DateTimeField(auto_now_add=True)
+1 -1
View File
@@ -1,6 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from .models import User, FriendRequest, Invitation from .models import FriendRequest, Invitation, User
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
+80 -115
View File
@@ -1,26 +1,25 @@
from unittest.mock import MagicMock, patch
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from rest_framework.test import APITestCase, APIClient
from rest_framework import status from rest_framework import status
from unittest.mock import patch, MagicMock from rest_framework.test import APIClient, APITestCase
from .models import User, FriendRequest, Invitation, UsernameUserManager
from torrent.models import Torrent from torrent.models import Torrent
from .views import UserViewSet, FriendRequestViewSet
from .models import FriendRequest, Invitation, User
class UserModelTestCase(TestCase): class UserModelTestCase(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(
username='testuser', username="testuser",
email='test@example.com', email="test@example.com",
password='testpassword', password="testpassword",
max_size=1000000 max_size=1000000,
) )
self.friend = User.objects.create_user( self.friend = User.objects.create_user(
username='frienduser', username="frienduser", email="friend@example.com", password="friendpassword"
email='friend@example.com',
password='friendpassword'
) )
def test_size_used_property(self): def test_size_used_property(self):
@@ -30,35 +29,32 @@ class UserModelTestCase(TestCase):
# Create a torrent for the user # Create a torrent for the user
Torrent.objects.create( Torrent.objects.create(
id='abc123', id="abc123",
name='Test Torrent', name="Test Torrent",
user=self.user, user=self.user,
size=5000, size=5000,
transmission_data={} transmission_data={},
) )
# Create another torrent # Create another torrent
Torrent.objects.create( Torrent.objects.create(
id='def456', id="def456",
name='Another Torrent', name="Another Torrent",
user=self.user, user=self.user,
size=3000, size=3000,
transmission_data={} transmission_data={},
) )
# Clear cached_property if it exists # Clear cached_property if it exists
if hasattr(self.user, 'total_size'): if hasattr(self.user, "total_size"):
delattr(self.user, 'total_size') delattr(self.user, "total_size")
# Size used should be the sum of torrent sizes # Size used should be the sum of torrent sizes
self.assertEqual(self.user.size_used, 8000) self.assertEqual(self.user.size_used, 8000)
def test_min_infos_property(self): def test_min_infos_property(self):
"""Test the min_infos property returns the correct user info""" """Test the min_infos property returns the correct user info"""
expected_info = { expected_info = {"username": "testuser", "id": self.user.id}
'username': 'testuser',
'id': self.user.id
}
self.assertEqual(self.user.min_infos, expected_info) self.assertEqual(self.user.min_infos, expected_info)
@@ -66,111 +62,86 @@ class UsernameUserManagerTestCase(TestCase):
def test_create_user(self): def test_create_user(self):
"""Test creating a regular user""" """Test creating a regular user"""
user = User.objects.create_user( user = User.objects.create_user(
username='newuser', username="newuser", email="new@example.com", password="newpassword"
email='new@example.com',
password='newpassword'
) )
self.assertFalse(user.is_staff) self.assertFalse(user.is_staff)
self.assertFalse(user.is_superuser) self.assertFalse(user.is_superuser)
self.assertEqual(user.username, 'newuser') self.assertEqual(user.username, "newuser")
self.assertEqual(user.email, 'new@example.com') self.assertEqual(user.email, "new@example.com")
self.assertTrue(user.check_password('newpassword')) self.assertTrue(user.check_password("newpassword"))
def test_create_superuser(self): def test_create_superuser(self):
"""Test creating a superuser""" """Test creating a superuser"""
admin = User.objects.create_superuser( admin = User.objects.create_superuser(
username='admin', username="admin", email="admin@example.com", password="adminpassword"
email='admin@example.com',
password='adminpassword'
) )
self.assertTrue(admin.is_staff) self.assertTrue(admin.is_staff)
self.assertTrue(admin.is_superuser) self.assertTrue(admin.is_superuser)
self.assertEqual(admin.username, 'admin') self.assertEqual(admin.username, "admin")
self.assertEqual(admin.email, 'admin@example.com') self.assertEqual(admin.email, "admin@example.com")
def test_create_user_without_username(self): def test_create_user_without_username(self):
"""Test creating a user without username raises error""" """Test creating a user without username raises error"""
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
User.objects.create_user( User.objects.create_user(
username='', username="", email="test@example.com", password="testpassword"
email='test@example.com',
password='testpassword'
) )
def test_create_user_without_email(self): def test_create_user_without_email(self):
"""Test creating a user without email raises error""" """Test creating a user without email raises error"""
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
User.objects.create_user( User.objects.create_user(
username='testuser', username="testuser", email="", password="testpassword"
email='',
password='testpassword'
) )
class FriendRequestModelTestCase(TestCase): class FriendRequestModelTestCase(TestCase):
def setUp(self): def setUp(self):
self.sender = User.objects.create_user( self.sender = User.objects.create_user(
username='sender', username="sender", email="sender@example.com", password="senderpassword"
email='sender@example.com',
password='senderpassword'
) )
self.receiver = User.objects.create_user( self.receiver = User.objects.create_user(
username='receiver', username="receiver",
email='receiver@example.com', email="receiver@example.com",
password='receiverpassword' password="receiverpassword",
) )
def test_friend_request_creation(self): def test_friend_request_creation(self):
"""Test creating a friend request""" """Test creating a friend request"""
friend_request = FriendRequest.objects.create( friend_request = FriendRequest.objects.create(
sender=self.sender, sender=self.sender, receiver=self.receiver
receiver=self.receiver
) )
self.assertEqual(friend_request.sender, self.sender) self.assertEqual(friend_request.sender, self.sender)
self.assertEqual(friend_request.receiver, self.receiver) self.assertEqual(friend_request.receiver, self.receiver)
def test_unique_together_constraint(self): def test_unique_together_constraint(self):
"""Test that the unique_together constraint works""" """Test that the unique_together constraint works"""
FriendRequest.objects.create( FriendRequest.objects.create(sender=self.sender, receiver=self.receiver)
sender=self.sender,
receiver=self.receiver
)
# Creating another request with the same sender and receiver should raise an error # Creating another request with the same sender and receiver should raise an error
with self.assertRaises(Exception): with self.assertRaises(Exception):
FriendRequest.objects.create( FriendRequest.objects.create(sender=self.sender, receiver=self.receiver)
sender=self.sender,
receiver=self.receiver
)
class InvitationModelTestCase(TestCase): class InvitationModelTestCase(TestCase):
def setUp(self): def setUp(self):
self.creator = User.objects.create_user( self.creator = User.objects.create_user(
username='creator', username="creator", email="creator@example.com", password="creatorpassword"
email='creator@example.com',
password='creatorpassword'
) )
def test_invitation_creation(self): def test_invitation_creation(self):
"""Test creating an invitation""" """Test creating an invitation"""
invitation = Invitation.objects.create( invitation = Invitation.objects.create(created_by=self.creator)
created_by=self.creator
)
self.assertEqual(invitation.created_by, self.creator) self.assertEqual(invitation.created_by, self.creator)
self.assertIsNotNone(invitation.token) self.assertIsNotNone(invitation.token)
self.assertIsNone(invitation.user) self.assertIsNone(invitation.user)
def test_invitation_assignment(self): def test_invitation_assignment(self):
"""Test assigning an invitation to a user""" """Test assigning an invitation to a user"""
invitation = Invitation.objects.create( invitation = Invitation.objects.create(created_by=self.creator)
created_by=self.creator
)
new_user = User.objects.create_user( new_user = User.objects.create_user(
username='newuser', username="newuser", email="new@example.com", password="newpassword"
email='new@example.com',
password='newpassword'
) )
invitation.user = new_user invitation.user = new_user
@@ -184,133 +155,127 @@ class InvitationModelTestCase(TestCase):
class UserViewSetTestCase(APITestCase): class UserViewSetTestCase(APITestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(
username='testuser', username="testuser", email="test@example.com", password="testpassword"
email='test@example.com',
password='testpassword'
) )
self.friend = User.objects.create_user( self.friend = User.objects.create_user(
username='frienduser', username="frienduser", email="friend@example.com", password="friendpassword"
email='friend@example.com',
password='friendpassword'
) )
self.client = APIClient() self.client = APIClient()
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
def test_list_users(self): def test_list_users(self):
"""Test listing users""" """Test listing users"""
url = reverse('user-list') url = reverse("user-list")
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2) # Should include both users self.assertEqual(len(response.data), 2) # Should include both users
def test_retrieve_user(self): def test_retrieve_user(self):
"""Test retrieving a specific user""" """Test retrieving a specific user"""
url = reverse('user-detail', args=[self.friend.id]) url = reverse("user-detail", args=[self.friend.id])
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['username'], 'frienduser') self.assertEqual(response.data["username"], "frienduser")
def test_add_friend_request(self): def test_add_friend_request(self):
"""Test adding a friend request""" """Test adding a friend request"""
url = reverse('user-add-friend-request', args=[self.friend.username]) url = reverse("user-add-friend-request", args=[self.friend.username])
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data['success']) self.assertTrue(response.data["success"])
self.assertEqual(response.data['message'], 'Request sent') self.assertEqual(response.data["message"], "Request sent")
# Verify the friend request was created # Verify the friend request was created
self.assertTrue(FriendRequest.objects.filter( self.assertTrue(
sender=self.user, FriendRequest.objects.filter(
receiver=self.friend sender=self.user, receiver=self.friend
).exists()) ).exists()
)
def test_add_friend_request_nonexistent_user(self): def test_add_friend_request_nonexistent_user(self):
"""Test adding a friend request to a nonexistent user""" """Test adding a friend request to a nonexistent user"""
url = reverse('user-add-friend-request', args=['nonexistentuser']) url = reverse("user-add-friend-request", args=["nonexistentuser"])
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(response.data['success']) self.assertFalse(response.data["success"])
self.assertEqual(response.data['message'], "User 'nonexistentuser' doesn't exist") self.assertEqual(
response.data["message"], "User 'nonexistentuser' doesn't exist"
)
def test_remove_friend(self): def test_remove_friend(self):
"""Test removing a friend""" """Test removing a friend"""
# First add as friend # First add as friend
self.user.friends.add(self.friend) self.user.friends.add(self.friend)
url = reverse('user-remove-friend', args=[self.friend.id]) url = reverse("user-remove-friend", args=[self.friend.id])
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data['success']) self.assertTrue(response.data["success"])
# Verify the friend was removed # Verify the friend was removed
self.assertFalse(self.user.friends.filter(id=self.friend.id).exists()) self.assertFalse(self.user.friends.filter(id=self.friend.id).exists())
@patch('user.views.shutil.disk_usage') @patch("user.views.shutil.disk_usage")
def test_user_stats(self, mock_disk_usage): def test_user_stats(self, mock_disk_usage):
"""Test getting user stats""" """Test getting user stats"""
# Mock disk_usage return value # Mock disk_usage return value
mock_disk_usage.return_value = MagicMock( mock_disk_usage.return_value = MagicMock(
total=1000000, total=1000000, used=500000, free=500000
used=500000,
free=500000
) )
# Create torrents for the user # Create torrents for the user
Torrent.objects.create( Torrent.objects.create(
id='abc123', id="abc123",
name='Test Torrent', name="Test Torrent",
user=self.user, user=self.user,
size=5000, size=5000,
transmission_data={} transmission_data={},
) )
url = reverse('user-user-stats') url = reverse("user-user-stats")
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
# Check that the response contains the expected fields # Check that the response contains the expected fields
self.assertIn('torrents_size', response.data) self.assertIn("torrents_size", response.data)
self.assertIn('torrents_len', response.data) self.assertIn("torrents_len", response.data)
self.assertIn('user_max_size', response.data) self.assertIn("user_max_size", response.data)
self.assertIn('disk_total', response.data) self.assertIn("disk_total", response.data)
self.assertIn('disk_used', response.data) self.assertIn("disk_used", response.data)
self.assertIn('disk_free', response.data) self.assertIn("disk_free", response.data)
class FriendRequestViewSetTestCase(APITestCase): class FriendRequestViewSetTestCase(APITestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(
username='testuser', username="testuser", email="test@example.com", password="testpassword"
email='test@example.com',
password='testpassword'
) )
self.sender = User.objects.create_user( self.sender = User.objects.create_user(
username='sender', username="sender", email="sender@example.com", password="senderpassword"
email='sender@example.com',
password='senderpassword'
) )
self.client = APIClient() self.client = APIClient()
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
# Create a friend request # Create a friend request
self.friend_request = FriendRequest.objects.create( self.friend_request = FriendRequest.objects.create(
sender=self.sender, sender=self.sender, receiver=self.user
receiver=self.user
) )
def test_list_friend_requests(self): def test_list_friend_requests(self):
"""Test listing friend requests""" """Test listing friend requests"""
url = reverse('friendrequest-list') url = reverse("friendrequest-list")
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1) self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['sender']['username'], 'sender') self.assertEqual(response.data[0]["sender"]["username"], "sender")
def test_delete_friend_request(self): def test_delete_friend_request(self):
"""Test deleting a friend request""" """Test deleting a friend request"""
url = reverse('friendrequest-detail', args=[self.friend_request.id]) url = reverse("friendrequest-detail", args=[self.friend_request.id])
response = self.client.delete(url) response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
# Verify the friend request was deleted # Verify the friend request was deleted
self.assertFalse(FriendRequest.objects.filter(id=self.friend_request.id).exists()) self.assertFalse(
FriendRequest.objects.filter(id=self.friend_request.id).exists()
)
+1 -1
View File
@@ -1,6 +1,6 @@
from django.urls import path from django.urls import path
from .views import UserLoginView, RegisterView from .views import RegisterView, UserLoginView
app_name = "user" app_name = "user"
urlpatterns = [ urlpatterns = [
+42 -30
View File
@@ -1,20 +1,19 @@
from django.contrib.auth.views import LoginView import shutil
from django.views.generic import CreateView
from django.contrib.auth import login
from django.urls import reverse_lazy
from django.db.models import Count, Sum, F, IntegerField
from django.db.models.functions import Coalesce
from rest_framework.viewsets import ModelViewSet, GenericViewSet from django.contrib.auth import login
from django.contrib.auth.views import LoginView
from django.db.models import Count, Sum
from django.db.models.functions import Coalesce
from django.urls import reverse_lazy
from django.views.generic import CreateView
from rest_framework import mixins from rest_framework import mixins
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
import shutil from rest_framework.viewsets import GenericViewSet
from .models import User, FriendRequest, Invitation
from .forms import RegisterForm from .forms import RegisterForm
from .serializers import UserSerializer, FriendRequestSerializer, InvitationSerializer from .models import FriendRequest, Invitation, User
from .serializers import FriendRequestSerializer, UserSerializer
class UserLoginView(LoginView): class UserLoginView(LoginView):
@@ -31,7 +30,9 @@ class RegisterView(CreateView):
invitation = None invitation = None
def get_form(self, form_class=None): def get_form(self, form_class=None):
self.invitation = Invitation.objects.get(token=self.kwargs.get("token"), user__isnull=True) self.invitation = Invitation.objects.get(
token=self.kwargs.get("token"), user__isnull=True
)
return super().get_form(form_class) return super().get_form(form_class)
def form_valid(self, form): def form_valid(self, form):
@@ -42,9 +43,7 @@ class RegisterView(CreateView):
return r return r
class UserViewSet(mixins.RetrieveModelMixin, class UserViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, GenericViewSet):
mixins.ListModelMixin,
GenericViewSet):
queryset = User.objects.all().annotate( queryset = User.objects.all().annotate(
count_torrent=Count("torrents") + Count("torrents_shares") count_torrent=Count("torrents") + Count("torrents_shares")
) )
@@ -72,18 +71,22 @@ class UserViewSet(mixins.RetrieveModelMixin,
return Response({"success": False, "message": "Already friend"}) return Response({"success": False, "message": "Already friend"})
elif FriendRequest.objects.filter(sender=user, receiver=receiver).exists(): elif FriendRequest.objects.filter(sender=user, receiver=receiver).exists():
# déjà une demande en attente # déjà une demande en attente
return Response({"success": False, "message": "Friend request Already sent"}) return Response(
{"success": False, "message": "Friend request Already sent"}
)
elif FriendRequest.objects.filter(sender=receiver, receiver=user).exists(): elif FriendRequest.objects.filter(sender=receiver, receiver=user).exists():
# friend request en cours, on accepte # friend request en cours, on accepte
FriendRequest.objects.filter(sender=receiver, receiver=user).delete() FriendRequest.objects.filter(sender=receiver, receiver=user).delete()
user.friends.add(receiver) user.friends.add(receiver)
return Response({"success": True, "message": f"{receiver.username} added to your friend list"}) return Response(
{
"success": True,
"message": f"{receiver.username} added to your friend list",
}
)
else: else:
# aucune demande en cours, on créer un friend request # aucune demande en cours, on créer un friend request
FriendRequest.objects.create( FriendRequest.objects.create(sender=user, receiver=receiver)
sender=user,
receiver=receiver
)
return Response({"success": True, "message": "Request sent"}) return Response({"success": True, "message": "Request sent"})
@action(methods=["get"], detail=True) @action(methods=["get"], detail=True)
@@ -91,8 +94,13 @@ class UserViewSet(mixins.RetrieveModelMixin,
friend = User.objects.get(pk=pk) friend = User.objects.get(pk=pk)
if self.request.user.friends.filter(pk=friend.pk).exists(): if self.request.user.friends.filter(pk=friend.pk).exists():
self.request.user.friends.remove(friend) self.request.user.friends.remove(friend)
return Response({"success": True, "message": f"The friend {friend.username} successfully removed"}) return Response(
return Response({"success": False, "message": f"error"}) {
"success": True,
"message": f"The friend {friend.username} successfully removed",
}
)
return Response({"success": False, "message": "error"})
@action(methods=["get"], detail=False) @action(methods=["get"], detail=False)
def user_stats(self, request): def user_stats(self, request):
@@ -104,23 +112,27 @@ class UserViewSet(mixins.RetrieveModelMixin,
disk_usage = shutil.disk_usage("/") disk_usage = shutil.disk_usage("/")
return Response({ return Response(
{
"torrents_size": stats["total_size"], "torrents_size": stats["total_size"],
"torrents_len": stats["total_torrent"], "torrents_len": stats["total_torrent"],
"torrent_len_shared": stats["total_shared_torrent"], "torrent_len_shared": stats["total_shared_torrent"],
"torrents_total_len": stats["total_torrent"] + stats["total_shared_torrent"], "torrents_total_len": stats["total_torrent"]
+ stats["total_shared_torrent"],
"user_max_size": request.user.max_size, "user_max_size": request.user.max_size,
"user_usage_percent": (stats["total_size"] / request.user.max_size) * 100, "user_usage_percent": (stats["total_size"] / request.user.max_size)
* 100,
"disk_total": disk_usage.total, "disk_total": disk_usage.total,
"disk_used": disk_usage.used, "disk_used": disk_usage.used,
"disk_free": disk_usage.free, "disk_free": disk_usage.free,
"disk_usage_percent": (disk_usage.used / disk_usage.total) * 100, "disk_usage_percent": (disk_usage.used / disk_usage.total) * 100,
}) }
)
class FriendRequestViewSet(mixins.ListModelMixin, class FriendRequestViewSet(
mixins.DestroyModelMixin, mixins.ListModelMixin, mixins.DestroyModelMixin, GenericViewSet
GenericViewSet): ):
queryset = FriendRequest.objects.all() queryset = FriendRequest.objects.all()
serializer_class = FriendRequestSerializer serializer_class = FriendRequestSerializer
-2
View File
@@ -1,3 +1 @@
from django.contrib import admin
# Register your models here. # Register your models here.
+2 -2
View File
@@ -2,5 +2,5 @@ from django.apps import AppConfig
class WatchPartyConfig(AppConfig): class WatchPartyConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = "django.db.models.BigAutoField"
name = 'watch_party' name = "watch_party"
+3 -1
View File
@@ -6,6 +6,8 @@ from django.db import models
class Room(models.Model): class Room(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4) id = models.UUIDField(primary_key=True, default=uuid.uuid4)
date_created = models.DateTimeField(auto_now_add=True) date_created = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey("user.User", on_delete=models.CASCADE, related_name="rooms_created") created_by = models.ForeignKey(
"user.User", on_delete=models.CASCADE, related_name="rooms_created"
)
users = models.ManyToManyField("user.User", related_name="rooms") users = models.ManyToManyField("user.User", related_name="rooms")
all_admin = models.BooleanField(default=False) all_admin = models.BooleanField(default=False)
-2
View File
@@ -1,3 +1 @@
from django.test import TestCase
# Create your tests here. # Create your tests here.
-2
View File
@@ -1,3 +1 @@
from django.shortcuts import render
# Create your views here. # Create your views here.
@@ -6,6 +6,8 @@ services:
restart: "no" restart: "no"
volumes: volumes:
- ./nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro - ./nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro
ports:
- "${LISTEN_PORT:-8000}:80"
transmission: transmission:
restart: "no" restart: "no"
@@ -13,20 +15,29 @@ services:
app: app:
build: build:
args: dockerfile: .docker/app/Dockerfile.dev
debug: true
restart: "no" restart: "no"
ports: ports:
- "${DEV_SERVER_PORT:-8080}:${DEV_SERVER_PORT:-8080}" - "${DEV_SERVER_PORT:-8080}:${DEV_SERVER_PORT:-8080}"
labels: !reset []
healthcheck:
disable: true
command: > command: >
bash -c "sleep 2 bash -c "sleep 2
&& python manage.py migrate && python manage.py migrate
&& ./dev_run.sh" && ./dev_run.sh"
# celery:
# restart: "no"
# command: >
# bash -c "sleep 5 & celery -A app worker -E -B"
event: event:
extends:
service: app
restart: "no" restart: "no"
ports: !reset []
command: >
bash -c "sleep 2 && python manage.py torrent_event"
gluetun:
restart: "no"
networks:
web:
external: false
+41 -31
View File
@@ -28,8 +28,6 @@ services:
# Port du container cible (le port d'écoute de Nginx à lintérieur du conteneur) # Port du container cible (le port d'écoute de Nginx à lintérieur du conteneur)
- "traefik.http.services.web.loadbalancer.server.port=80" - "traefik.http.services.web.loadbalancer.server.port=80"
ports:
- "${LISTEN_PORT:-8000}:80"
restart: unless-stopped restart: unless-stopped
networks: networks:
- web - web
@@ -37,15 +35,35 @@ services:
depends_on: depends_on:
- app - app
gluetun:
image: qmcgaw/gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- VPN_SERVICE_PROVIDER=protonvpn
- VPN_TYPE=wireguard
- WIREGUARD_PRIVATE_KEY=${PROTON_PKEY}
- SERVER_COUNTRIES=${PROTON_COUNTRIES}
- VPN_PORT_FORWARDING=on
- VPN_PORT_FORWARDING_PROVIDER=protonvpn
networks:
- default
volumes:
- gluetun_data:/tmp/gluetun
restart: unless-stopped
transmission: transmission:
image: linuxserver/transmission image: linuxserver/transmission
network_mode: "service:gluetun"
environment: environment:
- TZ=Europe/Paris - TZ=Europe/Paris
- PUID=${USER_ID} - PUID=${USER_ID}
- PGID=${GROUP_ID} - PGID=${GROUP_ID}
ports: # ports:
- "51413:51413" # - "51413:51413"
- "51413:51413/udp" # - "51413:51413/udp"
volumes: volumes:
- ./transmission/config:/config - ./transmission/config:/config
- ./transmission/downloads:/downloads - ./transmission/downloads:/downloads
@@ -53,38 +71,27 @@ services:
app: app:
build: build:
context: ./app context: ./
dockerfile: "Dockerfile" dockerfile: .docker/app/Dockerfile
args:
puid: ${USER_ID}
pgid: ${GROUP_ID}
debug: ${DEBUG:-false}
env_file: env_file:
- .env - .env
volumes: volumes:
- ./app:/app - ./app:/oxpanel/app
- ./transmission:/transmission:ro - ./transmission:/transmission:ro
- /app/frontend/node_modules - gluetun_data:/tmp/gluetun:ro
- /oxpanel/app/frontend/node_modules
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- redis - redis
- transmission - gluetun
healthcheck:
interval: 10s
timeout: 5s
retries: 3
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
command: > command: >
bash -c "sleep 10 python manage.py migrate
&& cd frontend && yarn build && cd .. && uvicorn app.asgi:application --config uvicorn_config.py"
&& python manage.py collectstatic --noinput
&& python manage.py migrate
&& uvicorn app.asgi:application --workers 3 --host 0.0.0.0 --port 8000 --lifespan off --loop uvloop --ws websockets --log-level info"
# celery:
# extends:
# service: app
# restart: unless-stopped
# depends_on:
# - redis
# - app
# command: >
# bash -c "sleep 5 & celery -A app worker -E -B"
event: event:
extends: extends:
@@ -93,10 +100,13 @@ services:
depends_on: depends_on:
- redis - redis
- app - app
- transmission - gluetun
command: > command: >
bash -c "sleep 15 & python manage.py torrent_event" bash -c "sleep 2 && python manage.py torrent_event"
networks: networks:
web: web:
external: true external: true
volumes:
gluetun_data:
+47
View File
@@ -0,0 +1,47 @@
[project]
name = "oxpanel25"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"anyio~=4.13.0",
"channels~=4.3.2",
"channels-redis~=4.3.0",
"django~=6.0.3",
"django-cors-headers~=4.9.0",
"django-filter~=25.2",
"django-vite~=3.1.0",
"djangorestframework~=3.17.1",
"djangorestframework-simplejwt~=5.5.1",
"stream-zip~=0.0.84",
"transmission-rpc~=7.0.11",
"uvicorn~=0.43.0",
"uvloop~=0.22.1",
"websockets~=16.0",
]
[dependency-groups]
dev = [
"ruff>=0.15.9",
]
[tool.ruff]
target-version = "py314"
line-length = 88
src = ["app"]
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "DJ"]
ignore = [
"E501",
]
[tool.ruff.lint.per-file-ignores]
"app/**/migrations/*.py" = ["E501", "F401"]
"app/**/settings.py" = ["F401"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
+15 -3
View File
@@ -31,20 +31,27 @@
"peer-id-ttl-hours": 6, "peer-id-ttl-hours": 6,
"peer-limit-global": 400, "peer-limit-global": 400,
"peer-limit-per-torrent": 100, "peer-limit-per-torrent": 100,
"peer-port": 51413, "peer-port": 64626,
"peer-port-random-high": 65535, "peer-port-random-high": 65535,
"peer-port-random-low": 49152, "peer-port-random-low": 49152,
"peer-port-random-on-start": false, "peer-port-random-on-start": false,
"peer-socket-tos": "le", "peer-socket-tos": "le",
"pex-enabled": true, "pex-enabled": true,
"pidfile": "",
"port-forwarding-enabled": true, "port-forwarding-enabled": true,
"preallocation": 1, "preallocation": 1,
"preferred_transports": [
"utp",
"tcp"
],
"prefetch-enabled": true, "prefetch-enabled": true,
"proxy_url": null,
"queue-stalled-enabled": true, "queue-stalled-enabled": true,
"queue-stalled-minutes": 30, "queue-stalled-minutes": 30,
"ratio-limit": 20, "ratio-limit": 20.0,
"ratio-limit-enabled": true, "ratio-limit-enabled": true,
"rename-partial-files": true, "rename-partial-files": true,
"reqq": 2000,
"rpc-authentication-required": false, "rpc-authentication-required": false,
"rpc-bind-address": "0.0.0.0", "rpc-bind-address": "0.0.0.0",
"rpc-enabled": true, "rpc-enabled": true,
@@ -66,17 +73,22 @@
"script-torrent-done-seeding-filename": "", "script-torrent-done-seeding-filename": "",
"seed-queue-enabled": false, "seed-queue-enabled": false,
"seed-queue-size": 10, "seed-queue-size": 10,
"sequential_download": false,
"sleep-per-seconds-during-verify": 100,
"speed-limit-down": 100, "speed-limit-down": 100,
"speed-limit-down-enabled": false, "speed-limit-down-enabled": false,
"speed-limit-up": 100, "speed-limit-up": 100,
"speed-limit-up-enabled": false, "speed-limit-up-enabled": false,
"start-added-torrents": true, "start-added-torrents": true,
"start_paused": false,
"tcp-enabled": true, "tcp-enabled": true,
"torrent-added-verify-mode": "fast", "torrent-added-verify-mode": "fast",
"torrent_complete_verify_enabled": false,
"trash-original-torrent-files": true, "trash-original-torrent-files": true,
"umask": "002", "umask": "002",
"upload-slots-per-torrent": 50, "upload-slots-per-torrent": 50,
"utp-enabled": true, "utp-enabled": true,
"watch-dir": "/watch", "watch-dir": "/watch",
"watch-dir-enabled": true "watch-dir-enabled": true,
"watch-dir-force-generic": false
} }
Generated
+489
View File
@@ -0,0 +1,489 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "asgiref"
version = "3.11.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "channels"
version = "4.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/92/b18d4bb54d14986a8b35215a1c9e6a7f9f4d57ca63ac9aee8290ebb4957d/channels-4.3.2.tar.gz", hash = "sha256:f2bb6bfb73ad7fb4705041d07613c7b4e69528f01ef8cb9fb6c21d9295f15667", size = 27023, upload-time = "2025-11-20T15:13:05.102Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/34/c32915288b7ef482377b6adc401192f98c6a99b3a145423d3b8aed807898/channels-4.3.2-py3-none-any.whl", hash = "sha256:fef47e9055a603900cf16cef85f050d522d9ac4b3daccf24835bd9580705c176", size = 31313, upload-time = "2025-11-20T15:13:02.357Z" },
]
[[package]]
name = "channels-redis"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "channels" },
{ name = "msgpack" },
{ name = "redis" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/69/fd3407ad407a80e72ca53850eb7a4c306273e67d5bbb71a86d0e6d088439/channels_redis-4.3.0.tar.gz", hash = "sha256:740ee7b54f0e28cf2264a940a24453d3f00526a96931f911fcb69228ef245dd2", size = 31440, upload-time = "2025-07-22T13:48:46.087Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/fe/b7224a401ad227b263e5ba84753ffb5a88df048f3b15efd2797903543ce4/channels_redis-4.3.0-py3-none-any.whl", hash = "sha256:48f3e902ae2d5fef7080215524f3b4a1d3cea4e304150678f867a1a822c0d9f5", size = 20641, upload-time = "2025-07-22T13:48:44.545Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
]
[[package]]
name = "click"
version = "8.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "django"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/e1/894115c6bd70e2c8b66b0c40a3c367d83a5a48c034a4d904d31b62f7c53a/django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1", size = 10872701, upload-time = "2026-03-03T13:55:15.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" },
]
[[package]]
name = "django-cors-headers"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" },
]
[[package]]
name = "django-filter"
version = "25.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" },
]
[[package]]
name = "django-vite"
version = "3.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/fe/4be07f538bbf9bf8bf73b045552ec2a1b4fba67e3176abb30490b643368e/django_vite-3.1.0.tar.gz", hash = "sha256:8b4ffe4a9fa81ff568bfb195e74dde8694aaf13fd4b656ae60bf59cce08b85e8", size = 25434, upload-time = "2025-02-23T15:32:07.914Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/59/7df4b1077fa41b43b4e542696d75138dbb86a65f44248c607b3e0f1014ce/django_vite-3.1.0-py3-none-any.whl", hash = "sha256:4e46572bd6b1ce70784be129205dc2ffcbc7a3c19fea50bebfb72b327bbde5fc", size = 23579, upload-time = "2025-02-23T15:32:06.603Z" },
]
[[package]]
name = "djangorestframework"
version = "3.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" },
]
[[package]]
name = "djangorestframework-simplejwt"
version = "5.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "djangorestframework" },
{ name = "pyjwt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "msgpack"
version = "1.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
{ url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
{ url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
{ url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
{ url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
{ url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
{ url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
{ url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
{ url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
{ url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
{ url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
{ url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
{ url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
]
[[package]]
name = "oxpanel25"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "anyio" },
{ name = "channels" },
{ name = "channels-redis" },
{ name = "django" },
{ name = "django-cors-headers" },
{ name = "django-filter" },
{ name = "django-vite" },
{ name = "djangorestframework" },
{ name = "djangorestframework-simplejwt" },
{ name = "stream-zip" },
{ name = "transmission-rpc" },
{ name = "uvicorn" },
{ name = "uvloop" },
{ name = "websockets" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "anyio", specifier = "~=4.13.0" },
{ name = "channels", specifier = "~=4.3.2" },
{ name = "channels-redis", specifier = "~=4.3.0" },
{ name = "django", specifier = "~=6.0.3" },
{ name = "django-cors-headers", specifier = "~=4.9.0" },
{ name = "django-filter", specifier = "~=25.2" },
{ name = "django-vite", specifier = "~=3.1.0" },
{ name = "djangorestframework", specifier = "~=3.17.1" },
{ name = "djangorestframework-simplejwt", specifier = "~=5.5.1" },
{ name = "stream-zip", specifier = "~=0.0.84" },
{ name = "transmission-rpc", specifier = "~=7.0.11" },
{ name = "uvicorn", specifier = "~=0.43.0" },
{ name = "uvloop", specifier = "~=0.22.1" },
{ name = "websockets", specifier = "~=16.0" },
]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.9" }]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
]
[[package]]
name = "pyjwt"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[[package]]
name = "redis"
version = "7.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" },
]
[[package]]
name = "requests"
version = "2.33.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]
[[package]]
name = "ruff"
version = "0.15.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" },
{ url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" },
{ url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" },
{ url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" },
{ url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" },
{ url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" },
{ url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" },
{ url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" },
{ url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" },
{ url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" },
{ url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" },
{ url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" },
{ url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" },
{ url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" },
{ url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" },
]
[[package]]
name = "sqlparse"
version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
]
[[package]]
name = "stream-zip"
version = "0.0.84"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycryptodome" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1b/f2/bc3d35c70735fa118eb3a49af8ce786863df87c910c14483daca5d056613/stream_zip-0.0.84.tar.gz", hash = "sha256:32ba07c3c7fe947c0224a9f8e4a228f14080e01b17d8ffc78c16a6043b1fba12", size = 10074, upload-time = "2026-02-04T08:37:41.417Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/8c/e11decad4ab23780503666170b09a8d0d07b15045b522b0ba94fc5df257f/stream_zip-0.0.84-py3-none-any.whl", hash = "sha256:4e3a68aba1aba643035c4b9e219a61de519752693ff727c3268581a1e70f1376", size = 9890, upload-time = "2026-02-04T08:37:39.862Z" },
]
[[package]]
name = "transmission-rpc"
version = "7.0.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/b8/dc4debf525c3bb8a676f4fd0ab8534845e3b067c78a81ad05ac39014d849/transmission_rpc-7.0.11.tar.gz", hash = "sha256:5872322e60b42e368bc9c4724773aea4593113cb19bd2da589f0ffcdabe57963", size = 113744, upload-time = "2024-08-20T22:41:07.485Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/4c/6319bcb1026e3f78c9cbcc9c24de77a76f09954e67ffc5ebfc29f7ce4b90/transmission_rpc-7.0.11-py3-none-any.whl", hash = "sha256:94fd008b54640dd9fff14d7ae26848f901e9d130a70950b8930f9b395988914f", size = 28231, upload-time = "2024-08-20T22:41:05.777Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "tzdata"
version = "2026.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "uvicorn"
version = "0.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/f2/368268300fb8af33743508d738ef7bb4d56afdb46c6d9c0fa3dd515df171/uvicorn-0.43.0.tar.gz", hash = "sha256:ab1652d2fb23abf124f36ccc399828558880def222c3cb3d98d24021520dc6e8", size = 85686, upload-time = "2026-04-03T18:37:48.984Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/df/0cf5b0c451602748fdc7a702d4667f6e209bf96aa6e3160d754234445f2a/uvicorn-0.43.0-py3-none-any.whl", hash = "sha256:46fac64f487fd968cd999e5e49efbbe64bd231b5bd8b4a0b482a23ebce499620", size = 68591, upload-time = "2026-04-03T18:37:47.64Z" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]