Compare commits

..

57 Commits

Author SHA1 Message Date
elordenador 1d319d8efa Merge branch 'latest' of github.com:dsaub/proyecto-final into latest 2026-05-31 18:52:49 +02:00
elordenador 874f4e29db refactor: update static and media URL handling for S3 integration 2026-05-31 18:51:39 +02:00
elordenador 131fe8fecc refactor: streamline environment variable handling using django-environ 2026-05-31 18:33:35 +02:00
Daniel (elordenador) b154da09a5 Merge pull request #103 from dsaub/dependabot/pip/stripe-15.2.0
build(deps): bump stripe from 15.1.0 to 15.2.0
2026-05-29 10:29:53 +02:00
dependabot[bot] f47fd21deb build(deps): bump stripe from 15.1.0 to 15.2.0
Bumps [stripe](https://github.com/stripe/stripe-python) from 15.1.0 to 15.2.0.
- [Release notes](https://github.com/stripe/stripe-python/releases)
- [Changelog](https://github.com/stripe/stripe-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-python/compare/v15.1.0...v15.2.0)

---
updated-dependencies:
- dependency-name: stripe
  dependency-version: 15.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 10:35:33 +00:00
elordenador ac27137b77 refactor: change table cells to headers for improved semantic structure in email templates 2026-05-26 13:46:08 +02:00
elordenador 7c445d4b66 refactor: change table cells to headers for improved semantic structure in ban email template 2026-05-26 13:36:59 +02:00
elordenador e4f0611ac5 refactor: replace parseInt with Number.parseInt for consistency and clarity
fix: add hidden input to card data labels for improved accessibility
refactor: add scope attributes to table headers for better semantic structure
2026-05-26 13:31:07 +02:00
elordenador 33dee87cb2 send_hemail now returns tuples of same length 2026-05-26 13:21:44 +02:00
elordenador 3de6d37e03 refactor: clean up send_email function and remove outdated SMTP implementation 2026-05-26 13:21:03 +02:00
elordenador 5503bbe8f7 refactor: organize constants and improve template rendering in views 2026-05-26 13:19:06 +02:00
elordenador dd5ecec3f6 fix: improve accessibility by adding aria-labelledby attributes to card input labels 2026-05-26 13:17:47 +02:00
Daniel (elordenador) c778669a7a Merge pull request #102 from dsaub/fix/major-issues
fix: resolver 9 issues MAJOR de SonarQube
2026-05-26 13:15:33 +02:00
Chroot 09f6f800de fix: script module con top-level await para S7785 2026-05-26 11:14:03 +00:00
Chroot 1ac17109a3 fix: usar async IIFE en loadReviews para S7785 2026-05-26 11:11:43 +00:00
Chroot 325e55417b fix: resolver 9 issues MAJOR de SonarQube Cloud
- views.py: eliminar parámetros no usados cart_items y product_ids
- views.py: reemplazar f-strings sin placeholders por strings normales
- base.html: añadir <title>Comercialmeria</title>
- add_review.html: asociar label 'Puntuación' con rating-input via for
- producto.html: promesa loadReviews con .catch()
- gestionar_imagenes.html: mejorar alt text descriptivo
- unban.html: quitar atributos deprecados width/cellspacing
2026-05-26 11:10:04 +00:00
Daniel (elordenador) e363bfd6dd Merge pull request #101 from dsaub/fix/const-self-ref-bug
fix: corregir constantes auto-referenciadas que rompían la app
2026-05-26 13:04:41 +02:00
Chroot 90308d2383 fix: corregir constantes auto-referenciadas que rompen la app
El sed de reemplazo de strings también modificó las definiciones
de constantes, dejando p.ej. LOGIN_TEMPLATE = LOGIN_TEMPLATE
en vez de LOGIN_TEMPLATE = "tienda/login.html", causando
NameError al importar el módulo.
2026-05-26 11:03:53 +00:00
Daniel (elordenador) de4f36a25c Merge pull request #99 from dsaub/fix/sonar-critical-issues
fix: resolver 12 issues CRITICAL de SonarQube Cloud
2026-05-26 12:57:57 +02:00
Chroot 424ffcffaf fix: resolver 12 issues CRITICAL de SonarQube Cloud
- forms.py: cambiar import wildcard por imports explícitos (S2208)
- views.py: definir constantes para strings duplicados (S1192)
- views.py: refactorizar login, create_order_from_cart, editar_producto (S3776)
2026-05-26 10:53:18 +00:00
elordenador f0a638be2e fix: update Docker workflows to use specific action versions and improve test command security 2026-05-26 12:12:03 +02:00
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 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
35 changed files with 1353 additions and 700 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
+7 -7
View File
@@ -14,13 +14,13 @@ 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 - name: Configurar uv
uses: astral-sh/setup-uv@v6 uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6
- name: Instalar dependencias - name: Instalar dependencias
run: | run: |
uv sync --no-dev --no-install-project uv sync --no-dev --no-install-project
@@ -28,7 +28,7 @@ jobs:
env: env:
DJANGO_SETTINGS_MODULE: proyecto.settings DJANGO_SETTINGS_MODULE: proyecto.settings
run: | run: |
uv run python manage.py test SECRET_KEY=testkeynotuseinproducto uv run python manage.py test
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -38,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
+8 -8
View File
@@ -13,13 +13,13 @@ jobs:
contents: read contents: read
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 - name: Configurar uv
uses: astral-sh/setup-uv@v6 uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6
- name: Instalar dependencias - name: Instalar dependencias
run: | run: |
uv sync --no-dev --no-install-project uv sync --no-dev --no-install-project
@@ -27,7 +27,7 @@ jobs:
env: env:
DJANGO_SETTINGS_MODULE: proyecto.settings DJANGO_SETTINGS_MODULE: proyecto.settings
run: | run: |
uv run python manage.py test SECRET_KEY=donotusethisinproductionitisunsafe uv run python manage.py test
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test needs: test
@@ -37,13 +37,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: Login en GHCR - name: Login en GHCR
uses: docker/login-action@v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -55,7 +55,7 @@ jobs:
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV
- name: Build y Push - name: Build y Push
uses: docker/build-push-action@v6 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with: with:
context: . context: .
push: true push: true
-33
View File
@@ -1,33 +0,0 @@
name: opencode
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
opencode:
if: |
contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: read
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Run opencode
uses: anomalyco/opencode/github@latest
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
with:
model: openai/gpt-5.3-codex
+21 -4
View File
@@ -5,16 +5,33 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
COPY pyproject.toml uv.lock /app/ COPY pyproject.toml uv.lock /app/
RUN apk --no-cache update && apk --no-cache upgrade
RUN pip install --no-cache-dir uv
RUN uv sync --no-dev --no-install-project # Install only dependencies, not the local project package
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/
RUN addgroup -S app \
&& adduser -S app -G app \
&& chown -R app:app /app /fonts
USER app
ENTRYPOINT ["/bin/sh", "/app/entrypoint.sh"] ENTRYPOINT ["/bin/sh", "/app/entrypoint.sh"]
+72 -110
View File
@@ -11,84 +11,47 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
""" """
import logging import logging
import os, sys import os
import sys
from pathlib import Path from pathlib import Path
import environ
DEV_ENV = len(sys.argv) > 1 and 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
def load_dotenv(dotenv_path: Path) -> None:
if not dotenv_path.exists():
return
for raw_line in dotenv_path.read_text(encoding='utf-8').splitlines():
line = raw_line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, value = line.split('=', 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {'1', 'true', 'yes', 'on'}
def env_list(name: str, default: list[str] | None = None) -> list[str]:
value = os.getenv(name)
if value is None:
return default or []
return [item.strip() for item in value.split(',') if item.strip()]
def env_int(name: str, default: int) -> int:
value = os.getenv(name)
if value is None:
return default
return int(value)
def env_str(name: str, default: str = '') -> str:
value = os.getenv(name)
if value is None:
return default
return value.strip()
def env_optional_str(name: str) -> str | None:
value = os.getenv(name)
if value is None:
return None
value = value.strip()
return value or None
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env') env = environ.Env(
DEBUG=(bool, True),
S3_ENABLE=(bool, False),
S3_USE_LOCAL_URLS=(bool, False),
POSTGRES_ENABLED=(bool, True),
POSTGRES_PORT=(int, 5432),
SMTP_PORT=(int, 587),
AWS_S3_USE_SSL=(bool, True),
AWS_QUERYSTRING_AUTH=(bool, False),
)
env.read_env(BASE_DIR / '.env')
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# 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 = env('SECRET_KEY', default='')
# 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')
S3_ENABLE = env_bool('S3_ENABLE', False) S3_ENABLE = env.bool('S3_ENABLE')
S3_USE_LOCAL_URLS = env_bool('S3_USE_LOCAL_URLS', False) S3_USE_LOCAL_URLS = env.bool('S3_USE_LOCAL_URLS')
ALLOWED_HOSTS = env_list('ALLOWED_HOSTS', [ ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[
'192.168.1.142',
'localhost', 'localhost',
'127.0.0.1', '127.0.0.1',
'zkqpv8r3-8000.uks1.devtunnels.ms'
]) ])
@@ -104,6 +67,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.forms', 'django.forms',
'compressor', 'compressor',
'ninja',
] ]
if S3_ENABLE: if S3_ENABLE:
@@ -147,33 +111,25 @@ 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'):
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):
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.getenv('POSTGRES_DB', 'tienda'),
'USER': os.getenv('POSTGRES_USER', 'postgres'),
'PASSWORD': os.getenv('POSTGRES_PASSWORD', ''),
'HOST': os.getenv('POSTGRES_HOST', '127.0.0.1'),
'PORT': env_int('POSTGRES_PORT', 5432),
}
}
else: else:
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.postgresql',
'NAME': BASE_DIR / 'db.sqlite3', 'NAME': env('POSTGRES_DB', default='tienda'),
'USER': env('POSTGRES_USER', default='postgres'),
'PASSWORD': env('POSTGRES_PASSWORD', default=''),
'HOST': env('POSTGRES_HOST', default='127.0.0.1'),
'PORT': env.int('POSTGRES_PORT'),
} }
} }
# 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
@@ -208,10 +164,10 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/ # https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / 'staticfiles'
COMPRESS_ROOT = STATIC_ROOT COMPRESS_ROOT = STATIC_ROOT
COMPRESS_URL = STATIC_URL
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / 'tienda' / 'static', BASE_DIR / 'tienda' / 'static',
] ]
@@ -230,15 +186,15 @@ STORAGES = {
} }
if S3_ENABLE: if S3_ENABLE:
AWS_STORAGE_BUCKET_NAME = env_str('AWS_STORAGE_BUCKET_NAME') or None AWS_STORAGE_BUCKET_NAME = env('AWS_STORAGE_BUCKET_NAME', default='') or None
AWS_ACCESS_KEY_ID = env_optional_str('AWS_ACCESS_KEY_ID') AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID', default=None)
AWS_SECRET_ACCESS_KEY = env_optional_str('AWS_SECRET_ACCESS_KEY') AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY', default=None)
AWS_S3_REGION_NAME = env_optional_str('AWS_S3_REGION_NAME') AWS_S3_REGION_NAME = env('AWS_S3_REGION_NAME', default=None)
AWS_S3_ENDPOINT_URL = env_optional_str('AWS_S3_ENDPOINT_URL') AWS_S3_ENDPOINT_URL = env('AWS_S3_ENDPOINT_URL', default=None)
AWS_S3_CUSTOM_DOMAIN = env_optional_str('AWS_S3_CUSTOM_DOMAIN') AWS_S3_CUSTOM_DOMAIN = env('AWS_S3_CUSTOM_DOMAIN', default=None)
AWS_S3_USE_SSL = env_bool('AWS_S3_USE_SSL', True) AWS_S3_USE_SSL = env.bool('AWS_S3_USE_SSL')
AWS_QUERYSTRING_AUTH = env_bool('AWS_QUERYSTRING_AUTH', False) AWS_QUERYSTRING_AUTH = env.bool('AWS_QUERYSTRING_AUTH')
AWS_DEFAULT_ACL = env_str('AWS_DEFAULT_ACL', 'public-read') or None AWS_DEFAULT_ACL = env('AWS_DEFAULT_ACL', default='public-read') or None
AWS_S3_OBJECT_PARAMETERS = {} AWS_S3_OBJECT_PARAMETERS = {}
STORAGES = { STORAGES = {
@@ -250,6 +206,14 @@ if S3_ENABLE:
}, },
} }
if S3_ENABLE and AWS_S3_CUSTOM_DOMAIN:
STATIC_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/static/"
MEDIA_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/media/"
else:
STATIC_URL = env("STATIC_URL", default="static/")
MEDIA_URL = env("MEDIA_URL", default="media/")
COMPRESS_URL = STATIC_URL
STATICFILES_FINDERS = [ STATICFILES_FINDERS = [
'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
@@ -258,15 +222,13 @@ STATICFILES_FINDERS = [
COMPRESS_PRECOMPILERS = () COMPRESS_PRECOMPILERS = ()
# Media files (User uploads) MEDIA_ROOT = Path(env('MEDIA_ROOT', default='/app/media'))
MEDIA_URL = 'media/'
MEDIA_ROOT = Path(os.getenv('MEDIA_ROOT', '/app/media'))
# Redis Configuration # Redis Configuration
CACHES = { CACHES = {
'default': { 'default': {
'BACKEND': 'django_redis.cache.RedisCache', 'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': os.getenv('REDIS_URL', 'redis://127.0.0.1:6379/1'), 'LOCATION': env('REDIS_URL', default='redis://127.0.0.1:6379/1'),
'OPTIONS': { 'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient', 'CLIENT_CLASS': 'django_redis.client.DefaultClient',
} }
@@ -291,30 +253,30 @@ MESSAGE_TAGS = {
# Login URL # Login URL
LOGIN_URL = '/tienda/login/' LOGIN_URL = '/tienda/login/'
STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY', '') STRIPE_PUBLISHABLE_KEY = env('STRIPE_PUBLISHABLE_KEY', default='')
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '') STRIPE_SECRET_KEY = env('STRIPE_SECRET_KEY', default='')
# PayPal Configuration (Sandbox) # PayPal Configuration (Sandbox)
# Para obtener credenciales: https://sandbox.paypal.com/ # Para obtener credenciales: https://sandbox.paypal.com/
PAYPAL_CLIENT_ID = os.getenv('PAYPAL_CLIENT_ID', '') # Reemplazar con tu Client ID de PayPal Sandbox PAYPAL_CLIENT_ID = env('PAYPAL_CLIENT_ID', default='') # Reemplazar con tu Client ID de PayPal Sandbox
PAYPAL_CLIENT_SECRET = os.getenv('PAYPAL_CLIENT_SECRET', '') # Reemplazar con tu Client Secret de PayPal Sandbox PAYPAL_CLIENT_SECRET = env('PAYPAL_CLIENT_SECRET', default='') # Reemplazar con tu Client Secret de PayPal Sandbox
PAYPAL_MODE = os.getenv('PAYPAL_MODE', 'sandbox') # Cambiar a 'live' en producción PAYPAL_MODE = env('PAYPAL_MODE', default='sandbox') # Cambiar a 'live' en producción
SMTP_ENDPOINT = os.getenv('SMTP_ENDPOINT', 'smtp.email.eu-paris-1.oci.oraclecloud.com') SMTP_ENDPOINT = env('SMTP_ENDPOINT', default='smtp.email.eu-paris-1.oci.oraclecloud.com')
SMTP_PORT = env_int('SMTP_PORT', 587) SMTP_PORT = env.int('SMTP_PORT')
SECURITY = os.getenv('SECURITY', 'tls') SECURITY = env('SECURITY', default='tls')
SMTP_USERNAME = os.getenv('SMTP_USERNAME', None) SMTP_USERNAME = env('SMTP_USERNAME', default=None)
SMTP_PASSWORD = os.getenv('SMTP_PASSWORD', None) SMTP_PASSWORD = env('SMTP_PASSWORD', default=None)
SMTP_EMAIL = os.getenv("SMTP_EMAIL", None) SMTP_EMAIL = env('SMTP_EMAIL', default=None)
AUTH_USER_MODEL = 'tienda.User' AUTH_USER_MODEL = 'tienda.User'
DOMAIN = os.getenv("DOMAIN", "localhost") DOMAIN = env('DOMAIN', default='localhost')
PROTOCOL = os.getenv("PROTOCOL", "http") PROTOCOL = env('PROTOCOL', default='http')
default_csrf_trusted_origins = [] default_csrf_trusted_origins = []
if DOMAIN: if DOMAIN:
@@ -324,16 +286,16 @@ for host in ALLOWED_HOSTS:
if host and host != '*': if host and host != '*':
default_csrf_trusted_origins.append(f"{PROTOCOL}://{host}") default_csrf_trusted_origins.append(f"{PROTOCOL}://{host}")
CSRF_TRUSTED_ORIGINS = env_list( CSRF_TRUSTED_ORIGINS = env.list(
'CSRF_TRUSTED_ORIGINS', 'CSRF_TRUSTED_ORIGINS',
list(dict.fromkeys(default_csrf_trusted_origins)), default=list(dict.fromkeys(default_csrf_trusted_origins)),
) )
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper() LOG_LEVEL = env('LOG_LEVEL', default='INFO').upper()
LOG_DIR = Path(os.getenv('LOG_DIR', BASE_DIR / 'logs')) LOG_DIR = Path(env('LOG_DIR', default=str(BASE_DIR / 'logs')))
LOG_DIR.mkdir(parents=True, exist_ok=True) LOG_DIR.mkdir(parents=True, exist_ok=True)
LOG_FILE = LOG_DIR / os.getenv('LOG_FILE', 'app.log') LOG_FILE = LOG_DIR / env('LOG_FILE', default='app.log')
LOGGING = { LOGGING = {
@@ -407,13 +369,13 @@ 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") or SMTP_EMAIL or "no-reply@localhost" DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='') 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 = env('REDIS_URL', default='redis://localhost:6379/0')
# Opcional: para guardar el resultado de las tareas # Opcional: para guardar el resultado de las tareas
CELERY_RESULT_BACKEND = os.getenv("REDIS_URL", "redis://localhost:6379/0") CELERY_RESULT_BACKEND = env('REDIS_URL', default='redis://localhost:6379/0')
# Configuraciones adicionales recomendadas # Configuraciones adicionales recomendadas
CELERY_ACCEPT_CONTENT = ['json'] CELERY_ACCEPT_CONTENT = ['json']
+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 (
+4 -2
View File
@@ -7,6 +7,8 @@ dependencies = [
"celery==5.6.3", "celery==5.6.3",
"Django==6.0.5", "Django==6.0.5",
"django-compressor==4.6.0", "django-compressor==4.6.0",
"django-environ>=0.13.0",
"django-ninja>=1.6.2",
"django-redis==6.0.0", "django-redis==6.0.0",
# S3 backend requerido por tienda/storage_backends.py cuando S3_ENABLE=True. # S3 backend requerido por tienda/storage_backends.py cuando S3_ENABLE=True.
"django-storages[s3]==1.14.6", "django-storages[s3]==1.14.6",
@@ -15,8 +17,8 @@ dependencies = [
"paypalrestsdk==1.13.3", "paypalrestsdk==1.13.3",
"pillow==12.2.0", "pillow==12.2.0",
"psycopg2-binary==2.9.12", "psycopg2-binary==2.9.12",
"requests==2.33.1", "requests==2.34.2",
"stripe==15.1.0", "stripe==15.2.0",
"whitenoise==6.12.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
+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 IMAGE_TYPE, EMAIL_FORMNAME, INCORRECT_PASSWORDS
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(
+109
View File
@@ -0,0 +1,109 @@
{% 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" for="rating-input">
Puntuación
<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 }}">
</label>
{% 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 = Number.parseInt(this.dataset.value);
updateStars(value);
});
star.addEventListener('mouseenter', function() {
const value = Number.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(Number.parseInt(ratingInput.value) || 1);
});
});
});
</script>
{% endblock %}
+3 -3
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;
@@ -44,8 +44,8 @@
</p> </p>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Datos de la tarjeta</label> <label id="label-card-data" class="form-label">Datos de la tarjeta <input type="hidden"></label>
<div id="card-element"></div> <div id="card-element" aria-labelledby="label-card-data"></div>
<div id="card-errors" role="alert"></div> <div id="card-errors" role="alert"></div>
</div> </div>
+25 -8
View File
@@ -6,13 +6,8 @@
<head> <head>
<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">
<title>Comercialmeria</title> <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'"> <meta name="description" content="Sitio web de comercio local Almeriense">
<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>
@@ -111,8 +106,8 @@
<!-- Barra de búsqueda con sugerencias --> <!-- Barra de búsqueda con sugerencias -->
<form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm"> <form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm">
<div class="input-group"> <div class="input-group">
<input class="form-control" type="search" name="q" id="searchInput" placeholder="Buscar productos..." aria-label="Buscar" autocomplete="off" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="searchSuggestions" aria-activedescendant="" aria-haspopup="listbox"> <input class="form-control" type="search" name="q" id="searchInput" placeholder="Buscar productos..." aria-label="Buscar" autocomplete="off" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="searchSuggestions" aria-activedescendant="searchbutton" aria-haspopup="listbox">
<button class="btn btn-outline-primary" type="submit" aria-label="Buscar productos">🔍 Buscar</button> <button class="btn btn-outline-primary" type="submit" id="searchbutton" aria-label="Buscar productos">🔍 Buscar</button>
</div> </div>
<div class="search-suggestions" id="searchSuggestions" role="listbox" aria-label="Sugerencias de búsqueda"></div> <div class="search-suggestions" id="searchSuggestions" role="listbox" aria-label="Sugerencias de búsqueda"></div>
</form> </form>
@@ -344,5 +339,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>
+17 -17
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 {
@@ -84,11 +84,11 @@
<table class="table table-striped align-middle"> <table class="table table-striped align-middle">
<thead> <thead>
<tr> <tr>
<th>Producto</th> <th scope="col">Producto</th>
<th class="text-end">Precio (sin IVA)</th> <th scope="col" class="text-end">Precio (sin IVA)</th>
<th class="text-end">Cantidad</th> <th scope="col" class="text-end">Cantidad</th>
<th class="text-end">Stock actual</th> <th scope="col" class="text-end">Stock actual</th>
<th class="text-end">Subtotal (con IVA)</th> <th scope="col" class="text-end">Subtotal (con IVA)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -104,16 +104,16 @@
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<th colspan="4" class="text-end">Subtotal:</th> <th colspan="4" scope="row" class="text-end">Subtotal:</th>
<th class="text-end">{{ cart.get_total|format_price }}€</th> <td class="text-end">{{ cart.get_total|format_price }}€</th>
</tr> </tr>
<tr> <tr>
<th colspan="4" class="text-end">IVA (21%):</th> <th scope="row" colspan="4" class="text-end">IVA (21%):</th>
<th class="text-end text-success">+{{ cart.get_vat_amount|format_price }}€</th> <td class="text-end text-success">+{{ cart.get_vat_amount|format_price }}€</th>
</tr> </tr>
<tr style="background-color: #f8f9fa;"> <tr style="background-color: #f8f9fa;">
<th colspan="4" class="text-end" style="font-size: 1.1rem;">Total:</th> <th scope="row" colspan="4" class="text-end" style="font-size: 1.1rem;">Total:</th>
<th class="text-end" style="font-size: 1.1rem;">{{ cart.get_total_with_vat|format_price }}€</th> <td class="text-end" style="font-size: 1.1rem;">{{ cart.get_total_with_vat|format_price }}€</th>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -125,7 +125,7 @@
<h5 class="card-title mb-3">2) Selecciona tu método de pago</h5> <h5 class="card-title mb-3">2) Selecciona tu método de pago</h5>
<!-- Tabs --> <!-- Tabs -->
<ul class="nav nav-tabs mb-3" id="paymentTabs" role="tablist"> <ul class="nav nav-tabs mb-3" id="paymentTabs">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-card" data-tab="pane-card" type="button" <button class="nav-link active" id="tab-card" data-tab="pane-card" type="button"
role="tab" aria-selected="true" aria-controls="pane-card" tabindex="0"> role="tab" aria-selected="true" aria-controls="pane-card" tabindex="0">
@@ -142,7 +142,7 @@
<!-- Tarjeta tab --> <!-- Tarjeta tab -->
<div id="pane-card" class="payment-tab-content active" <div id="pane-card" class="payment-tab-content active"
role="tabpanel" aria-labelledby="tab-card" tabindex="0"> role="tabpanel" aria-labelledby="tab-card">
{% if saved_cards %} {% if saved_cards %}
<fieldset class="mb-3"> <fieldset class="mb-3">
<legend class="fw-semibold fs-6 mb-2">Selección de tarjeta</legend> <legend class="fw-semibold fs-6 mb-2">Selección de tarjeta</legend>
@@ -164,8 +164,8 @@
<div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}> <div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Número de tarjeta</label> <label id="label-card-number" class="form-label">Número de tarjeta <input type="hidden"></label>
<div id="card-element"></div> <div id="card-element" aria-labelledby="label-card-number"></div>
<div id="card-errors" role="alert"></div> <div id="card-errors" role="alert"></div>
</div> </div>
<div class="form-check mb-3"> <div class="form-check mb-3">
@@ -187,7 +187,7 @@
<!-- PayPal tab --> <!-- PayPal tab -->
<div id="pane-paypal" class="payment-tab-content" <div id="pane-paypal" class="payment-tab-content"
role="tabpanel" aria-labelledby="tab-paypal" tabindex="0"> role="tabpanel" aria-labelledby="tab-paypal">
{% if saved_paypal %} {% if saved_paypal %}
<div class="alert alert-light border mb-3"> <div class="alert alert-light border mb-3">
<small class="text-muted">Cuenta PayPal guardada:</small> <small class="text-muted">Cuenta PayPal guardada:</small>
+4 -4
View File
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
<td align="center" style="padding: 20px;"> <th align="center" style="padding: 20px;">
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;"> <table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
<tr> <tr>
<td align="center" style="background-color: #007bff; padding: 40px;"> <th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido bloqueada</h1> <h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido bloqueada</h1>
</td> </th>
</tr> </tr>
<tr> <tr>
<td align="center" style="padding: 40px"> <td align="center" style="padding: 40px">
@@ -22,6 +22,6 @@
</td> </td>
</tr> </tr>
</table> </table>
</td> </th>
</tr> </tr>
</table> </table>
+4 -4
View File
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
<td align="center" style="padding: 20px;"> <th scope="col" align="center" style="padding: 20px;">
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;"> <table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
<tr> <tr>
<td align="center" style="background-color: #007bff; padding: 40px;"> <th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1> <h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1>
</td> </th>
</tr> </tr>
<tr> <tr>
<td style="padding: 30px; font-family: sans-serif; line-height: 1.5; color: #444444;"> <td style="padding: 30px; font-family: sans-serif; line-height: 1.5; color: #444444;">
@@ -16,6 +16,6 @@
</td> </td>
</tr> </tr>
</table> </table>
</td> </th>
</tr> </tr>
</table> </table>
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
<td align="center" style="padding: 20px;"> <th scope="col" align="center" style="padding: 20px;">
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;"> <table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
<tr> <tr>
<td align="center" style="background-color: #007bff; padding: 40px;"> <th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1> <h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1>
</td> </th>
</tr> </tr>
<tr> <tr>
<td align="center" style="padding: 40px"> <td align="center" style="padding: 40px">
@@ -22,6 +22,6 @@
</td> </td>
</tr> </tr>
</table> </table>
</td> </th>
</tr> </tr>
</table> </table>
+5 -5
View File
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table border="0" cellpadding="0" style="width: 100%;">
<tr> <tr>
<td align="center" style="padding: 20px;"> <th scope="col" align="center" style="padding: 20px;">
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;"> <table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
<tr> <tr>
<td align="center" style="background-color: #007bff; padding: 40px;"> <th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido desbloqueada</h1> <h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido desbloqueada</h1>
</td> </th>
</tr> </tr>
<tr> <tr>
<td align="center" style="padding: 40px"> <td align="center" style="padding: 40px">
@@ -22,6 +22,6 @@
</td> </td>
</tr> </tr>
</table> </table>
</td> </th>
</tr> </tr>
</table> </table>
+4 -4
View File
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
<td align="center" style="padding: 20px;"> <th scope="col" align="center" style="padding: 20px;">
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;"> <table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
<tr> <tr>
<td align="center" style="background-color: #007bff; padding: 40px;"> <th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1> <h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1>
</td> </th>
</tr> </tr>
<tr> <tr>
<td align="center" style="padding: 40px"> <td align="center" style="padding: 40px">
@@ -21,6 +21,6 @@
</td> </td>
</tr> </tr>
</table> </table>
</td> </th>
</tr> </tr>
</table> </table>
@@ -18,7 +18,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
{% if producto.primary_image %} {% if producto.primary_image %}
<img src="{{ producto.primary_image.image.url }}" alt="{{ producto.primary_image.alt|default:producto.name }}" class="rounded" style="width: 200px; height: 200px; object-fit: cover;"> <img src="{{ producto.primary_image.image.url }}" alt="{{ producto.primary_image.alt|default:producto.name }} - imagen principal" class="rounded" style="width: 200px; height: 200px; object-fit: cover;">
<p class="mt-2 text-muted mb-0">Esta imagen no se puede cambiar desde aquí.</p> <p class="mt-2 text-muted mb-0">Esta imagen no se puede cambiar desde aquí.</p>
{% else %} {% else %}
<p class="text-muted">No hay imagen principal asignada.</p> <p class="text-muted">No hay imagen principal asignada.</p>
+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 type="module">
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);
}
}
await 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"),
] ]
+2 -26
View File
@@ -4,30 +4,6 @@ from django.conf import settings
logger = logging.getLogger("email.system") logger = logging.getLogger("email.system")
#
#def send_email(dest: str, title: str, body: str):
# context = ssl.create_default_context()
# try:
# with smtplib.SMTP(settings.SMTP_ENDPOINT, settings.SMTP_PORT) as server:
#
#
# server.ehlo()
# server.starttls(context=context)
# server.ehlo()
# server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)
#
# message = """\
#Subject: {}
#{}
# """.format(title, body)
# server.sendmail(settings.SMTP_EMAIL, dest, message)
# logger.info("EMAIL_SENT to=%s subject=%s", dest, title)
#
# except Exception as e:
# logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e))
# return (False, e)
#
# return (True,)
def send_email(dest: str, title: str, body: str): def send_email(dest: str, title: str, body: str):
try: try:
@@ -40,7 +16,7 @@ def send_email(dest: str, title: str, body: str):
) )
logger.info("EMAIL_SENT to=%s subject=%s", dest, title) logger.info("EMAIL_SENT to=%s subject=%s", dest, title)
return (True,) return (True, None)
except Exception as e: except Exception as e:
logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e)) logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e))
return (False, e) return (False, e)
@@ -57,7 +33,7 @@ def send_hemail(dest: str, title: str, body: str, nbody: str):
) )
logger.info("EMAIL_SENT to=%s subject=%s", dest, title) logger.info("EMAIL_SENT to=%s subject=%s", dest, title)
return (True,) return (True, None)
except Exception as e: except Exception as e:
logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e)) logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e))
return (False, e) return (False, e)
+471 -320
View File
File diff suppressed because it is too large Load Diff
Generated
+110 -7
View File
@@ -14,6 +14,15 @@ 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" }, { 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]] [[package]]
name = "asgiref" name = "asgiref"
version = "3.11.1" version = "3.11.1"
@@ -324,6 +333,28 @@ 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" }, { 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-environ"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/3c/60983e6ec9b24a8d8588eecebfd21123cba980bce0a905807a27692f0860/django_environ-0.13.0.tar.gz", hash = "sha256:6c401e4c219442c2c4588c2116d5292b5484a6f69163ed09cd41f3943bfb645f", size = 63529, upload-time = "2026-02-18T01:08:08.791Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/00/3767393ece946084e1c6830a33ffb8e39d68642e27ad5ac7d4c8bd5de866/django_environ-0.13.0-py3-none-any.whl", hash = "sha256:37799d14cd78222c6fd8298e48bfe17965ff8e586091ad66a463e52e0e7b799e", size = 20682, upload-time = "2026-02-18T01:08:07.359Z" },
]
[[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]] [[package]]
name = "django-redis" name = "django-redis"
version = "6.0.0" version = "6.0.0"
@@ -407,11 +438,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.13" version = "3.15"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } 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 = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, { 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]] [[package]]
@@ -514,6 +545,8 @@ dependencies = [
{ name = "celery" }, { name = "celery" },
{ name = "django" }, { name = "django" },
{ name = "django-compressor" }, { name = "django-compressor" },
{ name = "django-environ" },
{ name = "django-ninja" },
{ name = "django-redis" }, { name = "django-redis" },
{ name = "django-storages", extra = ["s3"] }, { name = "django-storages", extra = ["s3"] },
{ name = "fpdf2" }, { name = "fpdf2" },
@@ -531,6 +564,8 @@ requires-dist = [
{ name = "celery", specifier = "==5.6.3" }, { name = "celery", specifier = "==5.6.3" },
{ name = "django", specifier = "==6.0.5" }, { name = "django", specifier = "==6.0.5" },
{ name = "django-compressor", specifier = "==4.6.0" }, { name = "django-compressor", specifier = "==4.6.0" },
{ name = "django-environ", specifier = ">=0.13.0" },
{ name = "django-ninja", specifier = ">=1.6.2" },
{ name = "django-redis", specifier = "==6.0.0" }, { name = "django-redis", specifier = "==6.0.0" },
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" }, { name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
{ name = "fpdf2", specifier = "==2.8.7" }, { name = "fpdf2", specifier = "==2.8.7" },
@@ -538,7 +573,7 @@ requires-dist = [
{ name = "paypalrestsdk", specifier = "==1.13.3" }, { name = "paypalrestsdk", specifier = "==1.13.3" },
{ name = "pillow", specifier = "==12.2.0" }, { name = "pillow", specifier = "==12.2.0" },
{ name = "psycopg2-binary", specifier = "==2.9.12" }, { name = "psycopg2-binary", specifier = "==2.9.12" },
{ name = "requests", specifier = "==2.33.1" }, { name = "requests", specifier = "==2.34.2" },
{ name = "stripe", specifier = "==15.1.0" }, { name = "stripe", specifier = "==15.1.0" },
{ name = "whitenoise", specifier = "==6.12.0" }, { name = "whitenoise", specifier = "==6.12.0" },
] ]
@@ -571,6 +606,62 @@ 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" }, { 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]] [[package]]
name = "pyopenssl" name = "pyopenssl"
version = "26.2.0" version = "26.2.0"
@@ -626,7 +717,7 @@ wheels = [
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.33.1" version = "2.34.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "certifi" }, { name = "certifi" },
@@ -634,9 +725,9 @@ dependencies = [
{ name = "idna" }, { name = "idna" },
{ name = "urllib3" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } 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 = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, { 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]] [[package]]
@@ -711,6 +802,18 @@ 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" }, { 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]] [[package]]
name = "tzdata" name = "tzdata"
version = "2026.2" version = "2026.2"