Compare commits
66 Commits
f9b3bc7096
..
latest
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d319d8efa | |||
| 874f4e29db | |||
| 131fe8fecc | |||
| b154da09a5 | |||
| f47fd21deb | |||
| ac27137b77 | |||
| 7c445d4b66 | |||
| e4f0611ac5 | |||
| 33dee87cb2 | |||
| 3de6d37e03 | |||
| 5503bbe8f7 | |||
| dd5ecec3f6 | |||
| c778669a7a | |||
| 09f6f800de | |||
| 1ac17109a3 | |||
| 325e55417b | |||
| e363bfd6dd | |||
| 90308d2383 | |||
| de4f36a25c | |||
| 424ffcffaf | |||
| f0a638be2e | |||
| a61664a46e | |||
| 1a73a9e373 | |||
| 4877e859bd | |||
| 848a49c92d | |||
| ac9efaaf91 | |||
| 2024e2f90c | |||
| 6ec0f4e732 | |||
| 35e7e93600 | |||
| a7f43483f0 | |||
| d773addc53 | |||
| b143d92cb2 | |||
| 9d7a7f7432 | |||
| 0bb2eeeaa6 | |||
| b9acf6a1c7 | |||
| 57efd95b0c | |||
| 5696fdddaa | |||
| 37383b0736 | |||
| 784fdd1284 | |||
| 336e499973 | |||
| e4fa941fd6 | |||
| 48b3f46623 | |||
| 8caba9b85b | |||
| d0f687f56f | |||
| e70a9aeb9c | |||
| e0350de530 | |||
| 62bf3fdc08 | |||
| 2b2054ace6 | |||
| f129b0462a | |||
| aa047b3fd8 | |||
| 429b531bad | |||
| 0438a77149 | |||
| 40f0ef8ea5 | |||
| e53ecef5dc | |||
| bf39724837 | |||
| 6f82787022 | |||
| 46343c1ea8 | |||
| 76c8a277da | |||
| 169a6d9dfb | |||
| f59841b5b8 | |||
| 32c1e1e6ff | |||
| 8a0335fabc | |||
| 74b9d3bbc6 | |||
| ffe7828d8e | |||
| a12954fb84 | |||
| 7f50674bb8 |
@@ -7,3 +7,13 @@ venv
|
||||
db.sqlite3
|
||||
static
|
||||
media
|
||||
docs
|
||||
logs
|
||||
staticfiles
|
||||
.gitignore
|
||||
AGENTS.md
|
||||
Dockerfile
|
||||
Makefile
|
||||
nginx.conf
|
||||
Procfile
|
||||
uv.lock
|
||||
@@ -1,15 +1,9 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "uv"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "daily"
|
||||
allow:
|
||||
- dependency-type: "direct"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,15 +9,17 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
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
|
||||
@@ -25,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
|
||||
@@ -35,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 }}
|
||||
@@ -53,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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
+5
-2
@@ -1,3 +1,4 @@
|
||||
# UV Config file
|
||||
[project]
|
||||
name = "proyecto-final"
|
||||
version = "0.1.0"
|
||||
@@ -6,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",
|
||||
@@ -14,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
@@ -1,101 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -u
|
||||
|
||||
readonly HOSTS=(
|
||||
"aws-docker-mysql"
|
||||
"aws-docker-redis"
|
||||
"aws-docker-celery"
|
||||
"aws-docker"
|
||||
)
|
||||
|
||||
readonly WAIT_SECONDS=5
|
||||
readonly REMOTE_DEPLOY_DIR="/root/deploys"
|
||||
|
||||
usage() {
|
||||
echo "Uso: $0 {start|stop|restart|update}"
|
||||
}
|
||||
|
||||
print_status() {
|
||||
local action="$1"
|
||||
local host="$2"
|
||||
local status="$3"
|
||||
|
||||
# Estilo similar al output de OpenRC.
|
||||
printf "* %-8s %-16s [%s]\n" "$action" "$host" "$status"
|
||||
}
|
||||
|
||||
run_remote_compose() {
|
||||
local host="$1"
|
||||
local command="$2"
|
||||
|
||||
ssh -o BatchMode=yes -o LogLevel=ERROR -T "$host" "sudo -n sh -c \"cd '$REMOTE_DEPLOY_DIR' || exit 1; if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then docker compose $command; elif command -v docker-compose >/dev/null 2>&1; then docker-compose $command; else exit 1; fi\"" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
run_for_all_hosts() {
|
||||
local mode="$1"
|
||||
local host=""
|
||||
local i=0
|
||||
local total=${#HOSTS[@]}
|
||||
|
||||
for host in "${HOSTS[@]}"; do
|
||||
case "$mode" in
|
||||
start)
|
||||
if run_remote_compose "$host" "up -d"; then
|
||||
print_status "Started" "$host" "ok"
|
||||
else
|
||||
print_status "Started" "$host" "fail"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
stop)
|
||||
if run_remote_compose "$host" "down"; then
|
||||
print_status "Stopped" "$host" "ok"
|
||||
else
|
||||
print_status "Stopped" "$host" "fail"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
restart)
|
||||
if run_remote_compose "$host" "down" && run_remote_compose "$host" "up -d"; then
|
||||
print_status "Restarted" "$host" "ok"
|
||||
else
|
||||
print_status "Restarted" "$host" "fail"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
update)
|
||||
if run_remote_compose "$host" "pull" && run_remote_compose "$host" "down" && run_remote_compose "$host" "up -d"; then
|
||||
print_status "Updated" "$host" "ok"
|
||||
else
|
||||
print_status "Updated" "$host" "fail"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
i=$((i + 1))
|
||||
if [ "$i" -lt "$total" ]; then
|
||||
sleep "$WAIT_SECONDS"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
start|stop|restart|update)
|
||||
run_for_all_hosts "$1"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Script para testear la configuración de PayPal
|
||||
Ejecutar: python test_paypal.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
def main() -> None:
|
||||
# Configurar Django solo cuando se ejecuta script manualmente.
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'proyecto.settings')
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
django.setup()
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
print("=" * 60)
|
||||
print("TEST DE CONFIGURACIÓN DE PAYPAL")
|
||||
print("=" * 60)
|
||||
|
||||
# Verificar configuración
|
||||
print("\n1. Verificando configuración en settings.py:")
|
||||
print(f" PAYPAL_MODE: {settings.PAYPAL_MODE}")
|
||||
print(f" PAYPAL_CLIENT_ID: {settings.PAYPAL_CLIENT_ID[:20]}..." if settings.PAYPAL_CLIENT_ID else " ❌ NO CONFIGURADO")
|
||||
print(f" PAYPAL_CLIENT_SECRET: {settings.PAYPAL_CLIENT_SECRET[:20]}..." if settings.PAYPAL_CLIENT_SECRET else " ❌ NO CONFIGURADO")
|
||||
|
||||
# Intentar importar paypalrestsdk
|
||||
print("\n2. Verificando SDK de PayPal:")
|
||||
try:
|
||||
import paypalrestsdk
|
||||
print(" ✓ paypalrestsdk importado correctamente")
|
||||
print(f" Versión: {paypalrestsdk.__version__ if hasattr(paypalrestsdk, '__version__') else 'Desconocida'}")
|
||||
except ImportError as e:
|
||||
print(f" ❌ Error: {e}")
|
||||
print(" SOLUCIÓN: uv add paypalrestsdk")
|
||||
sys.exit(1)
|
||||
|
||||
# Intentar conectar a PayPal
|
||||
print("\n3. Probando conexión a PayPal:")
|
||||
try:
|
||||
paypalrestsdk.configure({
|
||||
"mode": settings.PAYPAL_MODE,
|
||||
"client_id": settings.PAYPAL_CLIENT_ID,
|
||||
"client_secret": settings.PAYPAL_CLIENT_SECRET
|
||||
})
|
||||
print(" ✓ Configuración de PayPal aplicada")
|
||||
|
||||
# Intentar crear un pago de prueba
|
||||
print("\n4. Creando pago de prueba:")
|
||||
test_payment = paypalrestsdk.Payment({
|
||||
"intent": "sale",
|
||||
"payer": {
|
||||
"payment_method": "paypal"
|
||||
},
|
||||
"redirect_urls": {
|
||||
"return_url": "http://localhost:8000/test-return",
|
||||
"cancel_url": "http://localhost:8000/test-cancel"
|
||||
},
|
||||
"transactions": [
|
||||
{
|
||||
"amount": {
|
||||
"total": "10.00",
|
||||
"currency": "EUR",
|
||||
"details": {
|
||||
"subtotal": "10.00",
|
||||
"tax": "0",
|
||||
"shipping": "0"
|
||||
}
|
||||
},
|
||||
"description": "Pago de prueba",
|
||||
"item_list": {
|
||||
"items": [
|
||||
{
|
||||
"name": "Test Item",
|
||||
"sku": "test_1",
|
||||
"price": "10.00",
|
||||
"currency": "EUR",
|
||||
"quantity": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
if test_payment.create():
|
||||
print(" ✓ Pago creado exitosamente")
|
||||
print(f" Payment ID: {test_payment.id}")
|
||||
for link in test_payment.links:
|
||||
if link.rel == "approval_url":
|
||||
print(f" URL de aprobación: {link.href}")
|
||||
else:
|
||||
print(" ❌ Error al crear el pago:")
|
||||
if hasattr(test_payment, 'error') and test_payment.error:
|
||||
print(f" {test_payment.error}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Error de conexión: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST COMPLETADO")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Script de prueba para el cacheo de productos en Redis
|
||||
Ejecutar: python test_product_cache.py
|
||||
"""
|
||||
import os
|
||||
import django
|
||||
|
||||
# Configurar Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'proyecto.settings')
|
||||
django.setup()
|
||||
|
||||
from tienda.models import Product
|
||||
from django.core.cache import cache
|
||||
import time
|
||||
|
||||
def test_product_cache():
|
||||
"""Prueba el sistema de cacheo de productos"""
|
||||
print("=" * 60)
|
||||
print("TEST: Sistema de Cacheo de Productos en Redis")
|
||||
print("=" * 60)
|
||||
|
||||
# Obtener un producto de prueba
|
||||
try:
|
||||
product = Product.objects.first()
|
||||
if not product:
|
||||
print("❌ No hay productos en la base de datos para probar")
|
||||
return
|
||||
|
||||
product_id = product.id
|
||||
cache_key = f'product_{product_id}'
|
||||
|
||||
print(f"\n📦 Producto de prueba: {product.name} (ID: {product_id})")
|
||||
|
||||
# 1. Limpiar caché del producto
|
||||
cache.delete(cache_key)
|
||||
print(f"\n1️⃣ Caché limpiado")
|
||||
|
||||
# 2. Primera visita (debe cargar desde BD)
|
||||
print(f"\n2️⃣ Primera visita - Cargando desde BD...")
|
||||
start_time = time.time()
|
||||
cached_product = cache.get(cache_key)
|
||||
if cached_product is None:
|
||||
print(" ✅ No está en caché (esperado)")
|
||||
product_from_db = Product.objects.select_related('category', 'primary_image', 'creator').prefetch_related('secondary_images').get(id=product_id)
|
||||
cache.set(cache_key, product_from_db, 300)
|
||||
print(f" ✅ Producto cacheado por 5 minutos")
|
||||
db_time = (time.time() - start_time) * 1000
|
||||
|
||||
# 3. Segunda visita (debe cargar desde caché)
|
||||
print(f"\n3️⃣ Segunda visita - Cargando desde caché...")
|
||||
start_time = time.time()
|
||||
cached_product = cache.get(cache_key)
|
||||
if cached_product:
|
||||
print(f" ✅ Encontrado en caché: {cached_product.name}")
|
||||
cache_time = (time.time() - start_time) * 1000
|
||||
|
||||
# 4. Comparar tiempos
|
||||
print(f"\n⏱️ Comparación de rendimiento:")
|
||||
print(f" - Desde BD: {db_time:.2f}ms")
|
||||
print(f" - Desde caché: {cache_time:.2f}ms")
|
||||
speedup = db_time / cache_time if cache_time > 0 else float('inf')
|
||||
print(f" - Mejora: {speedup:.1f}x más rápido")
|
||||
|
||||
# 5. Verificar TTL
|
||||
ttl = cache.ttl(cache_key)
|
||||
print(f"\n⏳ TTL (tiempo de vida): {ttl} segundos (~5 minutos)")
|
||||
|
||||
# 6. Verificar en Redis
|
||||
print(f"\n🔍 Verificación en Redis:")
|
||||
print(f" - Clave: {cache_key}")
|
||||
print(f" - Base de datos: 1")
|
||||
print(f" - Comando para ver: valkey-cli -n 1 GET ':1:{cache_key}'")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ TEST COMPLETADO EXITOSAMENTE")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error durante el test: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_product_cache()
|
||||
+8
-3
@@ -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')
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
IMAGE_TYPE = "image/*"
|
||||
EMAIL_FORMNAME = "Correo Electrónico"
|
||||
INCORRECT_PASSWORDS = "Las contraseñas no coinciden"
|
||||
+58
-8
@@ -1,6 +1,20 @@
|
||||
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:
|
||||
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):
|
||||
@@ -61,10 +75,11 @@ class ProductForm(forms.Form):
|
||||
primary_image = forms.ImageField(
|
||||
label="Imagen Principal",
|
||||
required = False,
|
||||
validators=[validate_image_file],
|
||||
widget = forms.ClearableFileInput(
|
||||
attrs = {
|
||||
'class': 'form-control',
|
||||
'accept': 'image/*'
|
||||
'accept': IMAGE_TYPE
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -108,6 +123,7 @@ class ProductEditForm(forms.Form):
|
||||
primary_image = forms.ImageField(
|
||||
label="Imagen Principal (opcional)",
|
||||
required=False,
|
||||
validators=[validate_image_file],
|
||||
widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'})
|
||||
)
|
||||
|
||||
@@ -116,10 +132,11 @@ class SecondaryImageForm(forms.Form):
|
||||
image = forms.ImageField(
|
||||
label="Seleccionar Imagen",
|
||||
required = True,
|
||||
validators=[validate_image_file],
|
||||
widget = forms.ClearableFileInput(
|
||||
attrs = {
|
||||
'class': 'form-control',
|
||||
'accept': 'image/*'
|
||||
'accept': IMAGE_TYPE
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -178,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(
|
||||
@@ -190,7 +207,9 @@ class UserRegisterForm(forms.Form):
|
||||
password = forms.CharField(
|
||||
label = "Contraseña",
|
||||
max_length = 255,
|
||||
min_length = 8,
|
||||
required = True,
|
||||
validators=[MinLengthValidator(8)],
|
||||
widget = forms.PasswordInput(
|
||||
attrs = {
|
||||
'class': 'form-control'
|
||||
@@ -200,7 +219,9 @@ class UserRegisterForm(forms.Form):
|
||||
password_confirm = forms.CharField(
|
||||
label = "Verificar Contraseña",
|
||||
max_length = 255,
|
||||
min_length = 8,
|
||||
required = True,
|
||||
validators=[MinLengthValidator(8)],
|
||||
widget = forms.PasswordInput(
|
||||
attrs = {
|
||||
'class': 'form-control'
|
||||
@@ -218,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):
|
||||
@@ -235,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'})
|
||||
@@ -267,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.")
|
||||
|
||||
@@ -325,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'})
|
||||
@@ -351,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),
|
||||
),
|
||||
]
|
||||
+89
-8
@@ -3,9 +3,38 @@ from __future__ import annotations
|
||||
import unicodedata
|
||||
from django.db import models
|
||||
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:
|
||||
@@ -45,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,
|
||||
@@ -119,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"
|
||||
@@ -140,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)
|
||||
@@ -154,21 +204,33 @@ class StockReservation(models.Model):
|
||||
class StockReservationItem(models.Model):
|
||||
reservation = models.ForeignKey(StockReservation, on_delete=models.CASCADE, related_name="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:
|
||||
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):
|
||||
return f"{self.quantity}x {self.product.name} (reserva {self.reservation_id})"
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
@@ -190,7 +252,7 @@ class Cart(models.Model):
|
||||
class CartItem(models.Model):
|
||||
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name='items')
|
||||
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)
|
||||
|
||||
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')
|
||||
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)
|
||||
@@ -265,7 +327,7 @@ class OrderItem(models.Model):
|
||||
product = models.ForeignKey(Product, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
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')
|
||||
quantity = models.PositiveIntegerField(default=1)
|
||||
quantity = models.PositiveIntegerField(default=1, validators=[MaxValueValidator(MAX_QUANTITY)])
|
||||
unit_price = models.FloatField(default=0)
|
||||
total_price = models.FloatField(default=0)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING)
|
||||
@@ -323,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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}¤cy=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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
+384
-245
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user