Compare commits

..

53 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
35 changed files with 1302 additions and 693 deletions
+10
View File
@@ -7,3 +7,13 @@ venv
db.sqlite3
static
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
steps:
- name: Checkout del código
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Configurar Python
uses: actions/setup-python@v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.14'
- name: Configurar uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6
- name: Instalar dependencias
run: |
uv sync --no-dev --no-install-project
@@ -28,7 +28,7 @@ jobs:
env:
DJANGO_SETTINGS_MODULE: proyecto.settings
run: |
uv run python manage.py test
SECRET_KEY=testkeynotuseinproducto uv run python manage.py test
docker:
runs-on: ubuntu-latest
@@ -38,13 +38,13 @@ jobs:
steps:
- name: Checkout del código
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Configurar Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Build (sin push)
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
push: false
+8 -8
View File
@@ -13,13 +13,13 @@ jobs:
contents: read
steps:
- name: Checkout del código
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Configurar Python
uses: actions/setup-python@v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.14'
- name: Configurar uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6
- name: Instalar dependencias
run: |
uv sync --no-dev --no-install-project
@@ -27,7 +27,7 @@ jobs:
env:
DJANGO_SETTINGS_MODULE: proyecto.settings
run: |
uv run python manage.py test
SECRET_KEY=donotusethisinproductionitisunsafe uv run python manage.py test
docker:
runs-on: ubuntu-latest
needs: test
@@ -37,13 +37,13 @@ jobs:
steps:
- name: Checkout del código
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Configurar Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Login en GHCR
uses: docker/login-action@v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -55,7 +55,7 @@ jobs:
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV
- name: Build y Push
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
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
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
COPY ./proyecto /app/proyecto
COPY ./tienda /app/tienda
COPY ./manage.py /app/manage.py
EXPOSE 8000
RUN mkdir -pv /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"]
+72 -110
View File
@@ -11,84 +11,47 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
"""
import logging
import os, sys
import os
import sys
from pathlib import Path
import environ
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
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'.
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
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# 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!
DEBUG = env_bool('DEBUG', True)
S3_ENABLE = env_bool('S3_ENABLE', False)
S3_USE_LOCAL_URLS = env_bool('S3_USE_LOCAL_URLS', False)
DEBUG = env.bool('DEBUG')
S3_ENABLE = env.bool('S3_ENABLE')
S3_USE_LOCAL_URLS = env.bool('S3_USE_LOCAL_URLS')
ALLOWED_HOSTS = env_list('ALLOWED_HOSTS', [
'192.168.1.142',
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[
'localhost',
'127.0.0.1',
'zkqpv8r3-8000.uks1.devtunnels.ms'
])
@@ -104,6 +67,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
'django.forms',
'compressor',
'ninja',
]
if S3_ENABLE:
@@ -147,33 +111,25 @@ WSGI_APPLICATION = 'proyecto.wsgi.application'
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
# Usa PostgreSQL por defecto (POSTGRES_ENABLED=True); si no, SQLite.
if RUNNING_TESTS:
if RUNNING_TESTS or not env.bool('POSTGRES_ENABLED'):
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.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:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'ENGINE': 'django.db.backends.postgresql',
'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
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
@@ -208,10 +164,10 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
COMPRESS_ROOT = STATIC_ROOT
COMPRESS_URL = STATIC_URL
STATICFILES_DIRS = [
BASE_DIR / 'tienda' / 'static',
]
@@ -230,15 +186,15 @@ STORAGES = {
}
if S3_ENABLE:
AWS_STORAGE_BUCKET_NAME = env_str('AWS_STORAGE_BUCKET_NAME') or None
AWS_ACCESS_KEY_ID = env_optional_str('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = env_optional_str('AWS_SECRET_ACCESS_KEY')
AWS_S3_REGION_NAME = env_optional_str('AWS_S3_REGION_NAME')
AWS_S3_ENDPOINT_URL = env_optional_str('AWS_S3_ENDPOINT_URL')
AWS_S3_CUSTOM_DOMAIN = env_optional_str('AWS_S3_CUSTOM_DOMAIN')
AWS_S3_USE_SSL = env_bool('AWS_S3_USE_SSL', True)
AWS_QUERYSTRING_AUTH = env_bool('AWS_QUERYSTRING_AUTH', False)
AWS_DEFAULT_ACL = env_str('AWS_DEFAULT_ACL', 'public-read') or None
AWS_STORAGE_BUCKET_NAME = env('AWS_STORAGE_BUCKET_NAME', default='') or None
AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID', default=None)
AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY', default=None)
AWS_S3_REGION_NAME = env('AWS_S3_REGION_NAME', default=None)
AWS_S3_ENDPOINT_URL = env('AWS_S3_ENDPOINT_URL', default=None)
AWS_S3_CUSTOM_DOMAIN = env('AWS_S3_CUSTOM_DOMAIN', default=None)
AWS_S3_USE_SSL = env.bool('AWS_S3_USE_SSL')
AWS_QUERYSTRING_AUTH = env.bool('AWS_QUERYSTRING_AUTH')
AWS_DEFAULT_ACL = env('AWS_DEFAULT_ACL', default='public-read') or None
AWS_S3_OBJECT_PARAMETERS = {}
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 = [
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
@@ -258,15 +222,13 @@ STATICFILES_FINDERS = [
COMPRESS_PRECOMPILERS = ()
# Media files (User uploads)
MEDIA_URL = 'media/'
MEDIA_ROOT = Path(os.getenv('MEDIA_ROOT', '/app/media'))
MEDIA_ROOT = Path(env('MEDIA_ROOT', default='/app/media'))
# Redis Configuration
CACHES = {
'default': {
'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': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
}
@@ -291,30 +253,30 @@ MESSAGE_TAGS = {
# Login URL
LOGIN_URL = '/tienda/login/'
STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY', '')
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '')
STRIPE_PUBLISHABLE_KEY = env('STRIPE_PUBLISHABLE_KEY', default='')
STRIPE_SECRET_KEY = env('STRIPE_SECRET_KEY', default='')
# PayPal Configuration (Sandbox)
# 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_SECRET = os.getenv('PAYPAL_CLIENT_SECRET', '') # Reemplazar con tu Client Secret de PayPal Sandbox
PAYPAL_MODE = os.getenv('PAYPAL_MODE', 'sandbox') # Cambiar a 'live' en producción
PAYPAL_CLIENT_ID = env('PAYPAL_CLIENT_ID', default='') # Reemplazar con tu Client ID de PayPal Sandbox
PAYPAL_CLIENT_SECRET = env('PAYPAL_CLIENT_SECRET', default='') # Reemplazar con tu Client Secret de PayPal Sandbox
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_PORT = env_int('SMTP_PORT', 587)
SECURITY = os.getenv('SECURITY', 'tls')
SMTP_USERNAME = os.getenv('SMTP_USERNAME', None)
SMTP_PASSWORD = os.getenv('SMTP_PASSWORD', None)
SMTP_EMAIL = os.getenv("SMTP_EMAIL", None)
SMTP_ENDPOINT = env('SMTP_ENDPOINT', default='smtp.email.eu-paris-1.oci.oraclecloud.com')
SMTP_PORT = env.int('SMTP_PORT')
SECURITY = env('SECURITY', default='tls')
SMTP_USERNAME = env('SMTP_USERNAME', default=None)
SMTP_PASSWORD = env('SMTP_PASSWORD', default=None)
SMTP_EMAIL = env('SMTP_EMAIL', default=None)
AUTH_USER_MODEL = 'tienda.User'
DOMAIN = os.getenv("DOMAIN", "localhost")
PROTOCOL = os.getenv("PROTOCOL", "http")
DOMAIN = env('DOMAIN', default='localhost')
PROTOCOL = env('PROTOCOL', default='http')
default_csrf_trusted_origins = []
if DOMAIN:
@@ -324,16 +286,16 @@ for host in ALLOWED_HOSTS:
if host and host != '*':
default_csrf_trusted_origins.append(f"{PROTOCOL}://{host}")
CSRF_TRUSTED_ORIGINS = env_list(
CSRF_TRUSTED_ORIGINS = env.list(
'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_DIR = Path(os.getenv('LOG_DIR', BASE_DIR / 'logs'))
LOG_LEVEL = env('LOG_LEVEL', default='INFO').upper()
LOG_DIR = Path(env('LOG_DIR', default=str(BASE_DIR / 'logs')))
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 = {
@@ -407,13 +369,13 @@ EMAIL_HOST_USER = SMTP_USERNAME
EMAIL_HOST_PASSWORD = SMTP_PASSWORD
# 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)
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
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
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.urls import include, path
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 = [
path('', tienda_views.home, name='home'),
path('admin/', admin.site.urls),
path('tienda/', include('tienda.urls'))
path('tienda/', include('tienda.urls')),
path('api/', api.urls),
]
if settings.DEBUG and (
+4 -2
View File
@@ -7,6 +7,8 @@ dependencies = [
"celery==5.6.3",
"Django==6.0.5",
"django-compressor==4.6.0",
"django-environ>=0.13.0",
"django-ninja>=1.6.2",
"django-redis==6.0.0",
# S3 backend requerido por tienda/storage_backends.py cuando S3_ENABLE=True.
"django-storages[s3]==1.14.6",
@@ -15,8 +17,8 @@ dependencies = [
"paypalrestsdk==1.13.3",
"pillow==12.2.0",
"psycopg2-binary==2.9.12",
"requests==2.33.1",
"stripe==15.1.0",
"requests==2.34.2",
"stripe==15.2.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
+8 -3
View File
@@ -1,5 +1,5 @@
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.
from django.shortcuts import redirect
from django.urls import path
@@ -20,7 +20,6 @@ class UserAdmin(admin.ModelAdmin):
def banear_usuario_action(self, request, queryset):
usuarios_baneados = 0
for user in queryset:
user: User = user
# Desactiva usuario
if user.registration_status == User.RegisterStatus.BANNED:
continue
@@ -43,7 +42,6 @@ class UserAdmin(admin.ModelAdmin):
def desbanear_usuario_action(self, request, queryset):
user_desbaneados = 0
for user in queryset:
user: User = user
if user.registration_status != User.RegisterStatus.BANNED:
continue
@@ -151,3 +149,10 @@ class SavedPaymentMethodAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'method_type', 'label', 'is_default', 'created_at')
list_filter = ('method_type', 'is_default', 'created_at')
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"
+41 -9
View File
@@ -2,10 +2,13 @@ from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator, MinLengthValidator, MaxLengthValidator
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:
@@ -76,7 +79,7 @@ class ProductForm(forms.Form):
widget = forms.ClearableFileInput(
attrs = {
'class': 'form-control',
'accept': 'image/*'
'accept': IMAGE_TYPE
}
)
)
@@ -133,7 +136,7 @@ class SecondaryImageForm(forms.Form):
widget = forms.ClearableFileInput(
attrs = {
'class': 'form-control',
'accept': 'image/*'
'accept': IMAGE_TYPE
}
)
)
@@ -192,7 +195,7 @@ class UserRegisterForm(forms.Form):
)
)
email = forms.EmailField(
label = "Correo Electrónico",
label = EMAIL_FORMNAME,
max_length = 255,
required = True,
widget = forms.TextInput(
@@ -236,7 +239,7 @@ class UserRegisterForm(forms.Form):
password = cleaned_data.get("password")
password_confirm = cleaned_data.get("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):
@@ -253,7 +256,7 @@ class EditProfileForm(forms.Form):
widget=forms.TextInput(attrs={'class': 'form-control'})
)
email = forms.EmailField(
label="Correo Electrónico",
label=EMAIL_FORMNAME,
max_length=254,
required=True,
widget=forms.EmailInput(attrs={'class': 'form-control'})
@@ -285,7 +288,7 @@ class ChangePasswordForm(forms.Form):
new_password = cleaned_data.get("new_password")
confirm_password = cleaned_data.get("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:
raise ValidationError("La contraseña debe tener al menos 8 caracteres.")
@@ -343,7 +346,7 @@ class ShippingAddressForm(forms.Form):
class ResetPasswordForm(forms.Form):
email = forms.EmailField(
label="Correo Electrónico",
label=EMAIL_FORMNAME,
max_length=254,
required=True,
widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'tu@email.com'})
@@ -369,4 +372,33 @@ class ResetPasswordPhase2Form(forms.Form):
password = cleaned_data.get("password")
verify_password = cleaned_data.get("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),
),
]
+78 -5
View File
@@ -6,11 +6,37 @@ from django.contrib.auth.models import User, AbstractUser
from django.core.validators import MaxValueValidator
from django.utils.crypto import get_random_string
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:
while True:
code = f"{TRANSACTION_CODE_PREFIX}{get_random_string(TRANSACTION_CODE_LENGTH, TRANSACTION_CODE_ALPHABET)}"
@@ -48,9 +74,10 @@ class VerificationCode(models.Model):
default = VerificationModes.VERIFY_ACCOUNT
)
@staticmethod
def generate(user: User, code_mode: str) -> VerificationCode:
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():
return VerificationCode.objects.create(
code = code,
@@ -122,6 +149,26 @@ class Product(models.Model):
"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):
STATUS_ACTIVE = "active"
@@ -143,7 +190,7 @@ class StockReservation(models.Model):
]
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)
payment_method = models.CharField(max_length=20, choices=PAYMENT_CHOICES)
expires_at = models.DateTimeField(db_index=True)
@@ -173,10 +220,17 @@ class StockReservationItem(models.Model):
class Cart(models.Model):
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)
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):
return f"Cart {self.id} - {self.user or self.session_key}"
@@ -238,7 +292,7 @@ class Order(models.Model):
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')
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)
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)
@@ -331,6 +385,25 @@ class SavedPaymentMethod(models.Model):
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):
"""Direcciones de entrega de los usuarios"""
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):
pdf = Recibo()
font_path = "/fonts/Roboto-Regular.ttf"
pdf.add_font('Roboto', '', '/fonts/Roboto-Regular.ttf')
pdf.add_font('Roboto', 'B', '/fonts/Roboto-Bold.ttf')
pdf.add_page()
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"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.append(
+20
View File
@@ -318,3 +318,23 @@ p.price {
overflow-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 .utilities import send_email, send_hemail
from .vars import login_message, verify_message
import random, string
import secrets
import string
from . import pdf
from .models import User, VerificationCode
@@ -43,7 +44,7 @@ def enviar_correo_confirmacion(id: int):
code = VerificationCode.objects.create(
user = usuario,
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)
@@ -60,7 +61,7 @@ def enviar_correo_recuperacion(email: str):
ver_code = VerificationCode.objects.create(
code_mode = VerificationCode.VerificationModes.RESET_PASSWORD,
user = usuario,
code = ''.join(random.choices(string.digits, k=12))
code = ''.join(secrets.choice(string.digits) for _ in range(12))
)
ver_code.save()
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 %}
{% block head %}
<script src="https://js.stripe.com/v3/"></script>
<script src="https://js.stripe.com/v3/" integrity="sha384-353f1ae25ae0929bea5f9379a594131b27e45a89d8f918dcc040c4ccbe6fd35fe6fd1d61ccc6e0c911c9b54325235904"></script>
<style>
#card-element {
border: 1px solid #ced4da;
@@ -44,8 +44,8 @@
</p>
<div class="mb-3">
<label class="form-label">Datos de la tarjeta</label>
<div id="card-element"></div>
<label id="label-card-data" class="form-label">Datos de la tarjeta <input type="hidden"></label>
<div id="card-element" aria-labelledby="label-card-data"></div>
<div id="card-errors" role="alert"></div>
</div>
+25 -8
View File
@@ -6,13 +6,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Sitio web de comercio local Almeriense">
<title>Comercialmeria</title>
<link rel="preload" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
</noscript>
<meta name="description" content="Sitio web de comercio local Almeriense">
<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'">
<noscript><link rel="stylesheet" href="{% static 'css/custom.css' %}"></noscript>
@@ -111,8 +106,8 @@
<!-- Barra de búsqueda con sugerencias -->
<form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm">
<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">
<button class="btn btn-outline-primary" type="submit" aria-label="Buscar productos">🔍 Buscar</button>
<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" id="searchbutton" aria-label="Buscar productos">🔍 Buscar</button>
</div>
<div class="search-suggestions" id="searchSuggestions" role="listbox" aria-label="Sugerencias de búsqueda"></div>
</form>
@@ -344,5 +339,27 @@
});
</script>
{% 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>
</html>
+17 -17
View File
@@ -3,7 +3,7 @@
{% load vat_filters %}
{% 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>
<style>
#card-element {
@@ -84,11 +84,11 @@
<table class="table table-striped align-middle">
<thead>
<tr>
<th>Producto</th>
<th class="text-end">Precio (sin IVA)</th>
<th class="text-end">Cantidad</th>
<th class="text-end">Stock actual</th>
<th class="text-end">Subtotal (con IVA)</th>
<th scope="col">Producto</th>
<th scope="col" class="text-end">Precio (sin IVA)</th>
<th scope="col" class="text-end">Cantidad</th>
<th scope="col" class="text-end">Stock actual</th>
<th scope="col" class="text-end">Subtotal (con IVA)</th>
</tr>
</thead>
<tbody>
@@ -104,16 +104,16 @@
</tbody>
<tfoot>
<tr>
<th colspan="4" class="text-end">Subtotal:</th>
<th class="text-end">{{ cart.get_total|format_price }}€</th>
<th colspan="4" scope="row" class="text-end">Subtotal:</th>
<td class="text-end">{{ cart.get_total|format_price }}€</th>
</tr>
<tr>
<th colspan="4" class="text-end">IVA (21%):</th>
<th class="text-end text-success">+{{ cart.get_vat_amount|format_price }}€</th>
<th scope="row" colspan="4" class="text-end">IVA (21%):</th>
<td class="text-end text-success">+{{ cart.get_vat_amount|format_price }}€</th>
</tr>
<tr style="background-color: #f8f9fa;">
<th 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>
<th scope="row" colspan="4" class="text-end" style="font-size: 1.1rem;">Total:</th>
<td class="text-end" style="font-size: 1.1rem;">{{ cart.get_total_with_vat|format_price }}€</th>
</tr>
</tfoot>
</table>
@@ -125,7 +125,7 @@
<h5 class="card-title mb-3">2) Selecciona tu método de pago</h5>
<!-- 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">
<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">
@@ -142,7 +142,7 @@
<!-- Tarjeta tab -->
<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 %}
<fieldset class="mb-3">
<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 class="mb-3">
<label class="form-label">Número de tarjeta</label>
<div id="card-element"></div>
<label id="label-card-number" class="form-label">Número de tarjeta <input type="hidden"></label>
<div id="card-element" aria-labelledby="label-card-number"></div>
<div id="card-errors" role="alert"></div>
</div>
<div class="form-check mb-3">
@@ -187,7 +187,7 @@
<!-- PayPal tab -->
<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 %}
<div class="alert alert-light border mb-3">
<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">
<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;">
<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>
</td>
</th>
</tr>
<tr>
<td align="center" style="padding: 40px">
@@ -22,6 +22,6 @@
</td>
</tr>
</table>
</td>
</th>
</tr>
</table>
+4 -4
View File
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<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;">
<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>
</td>
</th>
</tr>
<tr>
<td style="padding: 30px; font-family: sans-serif; line-height: 1.5; color: #444444;">
@@ -16,6 +16,6 @@
</td>
</tr>
</table>
</td>
</th>
</tr>
</table>
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<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;">
<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>
</td>
</th>
</tr>
<tr>
<td align="center" style="padding: 40px">
@@ -22,6 +22,6 @@
</td>
</tr>
</table>
</td>
</th>
</tr>
</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>
<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;">
<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>
</td>
</th>
</tr>
<tr>
<td align="center" style="padding: 40px">
@@ -22,6 +22,6 @@
</td>
</tr>
</table>
</td>
</th>
</tr>
</table>
+4 -4
View File
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<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;">
<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>
</td>
</th>
</tr>
<tr>
<td align="center" style="padding: 40px">
@@ -21,6 +21,6 @@
</td>
</tr>
</table>
</td>
</th>
</tr>
</table>
@@ -18,7 +18,7 @@
</div>
<div class="card-body">
{% 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>
{% else %}
<p class="text-muted">No hay imagen principal asignada.</p>
+97
View File
@@ -62,4 +62,101 @@
{{ product.description }}
</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 %}
+5 -4
View File
@@ -16,6 +16,7 @@ from .models import (
)
from .forms import UserRegisterForm, UserLoginForm, EditProfileForm, ChangePasswordForm, ShippingAddressForm, ResetPasswordForm, ResetPasswordPhase2Form
from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX
import secrets
import string
import random
@@ -335,7 +336,7 @@ class VerificationCodeModelTests(TestCase):
"""50 códigos pueden crearse sin conflictos."""
codes = []
for i in range(50):
mode = random.choice([
mode = secrets.choice([
VerificationCode.VerificationModes.VERIFY_ACCOUNT,
VerificationCode.VerificationModes.RESET_PASSWORD
])
@@ -377,7 +378,7 @@ class CategoryModelTests(TestCase):
"""100 categorías pueden crearse sin problemas."""
categories = []
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)
self.assertEqual(len(categories), 100)
@@ -1786,7 +1787,7 @@ class EndpointViewTests(TestCase):
self.assertTrue(OrderMessage.objects.filter(order_item=item, sender=self.seller).exists())
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]))
self.assertEqual(delete_post.status_code, 302)
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")
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]))
self.assertEqual(delete_post.status_code, 302)
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("ayuda", views.ayuda, name="ayuda"),
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")
#
#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):
try:
@@ -40,7 +16,7 @@ def send_email(dest: str, title: str, body: str):
)
logger.info("EMAIL_SENT to=%s subject=%s", dest, title)
return (True,)
return (True, None)
except Exception as e:
logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(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)
return (True,)
return (True, None)
except Exception as e:
logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e))
return (False, e)
+443 -310
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" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "asgiref"
version = "3.11.1"
@@ -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" },
]
[[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]]
name = "django-redis"
version = "6.0.0"
@@ -407,11 +438,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.13"
version = "3.15"
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 = [
{ 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]]
@@ -514,6 +545,8 @@ dependencies = [
{ name = "celery" },
{ name = "django" },
{ name = "django-compressor" },
{ name = "django-environ" },
{ name = "django-ninja" },
{ name = "django-redis" },
{ name = "django-storages", extra = ["s3"] },
{ name = "fpdf2" },
@@ -531,6 +564,8 @@ requires-dist = [
{ name = "celery", specifier = "==5.6.3" },
{ name = "django", specifier = "==6.0.5" },
{ 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-storages", extras = ["s3"], specifier = "==1.14.6" },
{ name = "fpdf2", specifier = "==2.8.7" },
@@ -538,7 +573,7 @@ requires-dist = [
{ name = "paypalrestsdk", specifier = "==1.13.3" },
{ name = "pillow", specifier = "==12.2.0" },
{ 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 = "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" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
]
[[package]]
name = "pyopenssl"
version = "26.2.0"
@@ -626,7 +717,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.33.1"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -634,9 +725,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/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]]
@@ -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" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "tzdata"
version = "2026.2"