Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00ac38d126 | |||
| c4d27e9842 |
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -3,3 +3,4 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
__pycache__
|
__pycache__
|
||||||
node_modules
|
node_modules
|
||||||
|
db.sqlite3
|
||||||
|
|||||||
@@ -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=''
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -1,3 +1 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
|
|||||||
+2
-2
@@ -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"
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
from django.db import models
|
|
||||||
|
|
||||||
# Create your models here.
|
# Create your models here.
|
||||||
|
|||||||
+7
-7
@@ -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
@@ -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
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
|
|||||||
+11
-10
@@ -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)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
+10
-10
@@ -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,7 +1,7 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
'postcss-import': {},
|
'postcss-import': {},
|
||||||
'postcss-simple-vars': {},
|
// 'postcss-simple-vars': {},
|
||||||
'autoprefixer': {}
|
'autoprefixer': {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-r requirements-common.txt
|
|
||||||
daphne
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-r requirements-common.txt
|
|
||||||
uvicorn
|
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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])
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
|
|||||||
@@ -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
@@ -28,8 +28,6 @@ services:
|
|||||||
|
|
||||||
# Port du container cible (le port d'écoute de Nginx à l’intérieur du conteneur)
|
# Port du container cible (le port d'écoute de Nginx à l’inté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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user