Compare commits

...

61 Commits

Author SHA1 Message Date
elordenador a61664a46e a 2026-05-26 12:08:06 +02:00
elordenador 1a73a9e373 fix: replace random module with secrets for secure code generation in VerificationCode 2026-05-26 12:02:36 +02:00
elordenador 4877e859bd fix: update HTTP method requirements for borrar_producto and eliminar_direccion views to require POST only 2026-05-26 12:01:15 +02:00
elordenador 848a49c92d feat: add BlankToNoneCharField for handling empty strings in models and update Cart model to use it
fix: update view functions to require appropriate HTTP methods
2026-05-26 11:48:04 +02:00
elordenador ac9efaaf91 fix: update delete review URL to use review ID instead of product ID 2026-05-26 10:35:17 +02:00
elordenador 2024e2f90c fix: update session_key fields in Cart, Order, and StockReservation models for consistency 2026-05-26 10:29:06 +02:00
elordenador 6ec0f4e732 feat: add constants for image types and error messages in forms 2026-05-26 10:19:21 +02:00
elordenador 35e7e93600 fix: remove redundant type annotations for user in UserAdmin actions 2026-05-26 10:12:28 +02:00
elordenador a7f43483f0 refactor: remove obsolete service.sh script 2026-05-26 10:11:42 +02:00
elordenador d773addc53 fix: update database configuration to support PostgreSQL toggle 2026-05-26 10:10:45 +02:00
elordenador b143d92cb2 fix: consolidate RUN commands in Dockerfile for improved layer caching 2026-05-26 10:08:41 +02:00
elordenador 9d7a7f7432 Merge branch 'latest' of github.com:dsaub/proyecto-final into latest 2026-05-26 10:01:31 +02:00
elordenador 0bb2eeeaa6 fix: add integrity attributes to Stripe and n8n stylesheets for security 2026-05-26 10:00:29 +02:00
Daniel (elordenador) b9acf6a1c7 Merge pull request #98 from dsaub/dependabot/uv/idna-3.15
Bump idna from 3.13 to 3.15
2026-05-26 09:54:31 +02:00
elordenador 57efd95b0c fix: add integrity attribute to Stripe script for security 2026-05-26 09:51:05 +02:00
elordenador 5696fdddaa fix: remove hardcoded IP address from ALLOWED_HOSTS 2026-05-26 09:45:02 +02:00
elordenador 37383b0736 fix: update SECRET_KEY to use environment variable instead of hardcoded value 2026-05-26 09:44:53 +02:00
dependabot[bot] 784fdd1284 Bump idna from 3.13 to 3.15
Bumps [idna](https://github.com/kjd/idna) from 3.13 to 3.15.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md)
- [Commits](https://github.com/kjd/idna/compare/v3.13...v3.15)

---
updated-dependencies:
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 22:36:59 +00:00
Daniel (elordenador) 336e499973 Merge pull request #94 from dsaub/dependabot/pip/requests-2.34.2
Bump requests from 2.33.1 to 2.34.2
2026-05-15 13:19:08 +02:00
elordenador e4fa941fd6 Add API for AI Agent 2026-05-15 12:35:23 +02:00
dependabot[bot] 48b3f46623 Bump requests from 2.33.1 to 2.34.2
Bumps [requests](https://github.com/psf/requests) from 2.33.1 to 2.34.2.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.33.1...v2.34.2)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.34.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 09:52:43 +00:00
Daniel (elordenador) 8caba9b85b Merge pull request #91 from dsaub/feature/valoraciones
feat: sistema de valoraciones de productos
2026-05-12 10:51:53 +02:00
elordenador d0f687f56f feat: añadir edición y eliminación de valoraciones propias 2026-05-08 14:05:52 +02:00
elordenador e70a9aeb9c fix: usar nombre de URL correcto (producto en lugar de product_detail) 2026-05-08 14:04:17 +02:00
elordenador e0350de530 fix: usar estrellas Unicode en lugar de Bootstrap Icons 2026-05-08 14:03:31 +02:00
elordenador 62bf3fdc08 fix: mostrar mensaje correcto cuando no se puede valorar por no haber compra 2026-05-08 13:58:08 +02:00
elordenador 2b2054ace6 debug: añadir variables de debug al template 2026-05-08 13:57:33 +02:00
elordenador f129b0462a fix: permitir valorar si el usuario tiene cualquier OrderItem del producto 2026-05-08 13:53:56 +02:00
elordenador aa047b3fd8 fix: eliminar campo images del form (widget no soporta multiple) 2026-05-08 13:34:00 +02:00
elordenador 429b531bad feat: añadir Review al admin para gestionar valoraciones 2026-05-08 13:33:46 +02:00
elordenador 0438a77149 feat: añadir sistema de valoraciones con formulario, vistas y templates 2026-05-08 13:33:37 +02:00
elordenador 40f0ef8ea5 feat: añadir modelo Review para valoraciones de productos 2026-05-08 13:32:33 +02:00
Daniel (elordenador) e53ecef5dc Merge pull request #90 from dsaub/security-fixes
Security fixes: image validation, email masking, quantity limits
2026-05-08 13:26:38 +02:00
elordenador bf39724837 Fix security issues: image validation, email masking, quantity limits, min length
- #76: Add file type validation for product images (Media severity)
- #75: Mask emails in audit logs to prevent information leakage (Media severity)
- #74: Add max value validator to quantity fields (Low severity)
- #73: Add min length validation to password fields (Low severity)
2026-05-08 13:24:54 +02:00
Daniel (elordenador) 6f82787022 Merge pull request #89 from dsaub/fix/issue-77-idor-security
Fix IDOR vulnerability in cart operations (#77)
2026-05-08 13:19:58 +02:00
elordenador 46343c1ea8 Refactor error logging in create_paypal_payment function for clarity 2026-05-08 13:18:52 +02:00
elordenador 76c8a277da Remove unused send_test_email function from views.py 2026-05-08 13:16:43 +02:00
elordenador 169a6d9dfb Remove root test .py files 2026-05-08 13:14:52 +02:00
elordenador f59841b5b8 Add permissions section to test job in Docker workflow 2026-05-08 13:13:27 +02:00
elordenador 32c1e1e6ff Fix IDOR vulnerability in cart operations (issue #77)
- Add _get_cart_item_owner_filters() helper to validate CartItem ownership
- Update update_cart_item and remove_from_cart to validate ownership
- Prevents users from manipulating item_id to access other users' cart items
2026-05-08 13:09:50 +02:00
elordenador 8a0335fabc Merge branch 'latest' of github.com:dsaub/proyecto-final into latest 2026-05-08 13:07:32 +02:00
elordenador 74b9d3bbc6 Add send_email import 2026-05-08 13:07:06 +02:00
Daniel (elordenador) ffe7828d8e Add UV Config file header to pyproject.toml 2026-05-08 13:00:15 +02:00
Daniel (elordenador) a12954fb84 Update dependabot.yml configuration 2026-05-08 12:59:47 +02:00
Daniel (elordenador) 7f50674bb8 Update Dependabot configuration for Python packages
Changed the package ecosystem from 'uv' to 'pip' and updated the schedule to daily. Removed GitHub Actions updates section.
2026-05-08 12:55:42 +02:00
elordenador f9b3bc7096 Add Procfile 2026-05-08 10:39:38 +02:00
elordenador 932fe7316b Update 2026-05-08 10:37:09 +02:00
elordenador 84f125c4b3 Update Python version 2026-05-08 10:34:28 +02:00
elordenador bb4d9993ec Remove requirements.txt 2026-05-08 10:12:29 +02:00
Daniel (elordenador) beb74539e3 Update dependabot.yml 2026-05-08 10:06:47 +02:00
Daniel (elordenador) f9eda0ca57 Merge pull request #80 from dsaub/development
Development
2026-05-08 10:04:51 +02:00
Daniel (elordenador) 4a30b68b5c Merge pull request #79 from dsaub/copilot/transition-pip-dependencies-to-uv
Migrate dependency management to uv with direct-only Python deps and Dependabot support
2026-05-08 10:03:44 +02:00
copilot-swe-agent[bot] e18ff79ba7 Add Dependabot configuration
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/7a547c09-9817-47a6-979e-c19cbcaa4c08

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-05-08 07:58:40 +00:00
copilot-swe-agent[bot] 1ce2efd736 Finalize Dockerfile comment wording
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/7a547c09-9817-47a6-979e-c19cbcaa4c08

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-05-08 07:57:00 +00:00
copilot-swe-agent[bot] 36046ef816 Polish Dockerfile uv sync instructions
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/7a547c09-9817-47a6-979e-c19cbcaa4c08

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-05-08 07:55:56 +00:00
copilot-swe-agent[bot] e8a26f497e Apply validation feedback for uv lock and dependency docs
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/7a547c09-9817-47a6-979e-c19cbcaa4c08

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-05-08 07:54:56 +00:00
copilot-swe-agent[bot] 1ff72c7a94 Update PayPal docs and helper script to uv commands
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/7a547c09-9817-47a6-979e-c19cbcaa4c08

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-05-08 07:53:06 +00:00
copilot-swe-agent[bot] 580d60ec4f Add uv project config and switch CI/Docker installs to uv
Agent-Logs-Url: https://github.com/dsaub/proyecto-final/sessions/7a547c09-9817-47a6-979e-c19cbcaa4c08

Co-authored-by: dsaub <54474838+dsaub@users.noreply.github.com>
2026-05-08 07:51:40 +00:00
Daniel (elordenador) 4661bcdffd Merge pull request #62 from dsaub/development
Merge entire work to latest
2026-05-04 13:43:50 +02:00
Daniel (elordenador) 27c06fe0b5 Merge pull request #33 from dsaub/development
Build and Push Docker Image / test (push) Has been cancelled
Build and Push Docker Image / docker (push) Has been cancelled
Fix mobile header alignment and improve navbar responsiveness
2026-04-20 15:57:04 +02:00
Daniel (elordenador) 44bf6df686 Merge pull request #22 from dsaub/development
Enhance stock management, payment systems, and testing coverage
2026-04-20 12:25:33 +02:00
36 changed files with 1783 additions and 562 deletions
+11 -1
View File
@@ -6,4 +6,14 @@ venv
.venv .venv
db.sqlite3 db.sqlite3
static static
media media
docs
logs
staticfiles
.gitignore
AGENTS.md
Dockerfile
Makefile
nginx.conf
Procfile
uv.lock
+9
View File
@@ -0,0 +1,9 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "daily"
allow:
- dependency-type: "direct"
open-pull-requests-limit: 10
+9 -8
View File
@@ -14,20 +14,21 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout del código - name: Checkout del código
uses: actions/checkout@v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Configurar Python - name: Configurar Python
uses: actions/setup-python@v6 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with: with:
python-version: '3.14' python-version: '3.14'
- name: Configurar uv
uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6
- name: Instalar dependencias - name: Instalar dependencias
run: | run: |
python -m pip install --upgrade pip uv sync --no-dev --no-install-project
pip install -r requirements.txt
- name: Ejecutar tests - name: Ejecutar tests
env: env:
DJANGO_SETTINGS_MODULE: proyecto.settings DJANGO_SETTINGS_MODULE: proyecto.settings
run: | run: |
python manage.py test uv run python manage.py test
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -37,13 +38,13 @@ jobs:
steps: steps:
- name: Checkout del código - name: Checkout del código
uses: actions/checkout@v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Configurar Docker Buildx - name: Configurar Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Build (sin push) - name: Build (sin push)
uses: docker/build-push-action@v6 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with: with:
context: . context: .
push: false push: false
+6 -3
View File
@@ -9,6 +9,8 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- name: Checkout del código - name: Checkout del código
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -16,15 +18,16 @@ jobs:
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: '3.14' python-version: '3.14'
- name: Configurar uv
uses: astral-sh/setup-uv@v6
- name: Instalar dependencias - name: Instalar dependencias
run: | run: |
python -m pip install --upgrade pip uv sync --no-dev --no-install-project
pip install -r requirements.txt
- name: Ejecutar tests - name: Ejecutar tests
env: env:
DJANGO_SETTINGS_MODULE: proyecto.settings DJANGO_SETTINGS_MODULE: proyecto.settings
run: | run: |
python manage.py test uv run python manage.py test
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test needs: test
+1
View File
@@ -0,0 +1 @@
3.14
+23 -5
View File
@@ -4,16 +4,34 @@ ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
COPY requirements.txt /app/ COPY pyproject.toml uv.lock /app/
RUN apk --no-cache update && apk --no-cache upgrade
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app/ RUN apk --no-cache update \
&& apk --no-cache upgrade \
&& apk --no-cache add \
build-base \
freetype-dev \
jpeg-dev \
zlib-dev \
&& pip install --no-cache-dir uv \
&& uv sync --no-dev --no-install-project # Install only dependencies, not the local project package
COPY ./entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
COPY ./proyecto /app/proyecto
COPY ./tienda /app/tienda
COPY ./manage.py /app/manage.py
EXPOSE 8000 EXPOSE 8000
RUN mkdir -pv /fonts RUN mkdir -pv /fonts
COPY tienda/static/fonts/ /fonts/ COPY tienda/static/fonts/ /fonts/
ENTRYPOINT ["/bin/sh", "/app/entrypoint.sh"] RUN addgroup -S app \
&& adduser -S app -G app \
&& chown -R app:app /app /fonts
USER app
ENTRYPOINT ["/bin/sh", "/app/entrypoint.sh"]
+2
View File
@@ -0,0 +1,2 @@
web: gunicorn proyecto.wsgi --bind 0.0.0.0:$PORT
worker: celery -A proyecto worker --loglevel=info
+1 -1
View File
@@ -39,7 +39,7 @@ Con tus valores reales del Sandbox.
### 6. Instalar el SDK de PayPal ### 6. Instalar el SDK de PayPal
```bash ```bash
pip install paypalrestsdk uv add paypalrestsdk
``` ```
### 7. Usar cuentas de prueba para transacciones ### 7. Usar cuentas de prueba para transacciones
+1 -1
View File
@@ -67,7 +67,7 @@ Si todo está bien, deberías ver:
## Checklist de Configuración ## Checklist de Configuración
- [ ] `pip install paypalrestsdk` (verificar con: `.venv/bin/pip list | grep paypal`) - [ ] `uv add paypalrestsdk` (verificar con: `uv pip list | grep paypal`)
- [ ] `PAYPAL_CLIENT_ID` en settings.py (no vacío) - [ ] `PAYPAL_CLIENT_ID` en settings.py (no vacío)
- [ ] `PAYPAL_CLIENT_SECRET` en settings.py (no vacío) - [ ] `PAYPAL_CLIENT_SECRET` en settings.py (no vacío)
- [ ] `PAYPAL_MODE = 'sandbox'` en settings.py - [ ] `PAYPAL_MODE = 'sandbox'` en settings.py
+3 -3
View File
@@ -5,10 +5,10 @@ set -eu
echo "Sleeping due to mysql..." echo "Sleeping due to mysql..."
sleep 10 sleep 10
echo "Running DB migrations..." echo "Running DB migrations..."
python manage.py migrate uv run python manage.py migrate
echo "Collecting STATIC..." echo "Collecting STATIC..."
python manage.py collectstatic --noinput --clear uv run python manage.py collectstatic --noinput --clear
echo "Running server!" echo "Running server!"
gunicorn --bind 0.0.0.0:8000 proyecto.wsgi:application --forwarded-allow-ips="*" uv run gunicorn --bind 0.0.0.0:8000 proyecto.wsgi:application --forwarded-allow-ips="*"
+10 -18
View File
@@ -14,7 +14,7 @@ import logging
import os, sys import os, sys
from pathlib import Path from pathlib import Path
DEV_ENV = (sys.argv[1] == 'runserver') DEV_ENV = len(sys.argv) > 1 and sys.argv[1] == 'runserver'
RUNNING_TESTS = any(arg in {'test', 'pytest'} for arg in sys.argv) or 'PYTEST_CURRENT_TEST' in os.environ RUNNING_TESTS = any(arg in {'test', 'pytest'} for arg in sys.argv) or 'PYTEST_CURRENT_TEST' in os.environ
@@ -78,7 +78,7 @@ load_dotenv(BASE_DIR / '.env')
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-#g((q@lvnkt(j6)2(gvtn0px)r2r(911)pv59i(6w)5e!_-^ao') SECRET_KEY = os.getenv('SECRET_KEY', '')
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env_bool('DEBUG', True) DEBUG = env_bool('DEBUG', True)
@@ -86,7 +86,6 @@ S3_ENABLE = env_bool('S3_ENABLE', False)
S3_USE_LOCAL_URLS = env_bool('S3_USE_LOCAL_URLS', False) S3_USE_LOCAL_URLS = env_bool('S3_USE_LOCAL_URLS', False)
ALLOWED_HOSTS = env_list('ALLOWED_HOSTS', [ ALLOWED_HOSTS = env_list('ALLOWED_HOSTS', [
'192.168.1.142',
'localhost', 'localhost',
'127.0.0.1', '127.0.0.1',
]) ])
@@ -104,6 +103,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.forms', 'django.forms',
'compressor', 'compressor',
'ninja',
] ]
if S3_ENABLE: if S3_ENABLE:
@@ -147,14 +147,14 @@ WSGI_APPLICATION = 'proyecto.wsgi.application'
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases # https://docs.djangoproject.com/en/6.0/ref/settings/#databases
# Usa PostgreSQL por defecto (POSTGRES_ENABLED=True); si no, SQLite. # Usa PostgreSQL por defecto (POSTGRES_ENABLED=True); si no, SQLite.
if RUNNING_TESTS: if RUNNING_TESTS or not env_bool('POSTGRES_ENABLED', True):
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3',
} }
} }
elif env_bool('POSTGRES_ENABLED', True): else:
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.postgresql', 'ENGINE': 'django.db.backends.postgresql',
@@ -165,14 +165,6 @@ elif env_bool('POSTGRES_ENABLED', True):
'PORT': env_int('POSTGRES_PORT', 5432), 'PORT': env_int('POSTGRES_PORT', 5432),
} }
} }
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation # Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
@@ -307,9 +299,6 @@ SECURITY = os.getenv('SECURITY', 'tls')
SMTP_USERNAME = os.getenv('SMTP_USERNAME', None) SMTP_USERNAME = os.getenv('SMTP_USERNAME', None)
SMTP_PASSWORD = os.getenv('SMTP_PASSWORD', None) SMTP_PASSWORD = os.getenv('SMTP_PASSWORD', None)
SMTP_EMAIL = os.getenv("SMTP_EMAIL", None) SMTP_EMAIL = os.getenv("SMTP_EMAIL", None)
if not RUNNING_TESTS and (SMTP_USERNAME is None or SMTP_PASSWORD is None or SMTP_EMAIL is None):
print("Se requieren credenciales SMTP")
sys.exit(1)
@@ -396,8 +385,11 @@ logging.captureWarnings(True)
if RUNNING_TESTS: if RUNNING_TESTS:
EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
else: elif SMTP_USERNAME and SMTP_PASSWORD and SMTP_EMAIL:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
else:
print("ADVERTENCIA: Sin credenciales SMTP - usando backend console")
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
EMAIL_HOST = SMTP_ENDPOINT EMAIL_HOST = SMTP_ENDPOINT
EMAIL_PORT = SMTP_PORT EMAIL_PORT = SMTP_PORT
@@ -407,7 +399,7 @@ EMAIL_HOST_USER = SMTP_USERNAME
EMAIL_HOST_PASSWORD = SMTP_PASSWORD EMAIL_HOST_PASSWORD = SMTP_PASSWORD
# El correo que se usará como remitente por defecto # El correo que se usará como remitente por defecto
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", SMTP_EMAIL) DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL") or SMTP_EMAIL or "no-reply@localhost"
# URL de Redis (asumiendo que corre en el puerto default 6379) # URL de Redis (asumiendo que corre en el puerto default 6379)
CELERY_BROKER_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") CELERY_BROKER_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
+7 -1
View File
@@ -19,11 +19,17 @@ from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from tienda import views as tienda_views from tienda import views as tienda_views
from tienda.api import router as api_router
from ninja import NinjaAPI
api = NinjaAPI(title="Comercialmeria API", version="1.0.0")
api.add_router("/", api_router)
urlpatterns = [ urlpatterns = [
path('', tienda_views.home, name='home'), path('', tienda_views.home, name='home'),
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('tienda/', include('tienda.urls')) path('tienda/', include('tienda.urls')),
path('api/', api.urls),
] ]
if settings.DEBUG and ( if settings.DEBUG and (
+25
View File
@@ -0,0 +1,25 @@
# UV Config file
[project]
name = "proyecto-final"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"celery==5.6.3",
"Django==6.0.5",
"django-compressor==4.6.0",
"django-ninja>=1.6.2",
"django-redis==6.0.0",
# S3 backend requerido por tienda/storage_backends.py cuando S3_ENABLE=True.
"django-storages[s3]==1.14.6",
"fpdf2==2.8.7",
"gunicorn==26.0.0",
"paypalrestsdk==1.13.3",
"pillow==12.2.0",
"psycopg2-binary==2.9.12",
"requests==2.34.2",
"stripe==15.1.0",
"whitenoise==6.12.0",
]
[tool.uv]
package = false
-51
View File
@@ -1,51 +0,0 @@
amqp==5.3.1
asgiref==3.11.1
billiard==4.2.4
boto3==1.43.5
botocore==1.43.5
celery==5.6.3
certifi==2026.4.22
cffi==2.0.0
charset-normalizer==3.4.7
click==8.3.3
click-didyoumean==0.3.1
click-plugins==1.1.1.2
click-repl==0.3.0
cryptography==48.0.0
defusedxml==0.7.1
Django==6.0.5
django-appconf==1.2.0
django-redis==6.0.0
django-storages==1.14.6
django_compressor==4.6.0
fonttools==4.62.1
fpdf2==2.8.7
gunicorn==26.0.0
idna==3.13
jmespath==1.1.0
kombu==5.6.2
MarkupSafe==3.0.3
packaging==26.2
paypalrestsdk==1.13.3
pillow==12.2.0
prompt_toolkit==3.0.52
psycopg2-binary==2.9.12
pycparser==3.0
pyOpenSSL==26.2.0
python-dateutil==2.9.0.post0
rcssmin==1.2.2
redis==7.4.0
requests==2.33.1
rjsmin==1.2.5
s3transfer==0.17.0
six==1.17.0
sqlparse==0.5.5
stripe==15.1.0
typing_extensions==4.15.0
tzdata==2026.2
tzlocal==5.3.1
urllib3==2.6.3
vine==5.1.0
wcwidth==0.7.0
whitenoise==6.12.0
-101
View File
@@ -1,101 +0,0 @@
#!/bin/bash
set -u
readonly HOSTS=(
"aws-docker-mysql"
"aws-docker-redis"
"aws-docker-celery"
"aws-docker"
)
readonly WAIT_SECONDS=5
readonly REMOTE_DEPLOY_DIR="/root/deploys"
usage() {
echo "Uso: $0 {start|stop|restart|update}"
}
print_status() {
local action="$1"
local host="$2"
local status="$3"
# Estilo similar al output de OpenRC.
printf "* %-8s %-16s [%s]\n" "$action" "$host" "$status"
}
run_remote_compose() {
local host="$1"
local command="$2"
ssh -o BatchMode=yes -o LogLevel=ERROR -T "$host" "sudo -n sh -c \"cd '$REMOTE_DEPLOY_DIR' || exit 1; if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then docker compose $command; elif command -v docker-compose >/dev/null 2>&1; then docker-compose $command; else exit 1; fi\"" >/dev/null 2>&1
}
run_for_all_hosts() {
local mode="$1"
local host=""
local i=0
local total=${#HOSTS[@]}
for host in "${HOSTS[@]}"; do
case "$mode" in
start)
if run_remote_compose "$host" "up -d"; then
print_status "Started" "$host" "ok"
else
print_status "Started" "$host" "fail"
exit 1
fi
;;
stop)
if run_remote_compose "$host" "down"; then
print_status "Stopped" "$host" "ok"
else
print_status "Stopped" "$host" "fail"
exit 1
fi
;;
restart)
if run_remote_compose "$host" "down" && run_remote_compose "$host" "up -d"; then
print_status "Restarted" "$host" "ok"
else
print_status "Restarted" "$host" "fail"
exit 1
fi
;;
update)
if run_remote_compose "$host" "pull" && run_remote_compose "$host" "down" && run_remote_compose "$host" "up -d"; then
print_status "Updated" "$host" "ok"
else
print_status "Updated" "$host" "fail"
exit 1
fi
;;
*)
usage
exit 1
;;
esac
i=$((i + 1))
if [ "$i" -lt "$total" ]; then
sleep "$WAIT_SECONDS"
fi
done
}
if [ "$#" -ne 1 ]; then
usage
exit 1
fi
case "$1" in
start|stop|restart|update)
run_for_all_hosts "$1"
;;
*)
usage
exit 1
;;
esac
-109
View File
@@ -1,109 +0,0 @@
#!/usr/bin/env python
"""
Script para testear la configuración de PayPal
Ejecutar: python test_paypal.py
"""
import os
import sys
import django
def main() -> None:
# Configurar Django solo cuando se ejecuta script manualmente.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'proyecto.settings')
sys.path.insert(0, os.path.dirname(__file__))
django.setup()
from django.conf import settings
print("=" * 60)
print("TEST DE CONFIGURACIÓN DE PAYPAL")
print("=" * 60)
# Verificar configuración
print("\n1. Verificando configuración en settings.py:")
print(f" PAYPAL_MODE: {settings.PAYPAL_MODE}")
print(f" PAYPAL_CLIENT_ID: {settings.PAYPAL_CLIENT_ID[:20]}..." if settings.PAYPAL_CLIENT_ID else " ❌ NO CONFIGURADO")
print(f" PAYPAL_CLIENT_SECRET: {settings.PAYPAL_CLIENT_SECRET[:20]}..." if settings.PAYPAL_CLIENT_SECRET else " ❌ NO CONFIGURADO")
# Intentar importar paypalrestsdk
print("\n2. Verificando SDK de PayPal:")
try:
import paypalrestsdk
print(" ✓ paypalrestsdk importado correctamente")
print(f" Versión: {paypalrestsdk.__version__ if hasattr(paypalrestsdk, '__version__') else 'Desconocida'}")
except ImportError as e:
print(f" ❌ Error: {e}")
print(" SOLUCIÓN: pip install paypalrestsdk")
sys.exit(1)
# Intentar conectar a PayPal
print("\n3. Probando conexión a PayPal:")
try:
paypalrestsdk.configure({
"mode": settings.PAYPAL_MODE,
"client_id": settings.PAYPAL_CLIENT_ID,
"client_secret": settings.PAYPAL_CLIENT_SECRET
})
print(" ✓ Configuración de PayPal aplicada")
# Intentar crear un pago de prueba
print("\n4. Creando pago de prueba:")
test_payment = paypalrestsdk.Payment({
"intent": "sale",
"payer": {
"payment_method": "paypal"
},
"redirect_urls": {
"return_url": "http://localhost:8000/test-return",
"cancel_url": "http://localhost:8000/test-cancel"
},
"transactions": [
{
"amount": {
"total": "10.00",
"currency": "EUR",
"details": {
"subtotal": "10.00",
"tax": "0",
"shipping": "0"
}
},
"description": "Pago de prueba",
"item_list": {
"items": [
{
"name": "Test Item",
"sku": "test_1",
"price": "10.00",
"currency": "EUR",
"quantity": 1
}
]
}
}
]
})
if test_payment.create():
print(" ✓ Pago creado exitosamente")
print(f" Payment ID: {test_payment.id}")
for link in test_payment.links:
if link.rel == "approval_url":
print(f" URL de aprobación: {link.href}")
else:
print(" ❌ Error al crear el pago:")
if hasattr(test_payment, 'error') and test_payment.error:
print(f" {test_payment.error}")
except Exception as e:
print(f" ❌ Error de conexión: {e}")
import traceback
traceback.print_exc()
print("\n" + "=" * 60)
print("TEST COMPLETADO")
print("=" * 60)
if __name__ == '__main__':
main()
-85
View File
@@ -1,85 +0,0 @@
#!/usr/bin/env python
"""
Script de prueba para el cacheo de productos en Redis
Ejecutar: python test_product_cache.py
"""
import os
import django
# Configurar Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'proyecto.settings')
django.setup()
from tienda.models import Product
from django.core.cache import cache
import time
def test_product_cache():
"""Prueba el sistema de cacheo de productos"""
print("=" * 60)
print("TEST: Sistema de Cacheo de Productos en Redis")
print("=" * 60)
# Obtener un producto de prueba
try:
product = Product.objects.first()
if not product:
print("❌ No hay productos en la base de datos para probar")
return
product_id = product.id
cache_key = f'product_{product_id}'
print(f"\n📦 Producto de prueba: {product.name} (ID: {product_id})")
# 1. Limpiar caché del producto
cache.delete(cache_key)
print(f"\n1️⃣ Caché limpiado")
# 2. Primera visita (debe cargar desde BD)
print(f"\n2️⃣ Primera visita - Cargando desde BD...")
start_time = time.time()
cached_product = cache.get(cache_key)
if cached_product is None:
print(" ✅ No está en caché (esperado)")
product_from_db = Product.objects.select_related('category', 'primary_image', 'creator').prefetch_related('secondary_images').get(id=product_id)
cache.set(cache_key, product_from_db, 300)
print(f" ✅ Producto cacheado por 5 minutos")
db_time = (time.time() - start_time) * 1000
# 3. Segunda visita (debe cargar desde caché)
print(f"\n3️⃣ Segunda visita - Cargando desde caché...")
start_time = time.time()
cached_product = cache.get(cache_key)
if cached_product:
print(f" ✅ Encontrado en caché: {cached_product.name}")
cache_time = (time.time() - start_time) * 1000
# 4. Comparar tiempos
print(f"\n⏱️ Comparación de rendimiento:")
print(f" - Desde BD: {db_time:.2f}ms")
print(f" - Desde caché: {cache_time:.2f}ms")
speedup = db_time / cache_time if cache_time > 0 else float('inf')
print(f" - Mejora: {speedup:.1f}x más rápido")
# 5. Verificar TTL
ttl = cache.ttl(cache_key)
print(f"\n⏳ TTL (tiempo de vida): {ttl} segundos (~5 minutos)")
# 6. Verificar en Redis
print(f"\n🔍 Verificación en Redis:")
print(f" - Clave: {cache_key}")
print(f" - Base de datos: 1")
print(f" - Comando para ver: valkey-cli -n 1 GET ':1:{cache_key}'")
print("\n" + "=" * 60)
print("✅ TEST COMPLETADO EXITOSAMENTE")
print("=" * 60)
except Exception as e:
print(f"❌ Error durante el test: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
test_product_cache()
+9 -4
View File
@@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, StockReservation, StockReservationItem, User, VerificationCode, SavedPaymentMethod from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, StockReservation, StockReservationItem, User, VerificationCode, SavedPaymentMethod, Review
# Register your models here. # Register your models here.
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import path from django.urls import path
@@ -20,7 +20,6 @@ class UserAdmin(admin.ModelAdmin):
def banear_usuario_action(self, request, queryset): def banear_usuario_action(self, request, queryset):
usuarios_baneados = 0 usuarios_baneados = 0
for user in queryset: for user in queryset:
user: User = user
# Desactiva usuario # Desactiva usuario
if user.registration_status == User.RegisterStatus.BANNED: if user.registration_status == User.RegisterStatus.BANNED:
continue continue
@@ -43,7 +42,6 @@ class UserAdmin(admin.ModelAdmin):
def desbanear_usuario_action(self, request, queryset): def desbanear_usuario_action(self, request, queryset):
user_desbaneados = 0 user_desbaneados = 0
for user in queryset: for user in queryset:
user: User = user
if user.registration_status != User.RegisterStatus.BANNED: if user.registration_status != User.RegisterStatus.BANNED:
continue continue
@@ -150,4 +148,11 @@ class StockReservationAdmin(admin.ModelAdmin):
class SavedPaymentMethodAdmin(admin.ModelAdmin): class SavedPaymentMethodAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'method_type', 'label', 'is_default', 'created_at') list_display = ('id', 'user', 'method_type', 'label', 'is_default', 'created_at')
list_filter = ('method_type', 'is_default', 'created_at') list_filter = ('method_type', 'is_default', 'created_at')
search_fields = ('user__username', 'user__email', 'label', 'paypal_email') search_fields = ('user__username', 'user__email', 'label', 'paypal_email')
@admin.register(Review)
class ReviewAdmin(admin.ModelAdmin):
list_display = ('id', 'product', 'user', 'rating', 'title', 'created_at')
list_filter = ('rating', 'created_at')
search_fields = ('user__username', 'product__name', 'title', 'content')
+93
View File
@@ -0,0 +1,93 @@
from typing import Optional
from ninja import Router, Schema
from django.db.models import Count
from django.shortcuts import get_object_or_404
from .models import Category, Product
router = Router()
class CategoryOut(Schema):
id: int
name: str
product_count: int
class ImageInfo(Schema):
url: str
alt: str
class ProductListOut(Schema):
id: int
name: str
sku: Optional[str] = None
briefdesc: str
price: float
price_with_vat: float
stock: int
category_id: int
category_name: str
primary_image: Optional[ImageInfo] = None
average_rating: float
reviews_count: int
class ProductDetailOut(ProductListOut):
description: str
secondary_images: list[ImageInfo]
def _image_info(img, request):
if not img:
return None
return ImageInfo(
url=request.build_absolute_uri(img.image.url),
alt=img.alt or img.name,
)
def _product_to_list_out(p, request):
return ProductListOut(
id=p.id,
name=p.name,
sku=p.sku,
briefdesc=p.briefdesc,
price=p.price,
price_with_vat=p.get_price_with_vat(),
stock=p.stock,
category_id=p.category_id,
category_name=p.category.name,
primary_image=_image_info(p.primary_image, request),
average_rating=p.get_average_rating(),
reviews_count=p.get_reviews_count(),
)
def _product_to_detail_out(p, request):
base = _product_to_list_out(p, request)
data = base.dict()
data["description"] = p.description
data["secondary_images"] = [
_image_info(img, request) for img in p.secondary_images.all()
]
return ProductDetailOut(**data)
@router.get("/categorias", response=list[CategoryOut])
def listar_categorias(request):
qs = Category.objects.annotate(product_count=Count("product"))
return [
CategoryOut(id=c.id, name=c.name, product_count=c.product_count)
for c in qs
]
@router.get("/productos", response=list[ProductListOut])
def listar_productos(request, categoria_id: Optional[int] = None):
qs = Product.objects.select_related("category", "primary_image")
if categoria_id:
qs = qs.filter(category_id=categoria_id)
return [_product_to_list_out(p, request) for p in qs]
@router.get("/productos/{product_id}", response=ProductDetailOut)
def detalle_producto(request, product_id: int):
p = get_object_or_404(
Product.objects.select_related("category", "primary_image")
.prefetch_related("secondary_images"),
id=product_id,
)
return _product_to_detail_out(p, request)
+3
View File
@@ -0,0 +1,3 @@
IMAGE_TYPE = "image/*"
EMAIL_FORMNAME = "Correo Electrónico"
INCORRECT_PASSWORDS = "Las contraseñas no coinciden"
+58 -8
View File
@@ -1,6 +1,20 @@
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator, MinLengthValidator, MaxLengthValidator
from .models import Category from .models import Category
from .constants import *
ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']
ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
def validate_image_file(value):
ext = value.name.split('.')[-1].lower()
if ext not in ALLOWED_IMAGE_EXTENSIONS:
raise ValidationError(f'Tipo de archivo no permitido. Allowed: {", ".join(ALLOWED_IMAGE_EXTENSIONS)}')
if hasattr(value, 'content_type') and value.content_type not in ALLOWED_MIME_TYPES:
raise ValidationError(f'Tipo MIME no permitido. Allowed: {", ".join(ALLOWED_MIME_TYPES)}')
class ProductForm(forms.Form): class ProductForm(forms.Form):
@@ -61,10 +75,11 @@ class ProductForm(forms.Form):
primary_image = forms.ImageField( primary_image = forms.ImageField(
label="Imagen Principal", label="Imagen Principal",
required = False, required = False,
validators=[validate_image_file],
widget = forms.ClearableFileInput( widget = forms.ClearableFileInput(
attrs = { attrs = {
'class': 'form-control', 'class': 'form-control',
'accept': 'image/*' 'accept': IMAGE_TYPE
} }
) )
) )
@@ -108,6 +123,7 @@ class ProductEditForm(forms.Form):
primary_image = forms.ImageField( primary_image = forms.ImageField(
label="Imagen Principal (opcional)", label="Imagen Principal (opcional)",
required=False, required=False,
validators=[validate_image_file],
widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'}) widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'})
) )
@@ -116,10 +132,11 @@ class SecondaryImageForm(forms.Form):
image = forms.ImageField( image = forms.ImageField(
label="Seleccionar Imagen", label="Seleccionar Imagen",
required = True, required = True,
validators=[validate_image_file],
widget = forms.ClearableFileInput( widget = forms.ClearableFileInput(
attrs = { attrs = {
'class': 'form-control', 'class': 'form-control',
'accept': 'image/*' 'accept': IMAGE_TYPE
} }
) )
) )
@@ -178,7 +195,7 @@ class UserRegisterForm(forms.Form):
) )
) )
email = forms.EmailField( email = forms.EmailField(
label = "Correo Electrónico", label = EMAIL_FORMNAME,
max_length = 255, max_length = 255,
required = True, required = True,
widget = forms.TextInput( widget = forms.TextInput(
@@ -190,7 +207,9 @@ class UserRegisterForm(forms.Form):
password = forms.CharField( password = forms.CharField(
label = "Contraseña", label = "Contraseña",
max_length = 255, max_length = 255,
min_length = 8,
required = True, required = True,
validators=[MinLengthValidator(8)],
widget = forms.PasswordInput( widget = forms.PasswordInput(
attrs = { attrs = {
'class': 'form-control' 'class': 'form-control'
@@ -200,7 +219,9 @@ class UserRegisterForm(forms.Form):
password_confirm = forms.CharField( password_confirm = forms.CharField(
label = "Verificar Contraseña", label = "Verificar Contraseña",
max_length = 255, max_length = 255,
min_length = 8,
required = True, required = True,
validators=[MinLengthValidator(8)],
widget = forms.PasswordInput( widget = forms.PasswordInput(
attrs = { attrs = {
'class': 'form-control' 'class': 'form-control'
@@ -218,7 +239,7 @@ class UserRegisterForm(forms.Form):
password = cleaned_data.get("password") password = cleaned_data.get("password")
password_confirm = cleaned_data.get("password_confirm") password_confirm = cleaned_data.get("password_confirm")
if password and password_confirm and password != password_confirm: if password and password_confirm and password != password_confirm:
raise ValidationError("Las contraseñas no coinciden.") raise ValidationError(INCORRECT_PASSWORDS)
class EditProfileForm(forms.Form): class EditProfileForm(forms.Form):
@@ -235,7 +256,7 @@ class EditProfileForm(forms.Form):
widget=forms.TextInput(attrs={'class': 'form-control'}) widget=forms.TextInput(attrs={'class': 'form-control'})
) )
email = forms.EmailField( email = forms.EmailField(
label="Correo Electrónico", label=EMAIL_FORMNAME,
max_length=254, max_length=254,
required=True, required=True,
widget=forms.EmailInput(attrs={'class': 'form-control'}) widget=forms.EmailInput(attrs={'class': 'form-control'})
@@ -267,7 +288,7 @@ class ChangePasswordForm(forms.Form):
new_password = cleaned_data.get("new_password") new_password = cleaned_data.get("new_password")
confirm_password = cleaned_data.get("confirm_password") confirm_password = cleaned_data.get("confirm_password")
if new_password and confirm_password and new_password != confirm_password: if new_password and confirm_password and new_password != confirm_password:
raise ValidationError("Las contraseñas no coinciden.") raise ValidationError(INCORRECT_PASSWORDS)
if new_password and len(new_password) < 8: if new_password and len(new_password) < 8:
raise ValidationError("La contraseña debe tener al menos 8 caracteres.") raise ValidationError("La contraseña debe tener al menos 8 caracteres.")
@@ -325,7 +346,7 @@ class ShippingAddressForm(forms.Form):
class ResetPasswordForm(forms.Form): class ResetPasswordForm(forms.Form):
email = forms.EmailField( email = forms.EmailField(
label="Correo Electrónico", label=EMAIL_FORMNAME,
max_length=254, max_length=254,
required=True, required=True,
widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'tu@email.com'}) widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'tu@email.com'})
@@ -351,4 +372,33 @@ class ResetPasswordPhase2Form(forms.Form):
password = cleaned_data.get("password") password = cleaned_data.get("password")
verify_password = cleaned_data.get("verify_password") verify_password = cleaned_data.get("verify_password")
if password and verify_password and password != verify_password: if password and verify_password and password != verify_password:
raise ValidationError("Las contraseñas no coinciden.") raise ValidationError(INCORRECT_PASSWORDS)
class ReviewForm(forms.Form):
rating = forms.IntegerField(
label="Puntuación",
required=True,
min_value=1,
max_value=5,
widget=forms.HiddenInput()
)
title = forms.CharField(
label="Título",
max_length=200,
required=True,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Título de tu valoración'
})
)
content = forms.CharField(
label="Descripción",
max_length=2000,
required=True,
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 5,
'placeholder': 'Comparte tu experiencia con este producto...'
})
)
@@ -0,0 +1,49 @@
# Generated by Django 6.0.5 on 2026-05-08 11:32
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0008_alter_product_briefdesc_alter_product_description'),
]
operations = [
migrations.AlterField(
model_name='cartitem',
name='quantity',
field=models.PositiveIntegerField(default=1, validators=[django.core.validators.MaxValueValidator(9999)]),
),
migrations.AlterField(
model_name='orderitem',
name='quantity',
field=models.PositiveIntegerField(default=1, validators=[django.core.validators.MaxValueValidator(9999)]),
),
migrations.AlterField(
model_name='stockreservationitem',
name='quantity',
field=models.PositiveIntegerField(default=1, validators=[django.core.validators.MaxValueValidator(9999)]),
),
migrations.CreateModel(
name='Review',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rating', models.PositiveIntegerField(validators=[django.core.validators.MaxValueValidator(5)])),
('title', models.CharField(default='', max_length=200)),
('content', models.TextField(default='', max_length=2000)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('images', models.ManyToManyField(blank=True, related_name='product_reviews', to='tienda.image')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='tienda.product')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_reviews', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
'unique_together': {('product', 'user')},
},
),
]
@@ -0,0 +1,28 @@
# Generated by Django 6.0.5 on 2026-05-26 08:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0009_alter_cartitem_quantity_alter_orderitem_quantity_and_more'),
]
operations = [
migrations.AlterField(
model_name='cart',
name='session_key',
field=models.CharField(blank=True, default='', max_length=40),
),
migrations.AlterField(
model_name='order',
name='session_key',
field=models.CharField(blank=True, default='', max_length=40),
),
migrations.AlterField(
model_name='stockreservation',
name='session_key',
field=models.CharField(blank=True, default='', max_length=40),
),
]
+89 -8
View File
@@ -3,9 +3,38 @@ from __future__ import annotations
import unicodedata import unicodedata
from django.db import models from django.db import models
from django.contrib.auth.models import User, AbstractUser from django.contrib.auth.models import User, AbstractUser
from django.core.validators import MaxValueValidator
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX, TRANSACTION_CODE_LENGTH, TRANSACTION_CODE_ALPHABET from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX, TRANSACTION_CODE_LENGTH, TRANSACTION_CODE_ALPHABET
import random, string import secrets
import string
MAX_QUANTITY = 9999
class BlankToNoneCharField(models.CharField):
"""Treat empty strings as None in Python, but store as empty strings in DB."""
def to_python(self, value):
value = super().to_python(value)
if value == "":
return None
return value
def from_db_value(self, value, expression, connection):
if value == "":
return None
return value
def get_prep_value(self, value):
if value is None or value == "":
return ""
return super().get_prep_value(value)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
path = "django.db.models.CharField"
return name, path, args, kwargs
def generate_transaction_code() -> str: def generate_transaction_code() -> str:
@@ -45,9 +74,10 @@ class VerificationCode(models.Model):
default = VerificationModes.VERIFY_ACCOUNT default = VerificationModes.VERIFY_ACCOUNT
) )
@staticmethod
def generate(user: User, code_mode: str) -> VerificationCode: def generate(user: User, code_mode: str) -> VerificationCode:
while True: while True:
code = "".join(random.choices(string.ascii_letters+string.digits, k=64)) code = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
if not VerificationCode.objects.filter(code=code).exists(): if not VerificationCode.objects.filter(code=code).exists():
return VerificationCode.objects.create( return VerificationCode.objects.create(
code = code, code = code,
@@ -119,6 +149,26 @@ class Product(models.Model):
"creator": self.creator.to_dict() if self.creator else None "creator": self.creator.to_dict() if self.creator else None
} }
def has_user_purchased(self, user):
"""Verifica si el usuario ha comprado este producto al menos una vez"""
if not user or not user.is_authenticated:
return False
return OrderItem.objects.filter(
order__buyer=user,
product=self
).exists()
def get_average_rating(self):
"""Retorna la nota media de las valoraciones"""
reviews = self.reviews.all()
if not reviews.exists():
return 0
return round(reviews.aggregate(models.Avg('rating'))['rating__avg'], 1)
def get_reviews_count(self):
"""Retorna el número total de valoraciones"""
return self.reviews.count()
class StockReservation(models.Model): class StockReservation(models.Model):
STATUS_ACTIVE = "active" STATUS_ACTIVE = "active"
@@ -140,7 +190,7 @@ class StockReservation(models.Model):
] ]
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, related_name="stock_reservations") user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, related_name="stock_reservations")
session_key = models.CharField(max_length=40, null=True, blank=True) session_key = models.CharField(max_length=40, default="", blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_ACTIVE) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_ACTIVE)
payment_method = models.CharField(max_length=20, choices=PAYMENT_CHOICES) payment_method = models.CharField(max_length=20, choices=PAYMENT_CHOICES)
expires_at = models.DateTimeField(db_index=True) expires_at = models.DateTimeField(db_index=True)
@@ -154,20 +204,32 @@ class StockReservation(models.Model):
class StockReservationItem(models.Model): class StockReservationItem(models.Model):
reservation = models.ForeignKey(StockReservation, on_delete=models.CASCADE, related_name="items") reservation = models.ForeignKey(StockReservation, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="stock_reservation_items") product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="stock_reservation_items")
quantity = models.PositiveIntegerField(default=1) quantity = models.PositiveIntegerField(default=1, validators=[MaxValueValidator(MAX_QUANTITY)])
class Meta: class Meta:
unique_together = ("reservation", "product") unique_together = ("reservation", "product")
def clean(self):
from django.core.exceptions import ValidationError
if self.quantity is not None and self.quantity > MAX_QUANTITY:
raise ValidationError(f'La cantidad no puede exceder {MAX_QUANTITY} unidades.')
def __str__(self): def __str__(self):
return f"{self.quantity}x {self.product.name} (reserva {self.reservation_id})" return f"{self.quantity}x {self.product.name} (reserva {self.reservation_id})"
class Cart(models.Model): class Cart(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
session_key = models.CharField(max_length=40, null=True, blank=True) session_key = BlankToNoneCharField(max_length=40, default="", blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
if self.session_key is None:
self.session_key = ""
super().save(*args, **kwargs)
if self.session_key == "":
self.session_key = None
def __str__(self): def __str__(self):
return f"Cart {self.id} - {self.user or self.session_key}" return f"Cart {self.id} - {self.user or self.session_key}"
@@ -190,7 +252,7 @@ class Cart(models.Model):
class CartItem(models.Model): class CartItem(models.Model):
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name='items') cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.PositiveIntegerField(default=1) quantity = models.PositiveIntegerField(default=1, validators=[MaxValueValidator(MAX_QUANTITY)])
added_at = models.DateTimeField(auto_now_add=True) added_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
@@ -230,7 +292,7 @@ class Order(models.Model):
buyer = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='orders') buyer = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='orders')
shipping_address = models.ForeignKey('ShippingAddress', on_delete=models.SET_NULL, null=True, blank=True, related_name='orders') shipping_address = models.ForeignKey('ShippingAddress', on_delete=models.SET_NULL, null=True, blank=True, related_name='orders')
session_key = models.CharField(max_length=40, null=True, blank=True) session_key = models.CharField(max_length=40, default="", blank=True)
total = models.FloatField(default=0) total = models.FloatField(default=0)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PAID) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PAID)
payment_method = models.CharField(max_length=20, choices=PAYMENT_CHOICES, default=PAYMENT_MANUAL) payment_method = models.CharField(max_length=20, choices=PAYMENT_CHOICES, default=PAYMENT_MANUAL)
@@ -265,7 +327,7 @@ class OrderItem(models.Model):
product = models.ForeignKey(Product, on_delete=models.SET_NULL, null=True, blank=True) product = models.ForeignKey(Product, on_delete=models.SET_NULL, null=True, blank=True)
product_name = models.CharField(max_length=200, default="") product_name = models.CharField(max_length=200, default="")
seller = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='order_items_to_fulfill') seller = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='order_items_to_fulfill')
quantity = models.PositiveIntegerField(default=1) quantity = models.PositiveIntegerField(default=1, validators=[MaxValueValidator(MAX_QUANTITY)])
unit_price = models.FloatField(default=0) unit_price = models.FloatField(default=0)
total_price = models.FloatField(default=0) total_price = models.FloatField(default=0)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING)
@@ -323,6 +385,25 @@ class SavedPaymentMethod(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
class Review(models.Model):
"""Valoraciones de productos por usuarios que han realizado una compra"""
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='reviews')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='product_reviews')
rating = models.PositiveIntegerField(validators=[MaxValueValidator(5)])
title = models.CharField(max_length=200, default="")
content = models.TextField(max_length=2000, default="")
images = models.ManyToManyField(Image, related_name='product_reviews', blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('product', 'user')
ordering = ['-created_at']
def __str__(self):
return f"Valoración de {self.user.username} en {self.product.name} ({self.rating}★)"
class ShippingAddress(models.Model): class ShippingAddress(models.Model):
"""Direcciones de entrega de los usuarios""" """Direcciones de entrega de los usuarios"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='shipping_addresses') user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='shipping_addresses')
+5 -2
View File
@@ -17,15 +17,18 @@ class Recibo(FPDF):
def generar_recibo(cliente: str, total: float, objetos: list, metodo_pago: str, transaction_code: str): def generar_recibo(cliente: str, total: float, objetos: list, metodo_pago: str, transaction_code: str):
pdf = Recibo() pdf = Recibo()
font_path = "/fonts/Roboto-Regular.ttf"
pdf.add_font('Roboto', '', '/fonts/Roboto-Regular.ttf') pdf.add_font('Roboto', '', '/fonts/Roboto-Regular.ttf')
pdf.add_font('Roboto', 'B', '/fonts/Roboto-Bold.ttf') pdf.add_font('Roboto', 'B', '/fonts/Roboto-Bold.ttf')
pdf.add_page() pdf.add_page()
pdf.set_font('Roboto', size=12) pdf.set_font('Roboto', size=12)
METODOS_MAP = {"stripe": "Stripe", "paypal": "PayPal", "manual": "Manual"}
metodo_mostrar = METODOS_MAP.get(metodo_pago, metodo_pago)
pdf.cell(0, 10, f"Cliente: {cliente}", ln=True) pdf.cell(0, 10, f"Cliente: {cliente}", ln=True)
pdf.cell(0, 10, f"ID de transaccion: {transaction_code}", ln=True) pdf.cell(0, 10, f"ID de transaccion: {transaction_code}", ln=True)
pdf.cell(0, 10, f"") pdf.cell(0, 10, f"Metodo de pago: {metodo_mostrar}", ln=True)
pdf.cell(0, 10, "")
DATA = [] DATA = []
DATA.append( DATA.append(
+20
View File
@@ -318,3 +318,23 @@ p.price {
overflow-wrap: break-word; overflow-wrap: break-word;
word-wrap: break-word; word-wrap: break-word;
} }
:root {
--chat--color--primary: #513CB0;
--chat--color--primary-shade-50: #3f2a8f;
--chat--color--primary--shade-100: #361DA7;
--chat--color--secondary: #513CB0;
--chat--color-secondary-shade-50: #3f2a8f;
--chat--color--typing: #513CB0;
--chat--color-dark: #101330;
--chat--window--border-radius: 12px;
--chat--toggle--background: #513CB0;
--chat--toggle--hover--background: #3f2a8f;
--chat--toggle--active--background: #361DA7;
--chat--message--bot--background: #f0f0f0;
--chat--message--user--background: #513CB0;
--chat--header--background: #513CB0;
--chat--input--send--button--color: #513CB0;
--chat--input--send--button--color-hover: #3f2a8f;
--chat--close--button--color-hover: #FC3F44;
}
+4 -3
View File
@@ -4,7 +4,8 @@ from django.template.loader import render_to_string
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from .utilities import send_email, send_hemail from .utilities import send_email, send_hemail
from .vars import login_message, verify_message from .vars import login_message, verify_message
import random, string import secrets
import string
from . import pdf from . import pdf
from .models import User, VerificationCode from .models import User, VerificationCode
@@ -43,7 +44,7 @@ def enviar_correo_confirmacion(id: int):
code = VerificationCode.objects.create( code = VerificationCode.objects.create(
user = usuario, user = usuario,
code_mode = VerificationCode.VerificationModes.VERIFY_ACCOUNT, code_mode = VerificationCode.VerificationModes.VERIFY_ACCOUNT,
code = ''.join(random.choices(string.digits, k=12)) code = ''.join(secrets.choice(string.digits) for _ in range(12))
) )
message = verify_message.format(name = usuario.get_full_name(), protocol = settings.PROTOCOL, domain = settings.DOMAIN, code = code.code) message = verify_message.format(name = usuario.get_full_name(), protocol = settings.PROTOCOL, domain = settings.DOMAIN, code = code.code)
@@ -60,7 +61,7 @@ def enviar_correo_recuperacion(email: str):
ver_code = VerificationCode.objects.create( ver_code = VerificationCode.objects.create(
code_mode = VerificationCode.VerificationModes.RESET_PASSWORD, code_mode = VerificationCode.VerificationModes.RESET_PASSWORD,
user = usuario, user = usuario,
code = ''.join(random.choices(string.digits, k=12)) code = ''.join(secrets.choice(string.digits) for _ in range(12))
) )
ver_code.save() ver_code.save()
html_content = render_to_string( html_content = render_to_string(
+106
View File
@@ -0,0 +1,106 @@
{% extends "tienda/base.html" %}
{% block content %}
<div class="container py-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'index' %}">Inicio</a></li>
<li class="breadcrumb-item"><a href="{% url 'producto' product.id %}">{{ product.name }}</a></li>
<li class="breadcrumb-item active" aria-current="page">Valorar Producto</li>
</ol>
</nav>
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-body">
<h4 class="card-title mb-4">
{% if existing_review %}Actualizar{% else %}Añadir{% endif %} valoración: {{ product.name }}
</h4>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-4">
<label class="form-label">Puntuación</label>
<div class="star-rating d-flex gap-1" id="star-rating">
{% for i in "12345" %}
<span class="star fs-2 {% if form.initial.rating|default:0 >= i|add:0 %}text-warning text-dark{% else %}text-secondary{% endif %}" data-value="{{ i }}" style="cursor: pointer; font-size: 2rem;"></span>
{% endfor %}
</div>
<input type="hidden" name="rating" id="rating-input" value="{{ form.initial.rating|default:1 }}">
{% if form.rating.errors %}
<div class="text-danger small">{{ form.rating.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="title" class="form-label">Título</label>
{{ form.title }}
{% if form.title.errors %}
<div class="text-danger small">{{ form.title.errors }}</div>
{% endif %}
</div>
<div class="mb-3">
<label for="content" class="form-label">Descripción</label>
{{ form.content }}
{% if form.content.errors %}
<div class="text-danger small">{{ form.content.errors }}</div>
{% endif %}
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">
{% if existing_review %}Actualizar{% else %}Enviar{% endif %} valoración
</button>
<a href="{% url 'producto' product.id %}" class="btn btn-outline-secondary">Cancelar</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const stars = document.querySelectorAll('#star-rating .star');
const ratingInput = document.getElementById('rating-input');
function updateStars(value) {
stars.forEach((star, index) => {
if (index < value) {
star.classList.remove('text-secondary');
star.classList.add('text-warning');
} else {
star.classList.remove('text-warning');
star.classList.add('text-secondary');
}
});
ratingInput.value = value;
}
stars.forEach(star => {
star.addEventListener('click', function() {
const value = parseInt(this.dataset.value);
updateStars(value);
});
star.addEventListener('mouseenter', function() {
const value = parseInt(this.dataset.value);
stars.forEach((s, index) => {
if (index < value) {
s.classList.remove('text-secondary');
s.classList.add('text-warning');
}
});
});
star.addEventListener('mouseleave', function() {
updateStars(parseInt(ratingInput.value) || 1);
});
});
});
</script>
{% endblock %}
+1 -1
View File
@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% block head %} {% block head %}
<script src="https://js.stripe.com/v3/"></script> <script src="https://js.stripe.com/v3/" integrity="sha384-353f1ae25ae0929bea5f9379a594131b27e45a89d8f918dcc040c4ccbe6fd35fe6fd1d61ccc6e0c911c9b54325235904"></script>
<style> <style>
#card-element { #card-element {
border: 1px solid #ced4da; border: 1px solid #ced4da;
+22 -6
View File
@@ -7,12 +7,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Sitio web de comercio local Almeriense"> <meta name="description" content="Sitio web de comercio local Almeriense">
<title>Comercialmeria</title>
<link rel="preload" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
</noscript>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link rel="preload" href="{% static 'css/custom.css' %}" as="style" onload="this.onload=null;this.rel='stylesheet'"> <link rel="preload" href="{% static 'css/custom.css' %}" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="{% static 'css/custom.css' %}"></noscript> <noscript><link rel="stylesheet" href="{% static 'css/custom.css' %}"></noscript>
@@ -344,5 +338,27 @@
}); });
</script> </script>
{% endcache %} {% endcache %}
<link href="https://cdn.jsdelivr.net/npm/@n8n/chat/dist/style.css" rel="stylesheet" integrity="sha384-b7166c239e461f42296ad7248c04ef6768e9340a51aef45fad197acf8f4c16f119f36376b19516548885a9ecabdccc10" />
<script type="module">
import { createChat } from 'https://cdn.jsdelivr.net/npm/@n8n/chat/dist/chat.bundle.es.js';
createChat({
webhookUrl: 'https://n8n.elordenador.org/webhook/0e2cbe42-39d2-4e86-be62-c12542e246d4/chat',
initialMessages: [
'¡Hola! 👋',
'Soy el asistente virtual de Comercialmeria. ¿En qué puedo ayudarte?'
],
i18n: {
en: {
title: 'Chat de Soporte',
subtitle: 'Estamos aquí para ayudarte 24/7.',
footer: '',
getStarted: 'Nueva conversación',
inputPlaceholder: 'Escribe tu mensaje...',
},
},
});
</script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -3,7 +3,7 @@
{% load vat_filters %} {% load vat_filters %}
{% block head %} {% block head %}
<script src="https://js.stripe.com/v3/"></script> <script src="https://js.stripe.com/v3/" integrity="sha384-353f1ae25ae0929bea5f9379a594131b27e45a89d8f918dcc040c4ccbe6fd35fe6fd1d61ccc6e0c911c9b54325235904"></script>
<script src="https://www.paypal.com/sdk/js?client-id={{ paypal_client_id }}&currency=EUR" defer></script> <script src="https://www.paypal.com/sdk/js?client-id={{ paypal_client_id }}&currency=EUR" defer></script>
<style> <style>
#card-element { #card-element {
+97
View File
@@ -62,4 +62,101 @@
{{ product.description }} {{ product.description }}
</div> </div>
</div> </div>
<div class="row mt-4">
<div class="col-md-12">
<h4 class="mb-3">Valoraciones</h4>
<div id="reviews-summary" class="mb-4">
<div class="d-flex align-items-center gap-3">
<div class="fs-4" id="average-rating">0.0</div>
<div>
<div id="stars-display"></div>
<small class="text-muted" id="reviews-count">0 valoraciones</small>
</div>
{% if can_review %}
<a href="{% url 'add_review' product.id %}" class="btn btn-sm btn-outline-primary ms-auto">Valorar este producto</a>
{% elif user_has_review %}
<div class="ms-auto">
<a href="{% url 'add_review' product.id %}" class="btn btn-sm btn-outline-primary">Editar mi valoración</a>
<form method="post" action="{% url 'delete_review' user_review_id %}" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('¿Eliminar esta valoración?');">Eliminar</button>
</form>
</div>
{% elif user.is_authenticated %}
<span class="text-muted ms-auto">Solo puedes valorar productos que hayas comprado</span>
{% else %}
<a href="{% url 'login' %}?next={% url 'producto' product.id %}" class="btn btn-sm btn-outline-primary ms-auto">Inicia sesión para valorar</a>
{% endif %}
</div>
</div>
<div id="reviews-list"></div>
</div>
</div>
<script>
async function loadReviews() {
try {
const response = await fetch("{% url 'product_reviews' product.id %}");
const data = await response.json();
document.getElementById('average-rating').textContent = data.average_rating;
document.getElementById('reviews-count').textContent = data.reviews_count + ' valoraciones';
let starsHtml = '';
for (let i = 1; i <= 5; i++) {
starsHtml += `<span class="${i <= Math.round(data.average_rating) ? 'text-warning' : 'text-secondary'}">★</span>`;
}
document.getElementById('stars-display').innerHTML = starsHtml;
const reviewsList = document.getElementById('reviews-list');
if (data.reviews.length === 0) {
reviewsList.innerHTML = '<p class="text-muted">Aún no hay valoraciones para este producto.</p>';
} else {
let reviewsHtml = '';
data.reviews.forEach(review => {
let imagesHtml = '';
if (review.images && review.images.length > 0) {
imagesHtml = '<div class="mt-2">';
review.images.forEach(img => {
imagesHtml += `<img src="${img.image}" class="img-thumbnail me-1" style="max-width: 80px; max-height: 80px;" alt="">`;
});
imagesHtml += '</div>';
}
const actionsHtml = review.is_owner
? `<div class="mt-2">
<a href="/tienda/producto/${review.id}/valorar/" class="btn btn-sm btn-outline-primary me-1">Editar</a>
<form method="post" action="/tienda/producto/${review.id}/valorar/eliminar/" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('¿Eliminar esta valoración?');">Eliminar</button>
</form>
</div>`
: '';
reviewsHtml += `
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${review.user}</strong>
<span class="text-warning ms-1">${'★'.repeat(review.rating)}</span>
</div>
<small class="text-muted">${new Date(review.created_at).toLocaleDateString('es-ES')}</small>
</div>
<h6 class="mt-2">${review.title}</h6>
<p class="mb-1">${review.content}</p>
${imagesHtml}
${actionsHtml}
</div>
</div>
`;
});
reviewsList.innerHTML = reviewsHtml;
}
} catch (error) {
console.error('Error loading reviews:', error);
}
}
loadReviews();
</script>
{% endblock %} {% endblock %}
+5 -4
View File
@@ -16,6 +16,7 @@ from .models import (
) )
from .forms import UserRegisterForm, UserLoginForm, EditProfileForm, ChangePasswordForm, ShippingAddressForm, ResetPasswordForm, ResetPasswordPhase2Form from .forms import UserRegisterForm, UserLoginForm, EditProfileForm, ChangePasswordForm, ShippingAddressForm, ResetPasswordForm, ResetPasswordPhase2Form
from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX
import secrets
import string import string
import random import random
@@ -335,7 +336,7 @@ class VerificationCodeModelTests(TestCase):
"""50 códigos pueden crearse sin conflictos.""" """50 códigos pueden crearse sin conflictos."""
codes = [] codes = []
for i in range(50): for i in range(50):
mode = random.choice([ mode = secrets.choice([
VerificationCode.VerificationModes.VERIFY_ACCOUNT, VerificationCode.VerificationModes.VERIFY_ACCOUNT,
VerificationCode.VerificationModes.RESET_PASSWORD VerificationCode.VerificationModes.RESET_PASSWORD
]) ])
@@ -377,7 +378,7 @@ class CategoryModelTests(TestCase):
"""100 categorías pueden crearse sin problemas.""" """100 categorías pueden crearse sin problemas."""
categories = [] categories = []
for i in range(100): for i in range(100):
cat = Category.objects.create(name=f"Category_{i}_{random.randint(1000, 9999)}") cat = Category.objects.create(name=f"Category_{i}_{1000 + secrets.randbelow(9000)}")
categories.append(cat) categories.append(cat)
self.assertEqual(len(categories), 100) self.assertEqual(len(categories), 100)
@@ -1786,7 +1787,7 @@ class EndpointViewTests(TestCase):
self.assertTrue(OrderMessage.objects.filter(order_item=item, sender=self.seller).exists()) self.assertTrue(OrderMessage.objects.filter(order_item=item, sender=self.seller).exists())
delete_get = self.client.get(reverse("borrar_producto", args=[created.id])) delete_get = self.client.get(reverse("borrar_producto", args=[created.id]))
self.assertEqual(delete_get.status_code, 302) self.assertEqual(delete_get.status_code, 405)
delete_post = self.client.post(reverse("borrar_producto", args=[created.id])) delete_post = self.client.post(reverse("borrar_producto", args=[created.id]))
self.assertEqual(delete_post.status_code, 302) self.assertEqual(delete_post.status_code, 302)
self.assertFalse(Product.objects.filter(id=created.id).exists()) self.assertFalse(Product.objects.filter(id=created.id).exists())
@@ -2068,7 +2069,7 @@ class EndpointViewTests(TestCase):
self.assertEqual(new_address.full_name, "Comprador Dos Editado") self.assertEqual(new_address.full_name, "Comprador Dos Editado")
delete_get = self.client.get(reverse("eliminar_direccion", args=[new_address.id])) delete_get = self.client.get(reverse("eliminar_direccion", args=[new_address.id]))
self.assertEqual(delete_get.status_code, 302) self.assertEqual(delete_get.status_code, 405)
delete_post = self.client.post(reverse("eliminar_direccion", args=[new_address.id])) delete_post = self.client.post(reverse("eliminar_direccion", args=[new_address.id]))
self.assertEqual(delete_post.status_code, 302) self.assertEqual(delete_post.status_code, 302)
self.assertFalse(ShippingAddress.objects.filter(id=new_address.id).exists()) self.assertFalse(ShippingAddress.objects.filter(id=new_address.id).exists())
+4 -1
View File
@@ -68,5 +68,8 @@ urlpatterns = [
path("sobre-nosotros", views.sobre_nosotros, name="sobre_nosotros"), path("sobre-nosotros", views.sobre_nosotros, name="sobre_nosotros"),
path("ayuda", views.ayuda, name="ayuda"), path("ayuda", views.ayuda, name="ayuda"),
path("reset-password", views.reset_password, name="reset_password"), path("reset-password", views.reset_password, name="reset_password"),
path("reset-password-phase2/<str:code>", views.reset_password_phase2, name="reset_password_phase2") path("reset-password-phase2/<str:code>", views.reset_password_phase2, name="reset_password_phase2"),
path("producto/<int:product_id>/valorar/", views.add_review, name="add_review"),
path("valoracion/<int:review_id>/eliminar/", views.delete_review, name="delete_review"),
path("api/producto/<int:product_id>/valoraciones/", views.product_reviews, name="product_reviews"),
] ]
+220 -137
View File
@@ -4,8 +4,10 @@ from django.contrib.auth import authenticate, login as auth_login, logout as aut
from django.db.utils import DataError from django.db.utils import DataError
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib import messages from django.contrib import messages
from .models import User, Product, Category, Cart, CartItem, Image, Order, OrderItem, OrderMessage, ShippingAddress, StockReservation, StockReservationItem, VerificationCode, SavedPaymentMethod
from .forms import ProductForm, SecondaryImageForm, UserLoginForm, UserRegisterForm, ProductEditForm, EditProfileForm, ChangePasswordForm, ShippingAddressForm, ResetPasswordForm, ResetPasswordPhase2Form from tienda.utilities import send_email
from .models import User, Product, Category, Cart, CartItem, Image, Order, OrderItem, OrderMessage, ShippingAddress, StockReservation, StockReservationItem, VerificationCode, SavedPaymentMethod, Review
from .forms import ProductForm, SecondaryImageForm, UserLoginForm, UserRegisterForm, ProductEditForm, EditProfileForm, ChangePasswordForm, ShippingAddressForm, ResetPasswordForm, ResetPasswordPhase2Form, ReviewForm
from . import tasks from . import tasks
from .vars import ( from .vars import (
PAGE_SIZE, PAGE_SIZE,
@@ -17,7 +19,7 @@ from .vars import (
) )
from django.conf import settings from django.conf import settings
from django.views.decorators.csrf import csrf_exempt, csrf_protect from django.views.decorators.csrf import csrf_exempt, csrf_protect
from django.views.decorators.http import require_POST from django.views.decorators.http import require_GET, require_POST, require_http_methods
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
@@ -41,6 +43,17 @@ STOCK_RESERVATION_SESSION_KEY = "stock_reservation_id"
STOCK_RESERVATION_PAYMENT_SESSION_KEY = "stock_reservation_payment_method" STOCK_RESERVATION_PAYMENT_SESSION_KEY = "stock_reservation_payment_method"
def _mask_email(email: str) -> str:
if not email or '@' not in email:
return "***"
local, domain = email.rsplit('@', 1)
if len(local) <= 2:
masked_local = local[0] + '*'
else:
masked_local = local[0] + '*' * (len(local) - 2) + local[-1]
return f"{masked_local}@{domain}"
def _invalidate_product_cache(product_ids): def _invalidate_product_cache(product_ids):
unique_product_ids = {product_id for product_id in product_ids if product_id is not None} unique_product_ids = {product_id for product_id in product_ids if product_id is not None}
if not unique_product_ids: if not unique_product_ids:
@@ -191,6 +204,7 @@ def get_price_with_vat_decimal(price) -> Decimal:
rounding=ROUND_HALF_UP, rounding=ROUND_HALF_UP,
) )
@require_http_methods(["GET"])
def home(request: HttpRequest): def home(request: HttpRequest):
"""Página de inicio del sitio""" """Página de inicio del sitio"""
categorias = Category.objects.all() categorias = Category.objects.all()
@@ -206,7 +220,7 @@ def home(request: HttpRequest):
"total_sellers": total_sellers "total_sellers": total_sellers
}) })
@require_http_methods(["GET"])
def index(request: HttpRequest): def index(request: HttpRequest):
"""Página de productos (lista paginada)""" """Página de productos (lista paginada)"""
page = 1 page = 1
@@ -220,7 +234,7 @@ def index(request: HttpRequest):
categorias = Category.objects.all() categorias = Category.objects.all()
return render(request, "tienda/index.html", {"products": products, "categories": categorias}) return render(request, "tienda/index.html", {"products": products, "categories": categorias})
@require_http_methods(["GET", "POST"])
def login(request: HttpRequest): def login(request: HttpRequest):
if request.method == "POST": if request.method == "POST":
form: UserLoginForm = UserLoginForm(request.POST) form: UserLoginForm = UserLoginForm(request.POST)
@@ -233,7 +247,7 @@ def login(request: HttpRequest):
user: User = User.objects.get(email=email) user: User = User.objects.get(email=email)
username = user.username username = user.username
except User.DoesNotExist: except User.DoesNotExist:
audit_logger.warning("LOGIN FAILED email=%s reason=user_not_found ip=%s", email, client_ip) audit_logger.warning("LOGIN FAILED email=%s reason=user_not_found ip=%s", _mask_email(email), client_ip)
messages.error(request, "El email o la contraseña es incorrecta") messages.error(request, "El email o la contraseña es incorrecta")
return render(request, "tienda/login.html", {"form": form}) return render(request, "tienda/login.html", {"form": form})
if user.registration_status == User.RegisterStatus.BANNED: if user.registration_status == User.RegisterStatus.BANNED:
@@ -252,7 +266,7 @@ def login(request: HttpRequest):
logins = int(data) logins = int(data)
if logins >= 5: if logins >= 5:
audit_logger.info("LOGIN FAILED email=%s reason=rate_limited", email) audit_logger.info("LOGIN FAILED email=%s reason=rate_limited", _mask_email(email))
messages.error(request, "Has sufrido de Rate Limit por fallar 5 veces la contraseña") messages.error(request, "Has sufrido de Rate Limit por fallar 5 veces la contraseña")
return render(request, "tienda/login.html", {"form": form}) return render(request, "tienda/login.html", {"form": form})
logins+=1 logins+=1
@@ -260,7 +274,7 @@ def login(request: HttpRequest):
messages.error(request, "El email o la contraseña es incorrecta") messages.error(request, "El email o la contraseña es incorrecta")
return render(request, "tienda/login.html", {"form": form}) return render(request, "tienda/login.html", {"form": form})
if user.registration_status == User.RegisterStatus.CONFIRMATION_REQUIRED: if user.registration_status == User.RegisterStatus.CONFIRMATION_REQUIRED:
audit_logger.info("LOGIN_FAILED email=%s reason=not_verified", email) audit_logger.info("LOGIN_FAILED email=%s reason=not_verified", _mask_email(email))
messages.error(request, "No se puede iniciar sesión porque no has verificado tu cuenta, comprueba tu email. Si eliminaste el email pero querias verificarte, contacta con el soporte tecnico") messages.error(request, "No se puede iniciar sesión porque no has verificado tu cuenta, comprueba tu email. Si eliminaste el email pero querias verificarte, contacta con el soporte tecnico")
return render(request, "tienda/login.html", {"form": form}) return render(request, "tienda/login.html", {"form": form})
auth_login(request, user) auth_login(request, user)
@@ -270,7 +284,7 @@ def login(request: HttpRequest):
else: else:
request.session.set_expiry(1209600) request.session.set_expiry(1209600)
audit_logger.info("LOGIN_SUCCESS user_id=%s email=%s ip=%s remember=%s", user.id, user.email, client_ip, bool(remember)) audit_logger.info("LOGIN_SUCCESS user_id=%s email=%s ip=%s remember=%s", user.id, _mask_email(user.email), client_ip, bool(remember))
tasks.enviar_correo_bienvenida.delay(user.email, f"{user.first_name} {user.last_name}") tasks.enviar_correo_bienvenida.delay(user.email, f"{user.first_name} {user.last_name}")
messages.success(request, f"¡Bienvenido {user.first_name or user.username}!") messages.success(request, f"¡Bienvenido {user.first_name or user.username}!")
return redirect("index") return redirect("index")
@@ -278,47 +292,7 @@ def login(request: HttpRequest):
form = UserLoginForm() form = UserLoginForm()
return render(request, "tienda/login.html", {"form": form}) return render(request, "tienda/login.html", {"form": form})
# @require_http_methods(["GET", "POST"])
# if user is not None:
# auth_login(request, user)
#
# # Configurar duración de sesión
# if not remember:
# request.session.set_expiry(0)
# else:
# request.session.set_expiry(1209600) # 14 días en segundos
#
# audit_logger.info(
# "LOGIN_SUCCESS user_id=%s email=%s ip=%s remember=%s",
# user.id,
# user.email,
# client_ip,
# bool(remember),
# )
# tasks.enviar_correo_bienvenida.delay(user.email, "{} {}".format(user.first_name, user.last_name))
# # result = send_email(user.email, "Inicio de sesión correcto", login_message.format(name = "{} {}".format(user.first_name, user.last_name)))
# messages.success(request, f"¡Bienvenido {user.first_name or user.username}!")
# return redirect("index")
# else:
# user1: User = User.objects.get(username=username)
# if user1.registration_status == User.RegisterStatus.BANNED:
# audit_logger.warning(
# "LOGIN FAILED email=%s reason=user_banned ip=%s",
# email,
# client_ip,
# )
# messages.error(request, "Error, La cuenta esta bloqueada")
# return render(request, "tienda/login.html")
# audit_logger.warning(
# "LOGIN_FAILED email=%s reason=invalid_credentials ip=%s",
# email,
# client_ip,
# )
# messages.error(request, "Correo electrónico o contraseña incorrectos.")
# return render(request, "tienda/login.html")
#
# return render(request, "tienda/login.html")
def register(request: HttpRequest): def register(request: HttpRequest):
if request.method == "POST": if request.method == "POST":
form = UserRegisterForm(request.POST) form = UserRegisterForm(request.POST)
@@ -330,7 +304,7 @@ def register(request: HttpRequest):
# Validación email # Validación email
if User.objects.filter(email=email).exists(): if User.objects.filter(email=email).exists():
audit_logger.warning("REGISTER_FAILED email=%s reason=email_exists ip=%s", email, client_ip) audit_logger.warning("REGISTER_FAILED email=%s reason=email_exists ip=%s", _mask_email(email), client_ip)
messages.error(request, "Ya existe un usuario con este correo electrónico") messages.error(request, "Ya existe un usuario con este correo electrónico")
return render(request, "tienda/register.html", {"form":form}) return render(request, "tienda/register.html", {"form":form})
@@ -350,7 +324,7 @@ def register(request: HttpRequest):
"REGISTER_SUCCESS user_id=%s username=%s email=%s ip=%s", "REGISTER_SUCCESS user_id=%s username=%s email=%s ip=%s",
user.id, user.id,
user.username, user.username,
user.email, _mask_email(user.email),
client_ip, client_ip,
) )
@@ -362,17 +336,17 @@ def register(request: HttpRequest):
form = UserRegisterForm() form = UserRegisterForm()
return render(request, "tienda/register.html", {"form":form}) return render(request, "tienda/register.html", {"form":form})
@require_http_methods(["GET"])
def logout(request: HttpRequest): def logout(request: HttpRequest):
user_id = request.user.id if request.user.is_authenticated else None user_id = request.user.id if request.user.is_authenticated else None
email = request.user.email if request.user.is_authenticated else None email = request.user.email if request.user.is_authenticated else None
client_ip = _get_client_ip(request) client_ip = _get_client_ip(request)
auth_logout(request) auth_logout(request)
audit_logger.info("LOGOUT user_id=%s email=%s ip=%s", user_id, email, client_ip) audit_logger.info("LOGOUT user_id=%s email=%s ip=%s", user_id, _mask_email(email) if email else "***", client_ip)
messages.success(request, "Has cerrado sesión exitosamente.") messages.success(request, "Has cerrado sesión exitosamente.")
return redirect("index") return redirect("index")
@require_http_methods(["GET"])
def producto(request: HttpRequest, id: int): def producto(request: HttpRequest, id: int):
"""Vista de detalle del producto con cacheo en Redis (5 minutos)""" """Vista de detalle del producto con cacheo en Redis (5 minutos)"""
cache_key = f'product_{id}' cache_key = f'product_{id}'
@@ -387,8 +361,25 @@ def producto(request: HttpRequest, id: int):
# Cachear el producto por 5 minutos (300 segundos) # Cachear el producto por 5 minutos (300 segundos)
cache.set(cache_key, product, 300) cache.set(cache_key, product, 300)
return render(request, "tienda/producto.html", {"product": product}) can_review = False
user_has_review = False
user_review_id = None
if request.user.is_authenticated:
user_review = Review.objects.filter(product=product, user=request.user).first()
if user_review:
user_has_review = True
user_review_id = user_review.id
can_review = product.has_user_purchased(request.user) and not user_review
return render(request, "tienda/producto.html", {
"product": product,
"can_review": can_review,
"user_has_review": user_has_review,
"user_review_id": user_review_id
})
@require_http_methods(["GET"])
def categoria(request: HttpRequest, id: int): def categoria(request: HttpRequest, id: int):
page = 1 page = 1
if "page" in request.GET: if "page" in request.GET:
@@ -403,6 +394,7 @@ def categoria(request: HttpRequest, id: int):
# Funciones auxiliares para el carrito # Funciones auxiliares para el carrito
def get_or_create_cart(request): def get_or_create_cart(request):
"""Obtiene o crea un carrito para el usuario actual o sesión""" """Obtiene o crea un carrito para el usuario actual o sesión"""
if request.user.is_authenticated: if request.user.is_authenticated:
@@ -427,6 +419,13 @@ def _get_reservation_owner_filters(request: HttpRequest):
return {"session_key": _get_or_create_session_key(request)} return {"session_key": _get_or_create_session_key(request)}
def _get_cart_item_owner_filters(request: HttpRequest):
"""Retorna filtros para validar ownership de CartItem según el usuario."""
if request.user.is_authenticated:
return {"cart__user": request.user}
return {"cart__session_key": _get_or_create_session_key(request)}
def _release_expired_stock_reservations(): def _release_expired_stock_reservations():
now = timezone.now() now = timezone.now()
StockReservation.objects.filter( StockReservation.objects.filter(
@@ -611,7 +610,7 @@ def _get_selected_shipping_address(request: HttpRequest):
return ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first() return ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first()
@require_GET
def create_order_from_cart(request, payment_method, payment_reference="", shipping_address=None, stock_reservation=None): def create_order_from_cart(request, payment_method, payment_reference="", shipping_address=None, stock_reservation=None):
"""Crea un pedido a partir del carrito actual, validando y descontando stock.""" """Crea un pedido a partir del carrito actual, validando y descontando stock."""
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
@@ -801,7 +800,7 @@ def add_to_cart(request: HttpRequest, product_id: int):
messages.error(request, "Producto no encontrado.") messages.error(request, "Producto no encontrado.")
return redirect('index') return redirect('index')
@require_GET
def view_cart(request: HttpRequest): def view_cart(request: HttpRequest):
"""Muestra el contenido del carrito""" """Muestra el contenido del carrito"""
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
@@ -814,12 +813,12 @@ def view_cart(request: HttpRequest):
"stock_issues": stock_issues, "stock_issues": stock_issues,
}) })
@require_POST
def update_cart_item(request: HttpRequest, item_id: int): def update_cart_item(request: HttpRequest, item_id: int):
"""Actualiza la cantidad de un item del carrito""" """Actualiza la cantidad de un item del carrito"""
try: try:
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
cart_item = CartItem.objects.get(id=item_id, cart=cart) cart_item = CartItem.objects.get(id=item_id, cart=cart, **_get_cart_item_owner_filters(request))
_cancel_active_stock_reservations_for_request(request) _cancel_active_stock_reservations_for_request(request)
_clear_stock_reservation_session(request) _clear_stock_reservation_session(request)
@@ -851,14 +850,14 @@ def update_cart_item(request: HttpRequest, item_id: int):
messages.error(request, "Cantidad no válida.") messages.error(request, "Cantidad no válida.")
return redirect('view_cart') return redirect('view_cart')
@require_POST
def remove_from_cart(request: HttpRequest, item_id: int): def remove_from_cart(request: HttpRequest, item_id: int):
"""Elimina un producto del carrito""" """Elimina un producto del carrito"""
try: try:
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
_cancel_active_stock_reservations_for_request(request) _cancel_active_stock_reservations_for_request(request)
_clear_stock_reservation_session(request) _clear_stock_reservation_session(request)
cart_item = CartItem.objects.get(id=item_id, cart=cart) cart_item = CartItem.objects.get(id=item_id, cart=cart, **_get_cart_item_owner_filters(request))
product_name = cart_item.product.name product_name = cart_item.product.name
cart_item.delete() cart_item.delete()
messages.success(request, f"{product_name} eliminado del carrito.") messages.success(request, f"{product_name} eliminado del carrito.")
@@ -868,7 +867,7 @@ def remove_from_cart(request: HttpRequest, item_id: int):
return redirect('view_cart') return redirect('view_cart')
@require_POST
def clear_cart(request: HttpRequest): def clear_cart(request: HttpRequest):
"""Vacía todo el carrito""" """Vacía todo el carrito"""
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
@@ -878,7 +877,7 @@ def clear_cart(request: HttpRequest):
messages.success(request, "Carrito vaciado.") messages.success(request, "Carrito vaciado.")
return redirect('view_cart') return redirect('view_cart')
@require_GET
@login_required @login_required
def mis_productos(request: HttpRequest): def mis_productos(request: HttpRequest):
"""Muestra los productos creados por el usuario autenticado""" """Muestra los productos creados por el usuario autenticado"""
@@ -889,7 +888,7 @@ def mis_productos(request: HttpRequest):
"total_productos": productos.count() "total_productos": productos.count()
}) })
@require_GET
@login_required @login_required
def pedidos_vendedor(request: HttpRequest): def pedidos_vendedor(request: HttpRequest):
"""Muestra los pedidos asignados al vendedor autenticado""" """Muestra los pedidos asignados al vendedor autenticado"""
@@ -903,13 +902,10 @@ def pedidos_vendedor(request: HttpRequest):
"total_pedidos": total_pedidos_por_enviar "total_pedidos": total_pedidos_por_enviar
}) })
@login_required @login_required
@require_POST
def cambiar_estado_pedido(request: HttpRequest, item_id: int): def cambiar_estado_pedido(request: HttpRequest, item_id: int):
"""Cambia el estado de un pedido asignado al vendedor""" """Cambia el estado de un pedido asignado al vendedor"""
if request.method != "POST":
messages.error(request, "Acción no permitida.")
return redirect("pedidos_vendedor")
order_item = get_object_or_404(OrderItem, id=item_id, seller=request.user) order_item = get_object_or_404(OrderItem, id=item_id, seller=request.user)
nuevo_estado = request.POST.get("estado") nuevo_estado = request.POST.get("estado")
@@ -923,13 +919,10 @@ def cambiar_estado_pedido(request: HttpRequest, item_id: int):
return redirect("pedidos_vendedor") return redirect("pedidos_vendedor")
@login_required @login_required
@require_POST
def enviar_mensaje_pedido(request: HttpRequest, item_id: int): def enviar_mensaje_pedido(request: HttpRequest, item_id: int):
"""Envía un mensaje al comprador sobre un pedido""" """Envía un mensaje al comprador sobre un pedido"""
if request.method != "POST":
messages.error(request, "Acción no permitida.")
return redirect("pedidos_vendedor")
order_item = get_object_or_404(OrderItem, id=item_id, seller=request.user) order_item = get_object_or_404(OrderItem, id=item_id, seller=request.user)
mensaje = request.POST.get("mensaje", "").strip() mensaje = request.POST.get("mensaje", "").strip()
@@ -947,7 +940,7 @@ def enviar_mensaje_pedido(request: HttpRequest, item_id: int):
messages.success(request, "Mensaje enviado correctamente.") messages.success(request, "Mensaje enviado correctamente.")
return redirect("pedidos_vendedor") return redirect("pedidos_vendedor")
@require_http_methods(["GET","POST"])
@login_required @login_required
def crear_producto(request: HttpRequest): def crear_producto(request: HttpRequest):
if request.method == "POST": if request.method == "POST":
@@ -977,6 +970,7 @@ def crear_producto(request: HttpRequest):
form = ProductForm() form = ProductForm()
return render(request, "tienda/crear_producto.html", {"form":form}) return render(request, "tienda/crear_producto.html", {"form":form})
@require_http_methods(["GET","POST"])
@login_required @login_required
def editar_producto(request: HttpRequest, id: int): def editar_producto(request: HttpRequest, id: int):
"""Edita un producto del usuario autenticado""" """Edita un producto del usuario autenticado"""
@@ -1035,10 +1029,11 @@ def editar_producto(request: HttpRequest, id: int):
"producto": producto "producto": producto
}) })
@login_required @login_required
@require_POST
def borrar_producto(request: HttpRequest, id: int): def borrar_producto(request: HttpRequest, id: int):
"""Borra un producto del usuario autenticado""" """Borra un producto del usuario autenticado"""
if request.method != "POST": if request.method != "POST":
messages.error(request, "Acción no permitida.") messages.error(request, "Acción no permitida.")
return redirect("mis_productos") return redirect("mis_productos")
@@ -1050,7 +1045,7 @@ def borrar_producto(request: HttpRequest, id: int):
messages.success(request, f"Producto '{nombre}' eliminado correctamente.") messages.success(request, f"Producto '{nombre}' eliminado correctamente.")
return redirect("mis_productos") return redirect("mis_productos")
@require_http_methods(["GET","POST"])
@login_required @login_required
def gestionar_imagenes(request: HttpRequest, id: int): def gestionar_imagenes(request: HttpRequest, id: int):
"""Gestiona las imágenes secundarias de un producto""" """Gestiona las imágenes secundarias de un producto"""
@@ -1078,13 +1073,10 @@ def gestionar_imagenes(request: HttpRequest, id: int):
"form": form "form": form
}) })
@require_POST
@login_required @login_required
def eliminar_imagen_secundaria(request: HttpRequest, product_id: int, image_id: int): def eliminar_imagen_secundaria(request: HttpRequest, product_id: int, image_id: int):
"""Elimina una imagen secundaria de un producto""" """Elimina una imagen secundaria de un producto"""
if request.method != "POST":
messages.error(request, "Acción no permitida.")
return redirect("gestionar_imagenes", id=product_id)
producto = get_object_or_404(Product, id=product_id, creator=request.user) producto = get_object_or_404(Product, id=product_id, creator=request.user)
image = get_object_or_404(Image, id=image_id) image = get_object_or_404(Image, id=image_id)
@@ -1099,6 +1091,7 @@ def eliminar_imagen_secundaria(request: HttpRequest, product_id: int, image_id:
messages.success(request, "Imagen eliminada correctamente.") messages.success(request, "Imagen eliminada correctamente.")
return redirect("gestionar_imagenes", id=product_id) return redirect("gestionar_imagenes", id=product_id)
@require_GET
@login_required @login_required
def checkout(request: HttpRequest): def checkout(request: HttpRequest):
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
@@ -1120,20 +1113,19 @@ def checkout(request: HttpRequest):
"paypal_client_id": settings.PAYPAL_CLIENT_ID, "paypal_client_id": settings.PAYPAL_CLIENT_ID,
}) })
@require_GET
@csrf_exempt @csrf_exempt
def stripe_config(request): def stripe_config(request):
if request.method == "GET": stripe_config = {
stripe_config = { "publicKey": settings.STRIPE_PUBLISHABLE_KEY
"publicKey": settings.STRIPE_PUBLISHABLE_KEY }
} return JsonResponse(stripe_config, safe=False)
return JsonResponse(stripe_config, safe=False)
@login_required @login_required
@csrf_protect @csrf_protect
@require_POST
def create_checkout_session(request: HttpRequest): def create_checkout_session(request: HttpRequest):
if request.method != "POST":
return JsonResponse({"error": "Método no permitido"}, status=405)
try: try:
shipping_address = _get_selected_shipping_address(request) shipping_address = _get_selected_shipping_address(request)
@@ -1203,7 +1195,7 @@ def create_checkout_session(request: HttpRequest):
logger.exception("STRIPE_CHECKOUT_SESSION_ERROR user_id=%s error=%s", request.user.id, str(e)) logger.exception("STRIPE_CHECKOUT_SESSION_ERROR user_id=%s error=%s", request.user.id, str(e))
return JsonResponse({"error": "Error al crear la sesión de pago. Por favor inténtalo de nuevo."}, status=500) return JsonResponse({"error": "Error al crear la sesión de pago. Por favor inténtalo de nuevo."}, status=500)
@require_GET
@login_required @login_required
def checkout_success(request: HttpRequest): def checkout_success(request: HttpRequest):
payment_reference = request.session.get('stripe_session_id', "") payment_reference = request.session.get('stripe_session_id', "")
@@ -1230,7 +1222,7 @@ def checkout_success(request: HttpRequest):
messages.success(request, "Pago realizado correctamente. ¡Gracias por tu compra!") messages.success(request, "Pago realizado correctamente. ¡Gracias por tu compra!")
return render(request, "tienda/checkout_success.html", {"order": order}) return render(request, "tienda/checkout_success.html", {"order": order})
@require_GET
@login_required @login_required
def checkout_cancel(request: HttpRequest): def checkout_cancel(request: HttpRequest):
_cancel_active_stock_reservations_for_request(request) _cancel_active_stock_reservations_for_request(request)
@@ -1238,7 +1230,7 @@ def checkout_cancel(request: HttpRequest):
messages.info(request, "Pago cancelado. Puedes intentarlo de nuevo cuando quieras.") messages.info(request, "Pago cancelado. Puedes intentarlo de nuevo cuando quieras.")
return render(request, "tienda/checkout_cancel.html", {}) return render(request, "tienda/checkout_cancel.html", {})
@require_GET
def search(request: HttpRequest): def search(request: HttpRequest):
"""Vista para buscar productos""" """Vista para buscar productos"""
query = request.GET.get('q', '').strip() query = request.GET.get('q', '').strip()
@@ -1259,7 +1251,7 @@ def search(request: HttpRequest):
"categories": categories "categories": categories
}) })
@require_GET
def search_suggestions(request: HttpRequest): def search_suggestions(request: HttpRequest):
"""API AJAX que retorna sugerencias de búsqueda en JSON""" """API AJAX que retorna sugerencias de búsqueda en JSON"""
query = request.GET.get('q', '').strip() query = request.GET.get('q', '').strip()
@@ -1282,13 +1274,11 @@ def search_suggestions(request: HttpRequest):
@require_POST
@login_required @login_required
def create_paypal_payment(request: HttpRequest): def create_paypal_payment(request: HttpRequest):
"""Crea un pago con PayPal y redirige a PayPal""" """Crea un pago con PayPal y redirige a PayPal"""
if request.method != "POST":
return JsonResponse({"error": "Método no permitido"}, status=405)
try: try:
shipping_address = _get_selected_shipping_address(request) shipping_address = _get_selected_shipping_address(request)
if shipping_address is None: if shipping_address is None:
@@ -1388,19 +1378,19 @@ def create_paypal_payment(request: HttpRequest):
return JsonResponse({"error": "No se encontró la URL de aprobación"}, status=400) return JsonResponse({"error": "No se encontró la URL de aprobación"}, status=400)
else: else:
# Loguear el error # Loguear el error
error_msg = str(payment.error) if hasattr(payment, 'error') else "Error desconocido" logger.error("PAYPAL_CREATE_ERROR user_id=%s", request.user.id)
logger.error("PAYPAL_CREATE_ERROR user_id=%s error=%s", request.user.id, error_msg) return JsonResponse({"error": f"Error al crear el pago"}, status=400)
return JsonResponse({"error": f"Error al crear el pago: {error_msg}"}, status=400)
except ImportError: except ImportError:
logger.error("PAYPAL_SDK_NOT_INSTALLED") logger.error("PAYPAL_SDK_NOT_INSTALLED")
return JsonResponse({"error": "SDK de PayPal no instalado"}, status=500) return JsonResponse({"error": "SDK de PayPal no instalado"}, status=500)
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
logger.exception("PAYPAL_CREATE_EXCEPTION user_id=%s error=%s", request.user.id, error_msg) logger.exception("PAYPAL_CREATE_EXCEPTION user_id=%s", request.user.id)
return JsonResponse({"error": f"Error: {error_msg}"}, status=500) return JsonResponse({"error": f"Error al crear el pago"}, status=500)
@require_GET
@login_required @login_required
def paypal_execute(request: HttpRequest): def paypal_execute(request: HttpRequest):
"""Ejecuta el pago de PayPal después de la aprobación""" """Ejecuta el pago de PayPal después de la aprobación"""
@@ -1469,15 +1459,13 @@ def paypal_execute(request: HttpRequest):
# ==================== STRIPE PAYMENT INTENTS ==================== # ==================== STRIPE PAYMENT INTENTS ====================
@login_required @login_required
@require_POST
@csrf_protect @csrf_protect
def crear_payment_intent(request: HttpRequest): def crear_payment_intent(request: HttpRequest):
""" """
Crea un Stripe PaymentIntent para el carrito actual. Crea un Stripe PaymentIntent para el carrito actual.
Acepta JSON: { shipping_address_id, saved_payment_method_id (opcional), save_card (bool) } Acepta JSON: { shipping_address_id, saved_payment_method_id (opcional), save_card (bool) }
""" """
if request.method != "POST":
return JsonResponse({"error": "Método no permitido"}, status=405)
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
except (json.JSONDecodeError, UnicodeDecodeError): except (json.JSONDecodeError, UnicodeDecodeError):
@@ -1553,15 +1541,13 @@ def crear_payment_intent(request: HttpRequest):
@login_required @login_required
@require_POST
@csrf_protect @csrf_protect
def confirmar_pago_tarjeta(request: HttpRequest): def confirmar_pago_tarjeta(request: HttpRequest):
""" """
Verificar que el PaymentIntent fue exitoso y crear el pedido. Verificar que el PaymentIntent fue exitoso y crear el pedido.
Acepta JSON: { payment_intent_id, payment_method_id (si nueva tarjeta) } Acepta JSON: { payment_intent_id, payment_method_id (si nueva tarjeta) }
""" """
if request.method != "POST":
return JsonResponse({"error": "Método no permitido"}, status=405)
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
except (json.JSONDecodeError, UnicodeDecodeError): except (json.JSONDecodeError, UnicodeDecodeError):
@@ -1628,14 +1614,13 @@ def confirmar_pago_tarjeta(request: HttpRequest):
@login_required @login_required
@csrf_protect @csrf_protect
@require_POST
def crear_orden_paypal(request: HttpRequest): def crear_orden_paypal(request: HttpRequest):
""" """
Crea una orden de PayPal con el total del carrito actual (Orders API v2). Crea una orden de PayPal con el total del carrito actual (Orders API v2).
Acepta JSON: { shipping_address_id } Acepta JSON: { shipping_address_id }
Retorna: { id: paypal_order_id } Retorna: { id: paypal_order_id }
""" """
if request.method != "POST":
return JsonResponse({"error": "Método no permitido"}, status=405)
shipping_address = _get_selected_shipping_address(request) shipping_address = _get_selected_shipping_address(request)
if shipping_address is None: if shipping_address is None:
@@ -1682,13 +1667,12 @@ def crear_orden_paypal(request: HttpRequest):
@login_required @login_required
@csrf_protect @csrf_protect
@require_POST
def capturar_orden_paypal(request: HttpRequest): def capturar_orden_paypal(request: HttpRequest):
""" """
Captura una orden de PayPal aprobada y crea el pedido en nuestra BD. Captura una orden de PayPal aprobada y crea el pedido en nuestra BD.
Acepta JSON: { orderID } Acepta JSON: { orderID }
""" """
if request.method != "POST":
return JsonResponse({"error": "Método no permitido"}, status=405)
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
@@ -1771,7 +1755,7 @@ def capturar_orden_paypal(request: HttpRequest):
# ==================== MÉTODOS DE PAGO DEL USUARIO ==================== # ==================== MÉTODOS DE PAGO DEL USUARIO ====================
@require_GET
@login_required @login_required
def metodos_pago(request: HttpRequest): def metodos_pago(request: HttpRequest):
"""Lista los métodos de pago guardados del usuario.""" """Lista los métodos de pago guardados del usuario."""
@@ -1784,6 +1768,7 @@ def metodos_pago(request: HttpRequest):
@login_required @login_required
@require_GET
def agregar_tarjeta(request: HttpRequest): def agregar_tarjeta(request: HttpRequest):
"""Página para añadir una nueva tarjeta usando Stripe SetupIntent.""" """Página para añadir una nueva tarjeta usando Stripe SetupIntent."""
return render(request, "tienda/agregar_tarjeta.html", { return render(request, "tienda/agregar_tarjeta.html", {
@@ -1792,14 +1777,13 @@ def agregar_tarjeta(request: HttpRequest):
@login_required @login_required
@require_POST
@csrf_protect @csrf_protect
def crear_setup_intent(request: HttpRequest): def crear_setup_intent(request: HttpRequest):
""" """
Crea un Stripe SetupIntent y retorna el client_secret para que el frontend Crea un Stripe SetupIntent y retorna el client_secret para que el frontend
pueda montar el Card Element y confirmar sin realizar un cobro. pueda montar el Card Element y confirmar sin realizar un cobro.
""" """
if request.method != "POST":
return JsonResponse({"error": "Método no permitido"}, status=405)
try: try:
stripe.api_key = settings.STRIPE_SECRET_KEY stripe.api_key = settings.STRIPE_SECRET_KEY
customer_id = _get_or_create_stripe_customer(request.user) customer_id = _get_or_create_stripe_customer(request.user)
@@ -1818,13 +1802,12 @@ def crear_setup_intent(request: HttpRequest):
@login_required @login_required
@csrf_protect @csrf_protect
@require_POST
def confirmar_setup_intent(request: HttpRequest): def confirmar_setup_intent(request: HttpRequest):
""" """
Tras la confirmación del SetupIntent en el frontend, guarda la tarjeta. Tras la confirmación del SetupIntent en el frontend, guarda la tarjeta.
Acepta JSON: { payment_method_id, setup_intent_id } Acepta JSON: { payment_method_id, setup_intent_id }
""" """
if request.method != "POST":
return JsonResponse({"error": "Método no permitido"}, status=405)
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
@@ -1889,10 +1872,11 @@ def confirmar_setup_intent(request: HttpRequest):
@login_required @login_required
@require_http_methods(["GET", "POST"])
def eliminar_metodo_pago(request: HttpRequest, id: int): def eliminar_metodo_pago(request: HttpRequest, id: int):
"""Elimina un método de pago guardado del usuario.""" """Elimina un método de pago guardado del usuario."""
if request.method != "POST": if request.method != "POST":
messages.error(request, "Acción no permitida.")
return redirect("metodos_pago") return redirect("metodos_pago")
metodo = get_object_or_404(SavedPaymentMethod, id=id, user=request.user) metodo = get_object_or_404(SavedPaymentMethod, id=id, user=request.user)
@@ -1911,6 +1895,7 @@ def eliminar_metodo_pago(request: HttpRequest, id: int):
@login_required @login_required
@require_GET
def agregar_paypal(request: HttpRequest): def agregar_paypal(request: HttpRequest):
"""Página para guardar una cuenta de PayPal como método de pago (usa un pago de verificación de 0.01 €).""" """Página para guardar una cuenta de PayPal como método de pago (usa un pago de verificación de 0.01 €)."""
return render(request, "tienda/agregar_paypal.html", { return render(request, "tienda/agregar_paypal.html", {
@@ -1919,14 +1904,13 @@ def agregar_paypal(request: HttpRequest):
@login_required @login_required
@require_POST
@csrf_protect @csrf_protect
def crear_orden_paypal_setup(request: HttpRequest): def crear_orden_paypal_setup(request: HttpRequest):
""" """
Crea una orden PayPal de 0.01 € para verificar/guardar la cuenta. Crea una orden PayPal de 0.01 € para verificar/guardar la cuenta.
Retorna { id: paypal_order_id } Retorna { id: paypal_order_id }
""" """
if request.method != "POST":
return JsonResponse({"error": "Método no permitido"}, status=405)
try: try:
paypal_order = _paypal_create_order(Decimal("0.01")) paypal_order = _paypal_create_order(Decimal("0.01"))
return JsonResponse({"id": paypal_order.get("id")}) return JsonResponse({"id": paypal_order.get("id")})
@@ -1937,13 +1921,12 @@ def crear_orden_paypal_setup(request: HttpRequest):
@login_required @login_required
@csrf_protect @csrf_protect
@require_POST
def capturar_orden_paypal_setup(request: HttpRequest): def capturar_orden_paypal_setup(request: HttpRequest):
""" """
Captura la orden de verificación de PayPal y guarda la cuenta del usuario. Captura la orden de verificación de PayPal y guarda la cuenta del usuario.
Acepta JSON: { orderID } Acepta JSON: { orderID }
""" """
if request.method != "POST":
return JsonResponse({"error": "Método no permitido"}, status=405)
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
@@ -1994,6 +1977,7 @@ def capturar_orden_paypal_setup(request: HttpRequest):
# ==================== PORTAL DE USUARIO ==================== # ==================== PORTAL DE USUARIO ====================
@login_required @login_required
@require_GET
def portal_usuario(request: HttpRequest): def portal_usuario(request: HttpRequest):
"""Dashboard del portal de usuario""" """Dashboard del portal de usuario"""
# Obtener estadísticas del usuario # Obtener estadísticas del usuario
@@ -2015,7 +1999,7 @@ def portal_usuario(request: HttpRequest):
"recent_messages": recent_messages, "recent_messages": recent_messages,
}) })
@require_GET
@login_required @login_required
def mis_compras(request: HttpRequest): def mis_compras(request: HttpRequest):
"""Lista completa de compras del usuario autenticado""" """Lista completa de compras del usuario autenticado"""
@@ -2028,6 +2012,7 @@ def mis_compras(request: HttpRequest):
@login_required @login_required
@require_GET
def mis_recibos(request: HttpRequest): def mis_recibos(request: HttpRequest):
"""Lista de recibos (pedidos pagados) del usuario autenticado""" """Lista de recibos (pedidos pagados) del usuario autenticado"""
receipts = Order.objects.filter( receipts = Order.objects.filter(
@@ -2042,6 +2027,7 @@ def mis_recibos(request: HttpRequest):
@login_required @login_required
@require_http_methods(["GET","POST"])
def editar_perfil(request: HttpRequest): def editar_perfil(request: HttpRequest):
"""Edita la información del perfil del usuario""" """Edita la información del perfil del usuario"""
if request.method == "POST": if request.method == "POST":
@@ -2072,6 +2058,7 @@ def editar_perfil(request: HttpRequest):
@login_required @login_required
@require_http_methods(["GET","POST"])
def cambiar_contrasena(request: HttpRequest): def cambiar_contrasena(request: HttpRequest):
"""Cambia la contraseña del usuario""" """Cambia la contraseña del usuario"""
if request.method == "POST": if request.method == "POST":
@@ -2103,6 +2090,7 @@ def cambiar_contrasena(request: HttpRequest):
@login_required @login_required
@require_GET
def direcciones_usuario(request: HttpRequest): def direcciones_usuario(request: HttpRequest):
"""Lista las direcciones de entrega del usuario""" """Lista las direcciones de entrega del usuario"""
direcciones = ShippingAddress.objects.filter(user=request.user) direcciones = ShippingAddress.objects.filter(user=request.user)
@@ -2113,6 +2101,7 @@ def direcciones_usuario(request: HttpRequest):
@login_required @login_required
@require_http_methods(["GET", "POST"])
def crear_direccion(request: HttpRequest): def crear_direccion(request: HttpRequest):
"""Crea una nueva dirección de entrega""" """Crea una nueva dirección de entrega"""
if request.method == "POST": if request.method == "POST":
@@ -2152,6 +2141,7 @@ def crear_direccion(request: HttpRequest):
@login_required @login_required
@require_http_methods(["GET", "POST"])
def editar_direccion(request: HttpRequest, id: int): def editar_direccion(request: HttpRequest, id: int):
"""Edita una dirección de entrega existente""" """Edita una dirección de entrega existente"""
direccion = get_object_or_404(ShippingAddress, id=id, user=request.user) direccion = get_object_or_404(ShippingAddress, id=id, user=request.user)
@@ -2201,11 +2191,9 @@ def editar_direccion(request: HttpRequest, id: int):
@login_required @login_required
@require_POST
def eliminar_direccion(request: HttpRequest, id: int): def eliminar_direccion(request: HttpRequest, id: int):
"""Elimina una dirección de entrega""" """Elimina una dirección de entrega"""
if request.method != "POST":
messages.error(request, "Acción no permitida.")
return redirect("direcciones_usuario")
direccion = get_object_or_404(ShippingAddress, id=id, user=request.user) direccion = get_object_or_404(ShippingAddress, id=id, user=request.user)
direccion.delete() direccion.delete()
@@ -2214,6 +2202,7 @@ def eliminar_direccion(request: HttpRequest, id: int):
@login_required @login_required
@require_GET
def mensajes_comprador(request: HttpRequest): def mensajes_comprador(request: HttpRequest):
"""Muestra los mensajes recibidos de vendedores""" """Muestra los mensajes recibidos de vendedores"""
# Obtener todos los order items del comprador con mensajes # Obtener todos los order items del comprador con mensajes
@@ -2229,20 +2218,8 @@ def mensajes_comprador(request: HttpRequest):
def send_test_email(request: HttpRequest):
message = """
Correo de prueba, deberias recibir esto bien
y esto deberia tener un enter
"""
result = send_email("danilacasito8@gmail.com", "Correo de Prueba", message)
if result[0]:
return HttpResponse("Mira tu bandeja")
else:
return HttpResponse(result[1])
@require_GET
def verify(request: HttpRequest, code: str): def verify(request: HttpRequest, code: str):
obj = None obj = None
try: try:
@@ -2260,27 +2237,35 @@ def verify(request: HttpRequest, code: str):
return HttpResponse("<h1>Error</h1><p>No existe el codigo de verificación</p>") return HttpResponse("<h1>Error</h1><p>No existe el codigo de verificación</p>")
@require_GET
def rgpd(request: HttpRequest): def rgpd(request: HttpRequest):
return render(request, "tienda/rgpd.html", {}) return render(request, "tienda/rgpd.html", {})
@require_GET
def devoluciones(request: HttpRequest): def devoluciones(request: HttpRequest):
return render(request, "tienda/devoluciones.html", {}) return render(request, "tienda/devoluciones.html", {})
@require_GET
def aviso_legal(request: HttpRequest): def aviso_legal(request: HttpRequest):
return render(request, "tienda/aviso_legal.html", {}) return render(request, "tienda/aviso_legal.html", {})
@require_GET
def terminos(request: HttpRequest): def terminos(request: HttpRequest):
return render(request, "tienda/terminos.html", {}) return render(request, "tienda/terminos.html", {})
@require_GET
def cookies(request: HttpRequest): def cookies(request: HttpRequest):
return render(request, "tienda/cookies.html", {}) return render(request, "tienda/cookies.html", {})
@require_GET
def sobre_nosotros(request: HttpRequest): def sobre_nosotros(request: HttpRequest):
return render(request, "tienda/sobre_nosotros.html", {}) return render(request, "tienda/sobre_nosotros.html", {})
@require_GET
def ayuda(request: HttpRequest): def ayuda(request: HttpRequest):
return render(request, "tienda/ayuda.html", {}) return render(request, "tienda/ayuda.html", {})
@require_http_methods(["GET", "POST"])
def reset_password(request: HttpRequest): def reset_password(request: HttpRequest):
if request.method == "GET": if request.method == "GET":
form = ResetPasswordForm() form = ResetPasswordForm()
@@ -2292,6 +2277,7 @@ def reset_password(request: HttpRequest):
messages.info(request, "Si tienes una cuenta con ese correo electronico, se ha enviado un correo con un enlace") messages.info(request, "Si tienes una cuenta con ese correo electronico, se ha enviado un correo con un enlace")
return render(request, "tienda/index.html", {}) return render(request, "tienda/index.html", {})
@require_http_methods(["GET", "POST"])
def reset_password_phase2(request: HttpRequest, code: str): def reset_password_phase2(request: HttpRequest, code: str):
try: try:
ver_code = VerificationCode.objects.get(code=code) ver_code = VerificationCode.objects.get(code=code)
@@ -2318,3 +2304,100 @@ def reset_password_phase2(request: HttpRequest, code: str):
return render(request, "tienda/reset_password_phase2.html", {"form": form, "code": code}) return render(request, "tienda/reset_password_phase2.html", {"form": form, "code": code})
else: else:
raise Http404() raise Http404()
@login_required
@require_http_methods(["GET", "POST"])
def add_review(request: HttpRequest, product_id: int):
product = get_object_or_404(Product, id=product_id)
if not product.has_user_purchased(request.user):
messages.error(request, "Solo puedes valorar productos que hayas comprado.")
return redirect(reverse("producto", args=[product_id]))
existing_review = Review.objects.filter(product=product, user=request.user).first()
if request.method == "POST":
form = ReviewForm(request.POST, request.FILES)
if form.is_valid():
rating = form.cleaned_data["rating"]
title = form.cleaned_data["title"]
content = form.cleaned_data["content"]
if existing_review:
existing_review.rating = rating
existing_review.title = title
existing_review.content = content
existing_review.save()
existing_review.images.clear()
review = existing_review
messages.success(request, "¡Tu valoración ha sido actualizada!")
else:
review = Review.objects.create(
product=product,
user=request.user,
rating=rating,
title=title,
content=content
)
messages.success(request, "¡Gracias por tu valoración!")
uploaded_files = request.FILES.getlist("images")
for uploaded_file in uploaded_files:
image = Image.objects.create(
name=f"Review {product.name} - {request.user.username}",
image=uploaded_file
)
review.images.add(image)
return redirect(reverse("producto", args=[product_id]))
else:
initial_data = {}
if existing_review:
initial_data = {
"rating": existing_review.rating,
"title": existing_review.title,
"content": existing_review.content
}
form = ReviewForm(initial=initial_data)
return render(request, "tienda/add_review.html", {
"product": product,
"form": form,
"existing_review": existing_review
})
@require_GET
def product_reviews(request: HttpRequest, product_id: int):
product = get_object_or_404(Product, id=product_id)
reviews = product.reviews.select_related("user").prefetch_related("images").all()
return JsonResponse({
"reviews": [
{
"id": r.id,
"user": r.user.username,
"rating": r.rating,
"title": r.title,
"content": r.content,
"images": [img.to_dict() for img in r.images.all()],
"created_at": r.created_at.isoformat(),
"is_owner": request.user.is_authenticated and r.user.id == request.user.id
}
for r in reviews
],
"average_rating": product.get_average_rating(),
"reviews_count": product.get_reviews_count(),
"can_review": request.user.is_authenticated and product.has_user_purchased(request.user) and not Review.objects.filter(product=product, user=request.user).exists()
})
@require_POST
@login_required
def delete_review(request: HttpRequest, review_id: int):
review = get_object_or_404(Review, id=review_id, user=request.user)
product_id = review.product_id
review.delete()
messages.success(request, "Tu valoración ha sido eliminada.")
return redirect(reverse("producto", args=[product_id]))
Generated
+861
View File
@@ -0,0 +1,861 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "amqp"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "vine" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[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 = "billiard"
version = "4.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" },
]
[[package]]
name = "boto3"
version = "1.43.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/37/78c630d1308964aa9abf44951d9c4df776546ff37251ec2434944e205c4e/boto3-1.43.6.tar.gz", hash = "sha256:e6315effaf12b890b99956e6f8e2c3000a3f64e4ee91943cec3895ce9a836afb", size = 113153, upload-time = "2026-05-07T20:49:59.694Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/e2/3c2eef44f55eafab256836d1d9479bd6a74f70c26cbfdc0639a0e23e4327/boto3-1.43.6-py3-none-any.whl", hash = "sha256:179601ec2992726a718053bf41e43c223ceba397d31ceab11f64d9c910d9fc3a", size = 140502, upload-time = "2026-05-07T20:49:57.8Z" },
]
[[package]]
name = "botocore"
version = "1.43.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/a7/23d0f5028011455096a1eeac0ddf3cbe147b3e855e127342f8202552194d/botocore-1.43.6.tar.gz", hash = "sha256:b1e395b347356860398da42e61c808cf1e34b6fa7180cf2b9d87d986e1a06ba0", size = 15336070, upload-time = "2026-05-07T20:49:48.14Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/c8/6f47223840e8d8cfa8c9f7c0ec1b77970417f257fc885169ff4f6326ce09/botocore-1.43.6-py3-none-any.whl", hash = "sha256:b6d1fdbc6f65a5fe0b7e947823aa37535d3f39f3ba4d21110fab1f55bbbcc04b", size = 15017094, upload-time = "2026-05-07T20:49:44.964Z" },
]
[[package]]
name = "celery"
version = "5.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "billiard" },
{ name = "click" },
{ name = "click-didyoumean" },
{ name = "click-plugins" },
{ name = "click-repl" },
{ name = "kombu" },
{ name = "python-dateutil" },
{ name = "tzlocal" },
{ name = "vine" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243, upload-time = "2026-03-26T12:14:51.76Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/c9/6eccdda96e098f7ae843162db2d3c149c6931a24fda69fe4ab84d0027eb5/celery-5.6.3-py3-none-any.whl", hash = "sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6", size = 451235, upload-time = "2026-03-26T12:14:49.491Z" },
]
[[package]]
name = "certifi"
version = "2026.4.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[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.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
]
[[package]]
name = "click-didyoumean"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" },
]
[[package]]
name = "click-plugins"
version = "1.1.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" },
]
[[package]]
name = "click-repl"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" },
]
[[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 = "cryptography"
version = "48.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
]
[[package]]
name = "defusedxml"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
]
[[package]]
name = "django"
version = "6.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f1/bf85f0d29ef76abf901f193fe8fef4769d3da7794197832bc30151c071d8/django-6.0.5.tar.gz", hash = "sha256:bc6d6872e98a2864c836e42edd644b362db311147dd5aa8d5b82ba7a032f5269", size = 10924131, upload-time = "2026-05-05T13:54:39.329Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/5b/1328f8b84fce040c404f76822bf8c57d254e368e8cbd8bd67ec2b26d75f5/django-6.0.5-py3-none-any.whl", hash = "sha256:9d58a7cb49244e74c8e161d5e403a46d6209f1009ba40f5a66d6aa0d0786a8f0", size = 8368680, upload-time = "2026-05-05T13:54:33.532Z" },
]
[[package]]
name = "django-appconf"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/a2/e58bec8d7941b914af52a67c35b5709eceed2caa2848f28437f1666ed668/django_appconf-1.2.0.tar.gz", hash = "sha256:15a88d60dd942d6059f467412fe4581db632ef03018a3c183fb415d6fc9e5cec", size = 16127, upload-time = "2025-11-08T15:46:27.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/e6/4c34d94dfb74bbcbc489606e61f1924933de30d22c593dd1f429f35fbd7f/django_appconf-1.2.0-py3-none-any.whl", hash = "sha256:b81bce5ef0ceb9d84df48dfb623a32235d941c78cc5e45dbb6947f154ea277f4", size = 6500, upload-time = "2025-11-08T15:46:25.957Z" },
]
[[package]]
name = "django-compressor"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-appconf" },
{ name = "rcssmin" },
{ name = "rjsmin" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/e4/c6d87b1341d744ceafa85eeceb2adabb1c62b795b8207cbc580fb70df8f4/django_compressor-4.6.0.tar.gz", hash = "sha256:c7478feab98f3368780591f9ee28a433350f5277dd28811f7f710f5bc6dff3c0", size = 99735, upload-time = "2025-11-10T13:12:11.439Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/9d/9a0ba39f33574994e5b33aea55a68e8fad72b8dd923a82300e4e91774f59/django_compressor-4.6.0-py3-none-any.whl", hash = "sha256:6e7b21020a0d86272c5e37000c33accc4ebeb77394a3dd86d775a09aae7aade4", size = 96828, upload-time = "2025-11-10T13:12:10.001Z" },
]
[[package]]
name = "django-ninja"
version = "1.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/7c/3307e17b872f545c88314b2737a22f965785dfb5a120d739b0131d0492c3/django_ninja-1.6.2.tar.gz", hash = "sha256:d56ae5aa4791068ef4ac9a66cfdf2fc11f507413ded35abb79c51d0d52ad6412", size = 3685599, upload-time = "2026-03-18T20:06:47.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/0c/25f72060a39632fbd2d90e9c8b6052a09cd45b0598fc06c0758d313f0052/django_ninja-1.6.2-py3-none-any.whl", hash = "sha256:20095f5900bada22ea00cf1a58af50bdb285b2354c61a9d9b47d0dc89ac462d6", size = 2374994, upload-time = "2026-03-18T20:06:45.676Z" },
]
[[package]]
name = "django-redis"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "redis" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/53/dbcfa1e528e0d6c39947092625b2c89274b5d88f14d357cee53c4d6dbbd4/django_redis-6.0.0.tar.gz", hash = "sha256:2d9cb12a20424a4c4dde082c6122f486628bae2d9c2bee4c0126a4de7fda00dd", size = 56904, upload-time = "2025-06-17T18:15:46.376Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/79/055dfcc508cfe9f439d9f453741188d633efa9eab90fc78a67b0ab50b137/django_redis-6.0.0-py3-none-any.whl", hash = "sha256:20bf0063a8abee567eb5f77f375143c32810c8700c0674ced34737f8de4e36c0", size = 33687, upload-time = "2025-06-17T18:15:34.165Z" },
]
[[package]]
name = "django-storages"
version = "1.14.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" },
]
[package.optional-dependencies]
s3 = [
{ name = "boto3" },
]
[[package]]
name = "fonttools"
version = "4.62.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" },
{ url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" },
{ url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" },
{ url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" },
{ url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" },
{ url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" },
{ url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" },
{ url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" },
{ url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" },
{ url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" },
{ url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" },
{ url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" },
{ url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" },
{ url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" },
{ url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" },
{ url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" },
]
[[package]]
name = "fpdf2"
version = "2.8.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "defusedxml" },
{ name = "fonttools" },
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/27/f2/72feae0b2827ed38013e4307b14f95bf0b3d124adfef4d38a7d57533f7be/fpdf2-2.8.7.tar.gz", hash = "sha256:7060ccee5a9c7ab0a271fb765a36a23639f83ef8996c34e3d46af0a17ede57f9", size = 362351, upload-time = "2026-02-28T05:39:16.456Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/0a/cf50ecffa1e3747ed9380a3adfc829259f1f86b3fdbd9e505af789003141/fpdf2-2.8.7-py3-none-any.whl", hash = "sha256:d391fc508a3ce02fc43a577c830cda4fe6f37646f2d143d489839940932fbc19", size = 327056, upload-time = "2026-02-28T05:39:14.619Z" },
]
[[package]]
name = "gunicorn"
version = "26.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" },
]
[[package]]
name = "idna"
version = "3.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
name = "kombu"
version = "5.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "amqp" },
{ name = "packaging" },
{ name = "tzdata" },
{ name = "vine" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "paypalrestsdk"
version = "1.13.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyopenssl" },
{ name = "requests" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/5e585f31a1c6caeec1af093edc3c6046a46af330ab9d9f91bbf86b019b59/paypalrestsdk-1.13.3.tar.gz", hash = "sha256:dac236492a9ac1260a760014a2e56ab38b09d8143295b5e896545359b61fedd6", size = 23865, upload-time = "2023-11-01T20:50:00.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/e0/ce62183f4ca1d9cab773a086a5d49e934f3a782960558ff971adc9fc9d05/paypalrestsdk-1.13.3-py3-none-any.whl", hash = "sha256:a3f51616ee8f6d975a5a5a8c2049db63653c843479c8fdd71c9d588a31e14560", size = 23681, upload-time = "2023-11-01T20:49:57.307Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]]
name = "proyecto-final"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "celery" },
{ name = "django" },
{ name = "django-compressor" },
{ name = "django-ninja" },
{ name = "django-redis" },
{ name = "django-storages", extra = ["s3"] },
{ name = "fpdf2" },
{ name = "gunicorn" },
{ name = "paypalrestsdk" },
{ name = "pillow" },
{ name = "psycopg2-binary" },
{ name = "requests" },
{ name = "stripe" },
{ name = "whitenoise" },
]
[package.metadata]
requires-dist = [
{ name = "celery", specifier = "==5.6.3" },
{ name = "django", specifier = "==6.0.5" },
{ name = "django-compressor", specifier = "==4.6.0" },
{ name = "django-ninja", specifier = ">=1.6.2" },
{ name = "django-redis", specifier = "==6.0.0" },
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
{ name = "fpdf2", specifier = "==2.8.7" },
{ name = "gunicorn", specifier = "==26.0.0" },
{ name = "paypalrestsdk", specifier = "==1.13.3" },
{ name = "pillow", specifier = "==12.2.0" },
{ name = "psycopg2-binary", specifier = "==2.9.12" },
{ name = "requests", specifier = "==2.34.2" },
{ name = "stripe", specifier = "==15.1.0" },
{ name = "whitenoise", specifier = "==6.12.0" },
]
[[package]]
name = "psycopg2-binary"
version = "2.9.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" },
{ url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" },
{ url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" },
{ url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" },
{ url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" },
{ url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" },
{ url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" },
{ url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" },
{ url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
]
[[package]]
name = "pyopenssl"
version = "26.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "rcssmin"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/81/af/c9654b4f9b054ec163ed7cb20d8db0e5ae05e2e9ce99a4c11d91a2180b3f/rcssmin-1.2.2.tar.gz", hash = "sha256:806986eaf7414545edc28a1d29523e9560e49e151ff4a337d9d1f0271d6e1cc4", size = 587012, upload-time = "2025-10-12T10:48:08.932Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/40/9c4cb3133f6d4ddfbeada76988a10ff2a974706fd6fcbb97edd8c0f4cc76/rcssmin-1.2.2-cp314-cp314-manylinux1_i686.whl", hash = "sha256:540dd3aa586b5f8f4c4b90db37e6a31c04718cdf90dbe9bec43c3b4dd50519e7", size = 49032, upload-time = "2025-10-12T10:48:53.014Z" },
{ url = "https://files.pythonhosted.org/packages/07/84/a411a48fd4179a88c68a2ad3649b408fa7887a421d3435c10ae6f5724e3a/rcssmin-1.2.2-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:6ea38a38eec263858b70bed6715478dcfed7fbc5d63333a8c512631ee22baad9", size = 49497, upload-time = "2025-10-12T10:48:54.009Z" },
{ url = "https://files.pythonhosted.org/packages/a1/32/5663a71a9304e0c9f33b765264508229d026359cfff746e1d0a593d809ea/rcssmin-1.2.2-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:07dc7d352e8eb08de82fc4c545dd04f9f487466c8370051e0bee4eb1e4dc85d0", size = 50382, upload-time = "2025-10-12T10:48:55.079Z" },
{ url = "https://files.pythonhosted.org/packages/d7/28/e411eb191ffff7bd712f2eb0f691cb7ca514b1876d6bff2f5ae61359b8db/rcssmin-1.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cdccb0e08281f0dd5d463c16ec61a06bd1534de50206dc72918be3c10dcb82e5", size = 50962, upload-time = "2025-10-12T10:48:56.494Z" },
{ url = "https://files.pythonhosted.org/packages/fb/3f/cdb99526d294c5dd4b919dc4ef492b7bd11e08b585d15ec641dfb9423493/rcssmin-1.2.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2b6d5e2e2fd65738d57ef65aaaed2cff2288eccff7f704bf3d579e6f451cb60a", size = 52504, upload-time = "2025-10-12T10:48:57.886Z" },
{ url = "https://files.pythonhosted.org/packages/e8/60/a8183401fa64e93e1d52b2cdf275a2c11e0993f5f3162c573a67872b535d/rcssmin-1.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7018d4197713c7797d1a67ed47ab53d4706c2e9ed134123c30a47d389dda5386", size = 50561, upload-time = "2025-10-12T10:48:58.935Z" },
{ url = "https://files.pythonhosted.org/packages/47/5e/496d6c9c309e2fe79e6a69f25f7a6d18f545edb4ea3584f461b9f84b0d60/rcssmin-1.2.2-cp314-cp314t-manylinux1_i686.whl", hash = "sha256:0162c32ce946978edc834d4fba705ac5f9422d7f556f3264cc4fc67c7ee39171", size = 51214, upload-time = "2025-10-12T10:49:00.021Z" },
{ url = "https://files.pythonhosted.org/packages/5e/78/87da6706d5856ceee71421ba831d2f5d93c3e6865acfbb56ace8d54587cc/rcssmin-1.2.2-cp314-cp314t-manylinux1_x86_64.whl", hash = "sha256:f17dc92553a46412c49f972f0ab31088032b9482a9c421bc2d39691a5d8842aa", size = 51608, upload-time = "2025-10-12T10:49:01.422Z" },
{ url = "https://files.pythonhosted.org/packages/cd/6c/204b0262c11ac2da2b8df2d8fed76f1959273fbc8376450d0ac022d754b7/rcssmin-1.2.2-cp314-cp314t-manylinux2014_aarch64.whl", hash = "sha256:40c7dfba098bbd129d8c35dd8b604275585f9dc0496e5d17dbe7fd6b873b0233", size = 53349, upload-time = "2025-10-12T10:49:02.512Z" },
{ url = "https://files.pythonhosted.org/packages/c3/7b/9aae16756d3f33cbc512760ba3e69c3856a51aa293e463f2ca97760d1b1b/rcssmin-1.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d0197fab78ebbe33f5df9caf2572ef2d44bbe243a9130881a0c5c53ba03641fa", size = 53066, upload-time = "2025-10-12T10:49:03.589Z" },
{ url = "https://files.pythonhosted.org/packages/4e/18/b06fadfa9b85e486bb1571050217cb539c062d1ae4cd32b1a31c36f67fd4/rcssmin-1.2.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:19e53c58768369366fdaef00da59f275f724f229994ea885309df6ca368ff3c8", size = 54271, upload-time = "2025-10-12T10:49:04.735Z" },
{ url = "https://files.pythonhosted.org/packages/79/55/f29ce21f8e5a1f3c19d43b67b907268d227b7edcda2ca200ca0028734a5e/rcssmin-1.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8d3de1a870e00d157f3a7b1797498fdc09a3774629079572350f75783bb94b9a", size = 52423, upload-time = "2025-10-12T10:49:06.04Z" },
]
[[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.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "rjsmin"
version = "1.2.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/59/16/14288d309d0f42c6586440c47bf6ec1a880218f698f30293fa3782db4008/rjsmin-1.2.5.tar.gz", hash = "sha256:a3f8040b0273dec773e0e807e86a4d0a9535516c0a0a35aa1bb6de6e15bb1f09", size = 427399, upload-time = "2025-10-12T10:50:27.422Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/ed/b472d5a3fd7d63c016893f7d438e677901fea28089b5d30cd1a115bcc887/rjsmin-1.2.5-cp314-cp314-manylinux1_i686.whl", hash = "sha256:7096357ed596fdfe0acb750f8cbfca338f3c845cc12def3861e23ed811589d15", size = 31983, upload-time = "2025-10-12T10:51:11.361Z" },
{ url = "https://files.pythonhosted.org/packages/9c/e8/e76fa527fde17fd08288e4efef25c0aba7979ed5740eeab7bdff507bdeba/rjsmin-1.2.5-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:4e80b05803749502995fe33b6f5fd589b51dc46e50d873baf0b515c8f6e7b668", size = 32002, upload-time = "2025-10-12T10:51:12.257Z" },
{ url = "https://files.pythonhosted.org/packages/87/6c/ee395ef8ee117ba2d158a23a9502bc4a706e02f63bfdf6d01b802ae6ee9a/rjsmin-1.2.5-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:b6d0bc092acc3f54ea63ec1dcb808edaac5e956141d89fd0d038e80de5322052", size = 32435, upload-time = "2025-10-12T10:51:13.147Z" },
{ url = "https://files.pythonhosted.org/packages/1a/78/c157d33aa6148f0e8c57bb91a41969e1a4aab929f3bb0a8d9ff3b5e21556/rjsmin-1.2.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1e2943259be7beafdcb0847c2a901f223bf9044bdfa8105e1be1ad67d6c47795", size = 32877, upload-time = "2025-10-12T10:51:14.545Z" },
{ url = "https://files.pythonhosted.org/packages/e9/49/6252145bf85d87c815aaf441c5efdf1ce918db5ab6e915cf6d0d99ca3969/rjsmin-1.2.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e0387568c27fb49e55c1d0dfc27b54fc63d04b7756b1fed9743078130262907f", size = 32957, upload-time = "2025-10-12T10:51:15.964Z" },
{ url = "https://files.pythonhosted.org/packages/15/7e/c321c047b1a2fb7fa5ac818c37c1a15d348e1c12a1148de8ca5192a83b8f/rjsmin-1.2.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8196f1ecb0dff6c8647d4622e496869e94f1be92567ea2e941aa18d49a1a4347", size = 32456, upload-time = "2025-10-12T10:51:16.885Z" },
{ url = "https://files.pythonhosted.org/packages/5b/d7/2d190ce5ad10832df62edd4d9b1ae7092fd259ca58b39a1e202337f511a9/rjsmin-1.2.5-cp314-cp314t-manylinux1_i686.whl", hash = "sha256:9dd9f66568be9c8676278f140aa54102fab9af7feb59adf0c7a85bef49fe70df", size = 34115, upload-time = "2025-10-12T10:51:17.911Z" },
{ url = "https://files.pythonhosted.org/packages/76/ab/e7bcf261ede4cef7a0693927d7dcd1612bb59ba6c05191f58a92deec9f01/rjsmin-1.2.5-cp314-cp314t-manylinux1_x86_64.whl", hash = "sha256:5b8f72f7d96e5e1d30a33182cb39d4eb4516ddcd9b2f984813a9eefe66f8e180", size = 33977, upload-time = "2025-10-12T10:51:18.996Z" },
{ url = "https://files.pythonhosted.org/packages/a7/75/f1ff5f2199437b534204b40aa46c55c703489063cf7806c948a1a665575e/rjsmin-1.2.5-cp314-cp314t-manylinux2014_aarch64.whl", hash = "sha256:8c5906bd8830f616e992ad5e7277d0ea12c530110da188b2b9da23e9524a7cbc", size = 34604, upload-time = "2025-10-12T10:51:20.031Z" },
{ url = "https://files.pythonhosted.org/packages/d2/dc/acd463d88c56476cc683f1c6cce893c590007dccd390747e824b8e923d63/rjsmin-1.2.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8207bac0d3bab7791fd667f0863b5f32e51047845179b94b28c716e6514a9234", size = 34775, upload-time = "2025-10-12T10:51:21.364Z" },
{ url = "https://files.pythonhosted.org/packages/ce/56/e6f61718d1c36e646aabe552ad1f8f77744a4c57524eaa782b5b44eba220/rjsmin-1.2.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1e3ab93a51d7581ba0a3b6a383df2929b86d9d55f9516764678f9b4e409826e8", size = 34682, upload-time = "2025-10-12T10:51:22.755Z" },
{ url = "https://files.pythonhosted.org/packages/00/f3/37a4672ddb1307eb57d9b54ba89a48f483a04a63cac4e1471fdb4cba76e6/rjsmin-1.2.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:47dad1732a2c4779bdc76d5b3183fdf2ec27838f31071fa9dfcc79483d3480e2", size = 34161, upload-time = "2025-10-12T10:51:23.761Z" },
]
[[package]]
name = "s3transfer"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[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 = "stripe"
version = "15.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/45/26/5d6f5f5beae6f1ff78213e2e6f4fbd431518dcd98733cdd39fb4ba0d01d3/stripe-15.1.0.tar.gz", hash = "sha256:24bd3b6bd0969a4841bd4d7681556a9e35e46c414a07c8590a225fbd5a878450", size = 1501673, upload-time = "2026-04-24T00:18:58.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/4e/fd9cb74ddf1e61fb6241e2f6799a81ef99bf6cf2e94f8812ee1cd5458e5d/stripe-15.1.0-py3-none-any.whl", hash = "sha256:bdfb556be08662a41833e6403607ebf12e0062cae4f9b93e2b89b6ba926d7c82", size = 2143199, upload-time = "2026-04-24T00:18:56.027Z" },
]
[[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 = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "tzdata"
version = "2026.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
]
[[package]]
name = "tzlocal"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
name = "vine"
version = "5.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" },
]
[[package]]
name = "wcwidth"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" },
]
[[package]]
name = "whitenoise"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
]