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
|
db.sqlite3
|
||||||
static
|
static
|
||||||
media
|
media
|
||||||
|
docs
|
||||||
|
logs
|
||||||
|
staticfiles
|
||||||
|
.gitignore
|
||||||
|
AGENTS.md
|
||||||
|
Dockerfile
|
||||||
|
Makefile
|
||||||
|
nginx.conf
|
||||||
|
Procfile
|
||||||
|
uv.lock
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "uv"
|
- package-ecosystem: "pip"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "daily"
|
||||||
allow:
|
allow:
|
||||||
- dependency-type: "direct"
|
- dependency-type: "direct"
|
||||||
open-pull-requests-limit: 10
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout del código
|
- name: Checkout del código
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- name: Configurar Python
|
- name: Configurar Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.14'
|
python-version: '3.14'
|
||||||
- name: Configurar uv
|
- name: Configurar uv
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6
|
||||||
- name: Instalar dependencias
|
- name: Instalar dependencias
|
||||||
run: |
|
run: |
|
||||||
uv sync --no-dev --no-install-project
|
uv sync --no-dev --no-install-project
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
DJANGO_SETTINGS_MODULE: proyecto.settings
|
DJANGO_SETTINGS_MODULE: proyecto.settings
|
||||||
run: |
|
run: |
|
||||||
uv run python manage.py test
|
SECRET_KEY=testkeynotuseinproducto uv run python manage.py test
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -38,13 +38,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout del código
|
- name: Checkout del código
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
|
||||||
- name: Configurar Docker Buildx
|
- name: Configurar Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||||
|
|
||||||
- name: Build (sin push)
|
- name: Build (sin push)
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
|
|||||||
@@ -9,15 +9,17 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout del código
|
- name: Checkout del código
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- name: Configurar Python
|
- name: Configurar Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.14'
|
python-version: '3.14'
|
||||||
- name: Configurar uv
|
- name: Configurar uv
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6
|
||||||
- name: Instalar dependencias
|
- name: Instalar dependencias
|
||||||
run: |
|
run: |
|
||||||
uv sync --no-dev --no-install-project
|
uv sync --no-dev --no-install-project
|
||||||
@@ -25,7 +27,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
DJANGO_SETTINGS_MODULE: proyecto.settings
|
DJANGO_SETTINGS_MODULE: proyecto.settings
|
||||||
run: |
|
run: |
|
||||||
uv run python manage.py test
|
SECRET_KEY=donotusethisinproductionitisunsafe uv run python manage.py test
|
||||||
docker:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: test
|
needs: test
|
||||||
@@ -35,13 +37,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout del código
|
- name: Checkout del código
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
|
||||||
- name: Configurar Docker Buildx
|
- name: Configurar Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||||
|
|
||||||
- name: Login en GHCR
|
- name: Login en GHCR
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -53,7 +55,7 @@ jobs:
|
|||||||
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV
|
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build y Push
|
- name: Build y Push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pyproject.toml uv.lock /app/
|
COPY pyproject.toml uv.lock /app/
|
||||||
RUN apk --no-cache update && apk --no-cache upgrade
|
|
||||||
RUN pip install --no-cache-dir uv
|
|
||||||
RUN uv sync --no-dev --no-install-project # Install only dependencies, not the local project package
|
|
||||||
|
|
||||||
COPY . /app/
|
RUN apk --no-cache update \
|
||||||
|
&& apk --no-cache upgrade \
|
||||||
|
&& apk --no-cache add \
|
||||||
|
build-base \
|
||||||
|
freetype-dev \
|
||||||
|
jpeg-dev \
|
||||||
|
zlib-dev \
|
||||||
|
&& pip install --no-cache-dir uv \
|
||||||
|
&& uv sync --no-dev --no-install-project # Install only dependencies, not the local project package
|
||||||
|
|
||||||
|
COPY ./entrypoint.sh /app/entrypoint.sh
|
||||||
RUN chmod +x /app/entrypoint.sh
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
COPY ./proyecto /app/proyecto
|
||||||
|
COPY ./tienda /app/tienda
|
||||||
|
COPY ./manage.py /app/manage.py
|
||||||
|
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
RUN mkdir -pv /fonts
|
RUN mkdir -pv /fonts
|
||||||
COPY tienda/static/fonts/ /fonts/
|
COPY tienda/static/fonts/ /fonts/
|
||||||
|
|
||||||
|
RUN addgroup -S app \
|
||||||
|
&& adduser -S app -G app \
|
||||||
|
&& chown -R app:app /app /fonts
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/sh", "/app/entrypoint.sh"]
|
ENTRYPOINT ["/bin/sh", "/app/entrypoint.sh"]
|
||||||
|
|||||||
+72
-110
@@ -11,84 +11,47 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os, sys
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import environ
|
||||||
|
|
||||||
|
|
||||||
DEV_ENV = len(sys.argv) > 1 and sys.argv[1] == 'runserver'
|
DEV_ENV = len(sys.argv) > 1 and sys.argv[1] == 'runserver'
|
||||||
|
|
||||||
RUNNING_TESTS = any(arg in {'test', 'pytest'} for arg in sys.argv) or 'PYTEST_CURRENT_TEST' in os.environ
|
RUNNING_TESTS = any(arg in {'test', 'pytest'} for arg in sys.argv) or 'PYTEST_CURRENT_TEST' in os.environ
|
||||||
|
|
||||||
|
|
||||||
def load_dotenv(dotenv_path: Path) -> None:
|
|
||||||
if not dotenv_path.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
for raw_line in dotenv_path.read_text(encoding='utf-8').splitlines():
|
|
||||||
line = raw_line.strip()
|
|
||||||
if not line or line.startswith('#') or '=' not in line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
key, value = line.split('=', 1)
|
|
||||||
key = key.strip()
|
|
||||||
value = value.strip().strip('"').strip("'")
|
|
||||||
os.environ.setdefault(key, value)
|
|
||||||
|
|
||||||
|
|
||||||
def env_bool(name: str, default: bool = False) -> bool:
|
|
||||||
value = os.getenv(name)
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
return value.strip().lower() in {'1', 'true', 'yes', 'on'}
|
|
||||||
|
|
||||||
|
|
||||||
def env_list(name: str, default: list[str] | None = None) -> list[str]:
|
|
||||||
value = os.getenv(name)
|
|
||||||
if value is None:
|
|
||||||
return default or []
|
|
||||||
return [item.strip() for item in value.split(',') if item.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def env_int(name: str, default: int) -> int:
|
|
||||||
value = os.getenv(name)
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
return int(value)
|
|
||||||
|
|
||||||
|
|
||||||
def env_str(name: str, default: str = '') -> str:
|
|
||||||
value = os.getenv(name)
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
return value.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def env_optional_str(name: str) -> str | None:
|
|
||||||
value = os.getenv(name)
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
value = value.strip()
|
|
||||||
return value or None
|
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
load_dotenv(BASE_DIR / '.env')
|
env = environ.Env(
|
||||||
|
DEBUG=(bool, True),
|
||||||
|
S3_ENABLE=(bool, False),
|
||||||
|
S3_USE_LOCAL_URLS=(bool, False),
|
||||||
|
POSTGRES_ENABLED=(bool, True),
|
||||||
|
POSTGRES_PORT=(int, 5432),
|
||||||
|
SMTP_PORT=(int, 587),
|
||||||
|
AWS_S3_USE_SSL=(bool, True),
|
||||||
|
AWS_QUERYSTRING_AUTH=(bool, False),
|
||||||
|
)
|
||||||
|
env.read_env(BASE_DIR / '.env')
|
||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-#g((q@lvnkt(j6)2(gvtn0px)r2r(911)pv59i(6w)5e!_-^ao')
|
SECRET_KEY = env('SECRET_KEY', default='')
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = env_bool('DEBUG', True)
|
DEBUG = env.bool('DEBUG')
|
||||||
S3_ENABLE = env_bool('S3_ENABLE', False)
|
S3_ENABLE = env.bool('S3_ENABLE')
|
||||||
S3_USE_LOCAL_URLS = env_bool('S3_USE_LOCAL_URLS', False)
|
S3_USE_LOCAL_URLS = env.bool('S3_USE_LOCAL_URLS')
|
||||||
|
|
||||||
ALLOWED_HOSTS = env_list('ALLOWED_HOSTS', [
|
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[
|
||||||
'192.168.1.142',
|
|
||||||
'localhost',
|
'localhost',
|
||||||
'127.0.0.1',
|
'127.0.0.1',
|
||||||
|
'zkqpv8r3-8000.uks1.devtunnels.ms'
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
@@ -104,6 +67,7 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'django.forms',
|
'django.forms',
|
||||||
'compressor',
|
'compressor',
|
||||||
|
'ninja',
|
||||||
]
|
]
|
||||||
|
|
||||||
if S3_ENABLE:
|
if S3_ENABLE:
|
||||||
@@ -147,33 +111,25 @@ WSGI_APPLICATION = 'proyecto.wsgi.application'
|
|||||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||||
# Usa PostgreSQL por defecto (POSTGRES_ENABLED=True); si no, SQLite.
|
# Usa PostgreSQL por defecto (POSTGRES_ENABLED=True); si no, SQLite.
|
||||||
|
|
||||||
if RUNNING_TESTS:
|
if RUNNING_TESTS or not env.bool('POSTGRES_ENABLED'):
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
elif env_bool('POSTGRES_ENABLED', True):
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.postgresql',
|
|
||||||
'NAME': os.getenv('POSTGRES_DB', 'tienda'),
|
|
||||||
'USER': os.getenv('POSTGRES_USER', 'postgres'),
|
|
||||||
'PASSWORD': os.getenv('POSTGRES_PASSWORD', ''),
|
|
||||||
'HOST': os.getenv('POSTGRES_HOST', '127.0.0.1'),
|
|
||||||
'PORT': env_int('POSTGRES_PORT', 5432),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'ENGINE': 'django.db.backends.postgresql',
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
'NAME': env('POSTGRES_DB', default='tienda'),
|
||||||
|
'USER': env('POSTGRES_USER', default='postgres'),
|
||||||
|
'PASSWORD': env('POSTGRES_PASSWORD', default=''),
|
||||||
|
'HOST': env('POSTGRES_HOST', default='127.0.0.1'),
|
||||||
|
'PORT': env.int('POSTGRES_PORT'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
@@ -208,10 +164,10 @@ USE_TZ = True
|
|||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = 'static/'
|
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
COMPRESS_ROOT = STATIC_ROOT
|
COMPRESS_ROOT = STATIC_ROOT
|
||||||
COMPRESS_URL = STATIC_URL
|
|
||||||
STATICFILES_DIRS = [
|
STATICFILES_DIRS = [
|
||||||
BASE_DIR / 'tienda' / 'static',
|
BASE_DIR / 'tienda' / 'static',
|
||||||
]
|
]
|
||||||
@@ -230,15 +186,15 @@ STORAGES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if S3_ENABLE:
|
if S3_ENABLE:
|
||||||
AWS_STORAGE_BUCKET_NAME = env_str('AWS_STORAGE_BUCKET_NAME') or None
|
AWS_STORAGE_BUCKET_NAME = env('AWS_STORAGE_BUCKET_NAME', default='') or None
|
||||||
AWS_ACCESS_KEY_ID = env_optional_str('AWS_ACCESS_KEY_ID')
|
AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID', default=None)
|
||||||
AWS_SECRET_ACCESS_KEY = env_optional_str('AWS_SECRET_ACCESS_KEY')
|
AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY', default=None)
|
||||||
AWS_S3_REGION_NAME = env_optional_str('AWS_S3_REGION_NAME')
|
AWS_S3_REGION_NAME = env('AWS_S3_REGION_NAME', default=None)
|
||||||
AWS_S3_ENDPOINT_URL = env_optional_str('AWS_S3_ENDPOINT_URL')
|
AWS_S3_ENDPOINT_URL = env('AWS_S3_ENDPOINT_URL', default=None)
|
||||||
AWS_S3_CUSTOM_DOMAIN = env_optional_str('AWS_S3_CUSTOM_DOMAIN')
|
AWS_S3_CUSTOM_DOMAIN = env('AWS_S3_CUSTOM_DOMAIN', default=None)
|
||||||
AWS_S3_USE_SSL = env_bool('AWS_S3_USE_SSL', True)
|
AWS_S3_USE_SSL = env.bool('AWS_S3_USE_SSL')
|
||||||
AWS_QUERYSTRING_AUTH = env_bool('AWS_QUERYSTRING_AUTH', False)
|
AWS_QUERYSTRING_AUTH = env.bool('AWS_QUERYSTRING_AUTH')
|
||||||
AWS_DEFAULT_ACL = env_str('AWS_DEFAULT_ACL', 'public-read') or None
|
AWS_DEFAULT_ACL = env('AWS_DEFAULT_ACL', default='public-read') or None
|
||||||
AWS_S3_OBJECT_PARAMETERS = {}
|
AWS_S3_OBJECT_PARAMETERS = {}
|
||||||
|
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
@@ -250,6 +206,14 @@ if S3_ENABLE:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if S3_ENABLE and AWS_S3_CUSTOM_DOMAIN:
|
||||||
|
STATIC_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/static/"
|
||||||
|
MEDIA_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/media/"
|
||||||
|
else:
|
||||||
|
STATIC_URL = env("STATIC_URL", default="static/")
|
||||||
|
MEDIA_URL = env("MEDIA_URL", default="media/")
|
||||||
|
|
||||||
|
COMPRESS_URL = STATIC_URL
|
||||||
STATICFILES_FINDERS = [
|
STATICFILES_FINDERS = [
|
||||||
'django.contrib.staticfiles.finders.FileSystemFinder',
|
'django.contrib.staticfiles.finders.FileSystemFinder',
|
||||||
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
||||||
@@ -258,15 +222,13 @@ STATICFILES_FINDERS = [
|
|||||||
|
|
||||||
COMPRESS_PRECOMPILERS = ()
|
COMPRESS_PRECOMPILERS = ()
|
||||||
|
|
||||||
# Media files (User uploads)
|
MEDIA_ROOT = Path(env('MEDIA_ROOT', default='/app/media'))
|
||||||
MEDIA_URL = 'media/'
|
|
||||||
MEDIA_ROOT = Path(os.getenv('MEDIA_ROOT', '/app/media'))
|
|
||||||
|
|
||||||
# Redis Configuration
|
# Redis Configuration
|
||||||
CACHES = {
|
CACHES = {
|
||||||
'default': {
|
'default': {
|
||||||
'BACKEND': 'django_redis.cache.RedisCache',
|
'BACKEND': 'django_redis.cache.RedisCache',
|
||||||
'LOCATION': os.getenv('REDIS_URL', 'redis://127.0.0.1:6379/1'),
|
'LOCATION': env('REDIS_URL', default='redis://127.0.0.1:6379/1'),
|
||||||
'OPTIONS': {
|
'OPTIONS': {
|
||||||
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
||||||
}
|
}
|
||||||
@@ -291,30 +253,30 @@ MESSAGE_TAGS = {
|
|||||||
# Login URL
|
# Login URL
|
||||||
LOGIN_URL = '/tienda/login/'
|
LOGIN_URL = '/tienda/login/'
|
||||||
|
|
||||||
STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY', '')
|
STRIPE_PUBLISHABLE_KEY = env('STRIPE_PUBLISHABLE_KEY', default='')
|
||||||
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '')
|
STRIPE_SECRET_KEY = env('STRIPE_SECRET_KEY', default='')
|
||||||
|
|
||||||
# PayPal Configuration (Sandbox)
|
# PayPal Configuration (Sandbox)
|
||||||
# Para obtener credenciales: https://sandbox.paypal.com/
|
# Para obtener credenciales: https://sandbox.paypal.com/
|
||||||
PAYPAL_CLIENT_ID = os.getenv('PAYPAL_CLIENT_ID', '') # Reemplazar con tu Client ID de PayPal Sandbox
|
PAYPAL_CLIENT_ID = env('PAYPAL_CLIENT_ID', default='') # Reemplazar con tu Client ID de PayPal Sandbox
|
||||||
PAYPAL_CLIENT_SECRET = os.getenv('PAYPAL_CLIENT_SECRET', '') # Reemplazar con tu Client Secret de PayPal Sandbox
|
PAYPAL_CLIENT_SECRET = env('PAYPAL_CLIENT_SECRET', default='') # Reemplazar con tu Client Secret de PayPal Sandbox
|
||||||
PAYPAL_MODE = os.getenv('PAYPAL_MODE', 'sandbox') # Cambiar a 'live' en producción
|
PAYPAL_MODE = env('PAYPAL_MODE', default='sandbox') # Cambiar a 'live' en producción
|
||||||
|
|
||||||
|
|
||||||
SMTP_ENDPOINT = os.getenv('SMTP_ENDPOINT', 'smtp.email.eu-paris-1.oci.oraclecloud.com')
|
SMTP_ENDPOINT = env('SMTP_ENDPOINT', default='smtp.email.eu-paris-1.oci.oraclecloud.com')
|
||||||
SMTP_PORT = env_int('SMTP_PORT', 587)
|
SMTP_PORT = env.int('SMTP_PORT')
|
||||||
SECURITY = os.getenv('SECURITY', 'tls')
|
SECURITY = env('SECURITY', default='tls')
|
||||||
SMTP_USERNAME = os.getenv('SMTP_USERNAME', None)
|
SMTP_USERNAME = env('SMTP_USERNAME', default=None)
|
||||||
SMTP_PASSWORD = os.getenv('SMTP_PASSWORD', None)
|
SMTP_PASSWORD = env('SMTP_PASSWORD', default=None)
|
||||||
SMTP_EMAIL = os.getenv("SMTP_EMAIL", None)
|
SMTP_EMAIL = env('SMTP_EMAIL', default=None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
AUTH_USER_MODEL = 'tienda.User'
|
AUTH_USER_MODEL = 'tienda.User'
|
||||||
|
|
||||||
|
|
||||||
DOMAIN = os.getenv("DOMAIN", "localhost")
|
DOMAIN = env('DOMAIN', default='localhost')
|
||||||
PROTOCOL = os.getenv("PROTOCOL", "http")
|
PROTOCOL = env('PROTOCOL', default='http')
|
||||||
|
|
||||||
default_csrf_trusted_origins = []
|
default_csrf_trusted_origins = []
|
||||||
if DOMAIN:
|
if DOMAIN:
|
||||||
@@ -324,16 +286,16 @@ for host in ALLOWED_HOSTS:
|
|||||||
if host and host != '*':
|
if host and host != '*':
|
||||||
default_csrf_trusted_origins.append(f"{PROTOCOL}://{host}")
|
default_csrf_trusted_origins.append(f"{PROTOCOL}://{host}")
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = env_list(
|
CSRF_TRUSTED_ORIGINS = env.list(
|
||||||
'CSRF_TRUSTED_ORIGINS',
|
'CSRF_TRUSTED_ORIGINS',
|
||||||
list(dict.fromkeys(default_csrf_trusted_origins)),
|
default=list(dict.fromkeys(default_csrf_trusted_origins)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
|
LOG_LEVEL = env('LOG_LEVEL', default='INFO').upper()
|
||||||
LOG_DIR = Path(os.getenv('LOG_DIR', BASE_DIR / 'logs'))
|
LOG_DIR = Path(env('LOG_DIR', default=str(BASE_DIR / 'logs')))
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
LOG_FILE = LOG_DIR / os.getenv('LOG_FILE', 'app.log')
|
LOG_FILE = LOG_DIR / env('LOG_FILE', default='app.log')
|
||||||
|
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
@@ -407,13 +369,13 @@ EMAIL_HOST_USER = SMTP_USERNAME
|
|||||||
EMAIL_HOST_PASSWORD = SMTP_PASSWORD
|
EMAIL_HOST_PASSWORD = SMTP_PASSWORD
|
||||||
|
|
||||||
# El correo que se usará como remitente por defecto
|
# El correo que se usará como remitente por defecto
|
||||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL") or SMTP_EMAIL or "no-reply@localhost"
|
DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='') or SMTP_EMAIL or 'no-reply@localhost'
|
||||||
|
|
||||||
# URL de Redis (asumiendo que corre en el puerto default 6379)
|
# URL de Redis (asumiendo que corre en el puerto default 6379)
|
||||||
CELERY_BROKER_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
CELERY_BROKER_URL = env('REDIS_URL', default='redis://localhost:6379/0')
|
||||||
|
|
||||||
# Opcional: para guardar el resultado de las tareas
|
# Opcional: para guardar el resultado de las tareas
|
||||||
CELERY_RESULT_BACKEND = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
CELERY_RESULT_BACKEND = env('REDIS_URL', default='redis://localhost:6379/0')
|
||||||
|
|
||||||
# Configuraciones adicionales recomendadas
|
# Configuraciones adicionales recomendadas
|
||||||
CELERY_ACCEPT_CONTENT = ['json']
|
CELERY_ACCEPT_CONTENT = ['json']
|
||||||
|
|||||||
+7
-1
@@ -19,11 +19,17 @@ from django.conf.urls.static import static
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from tienda import views as tienda_views
|
from tienda import views as tienda_views
|
||||||
|
from tienda.api import router as api_router
|
||||||
|
from ninja import NinjaAPI
|
||||||
|
|
||||||
|
api = NinjaAPI(title="Comercialmeria API", version="1.0.0")
|
||||||
|
api.add_router("/", api_router)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', tienda_views.home, name='home'),
|
path('', tienda_views.home, name='home'),
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('tienda/', include('tienda.urls'))
|
path('tienda/', include('tienda.urls')),
|
||||||
|
path('api/', api.urls),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG and (
|
if settings.DEBUG and (
|
||||||
|
|||||||
+5
-2
@@ -1,3 +1,4 @@
|
|||||||
|
# UV Config file
|
||||||
[project]
|
[project]
|
||||||
name = "proyecto-final"
|
name = "proyecto-final"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -6,6 +7,8 @@ dependencies = [
|
|||||||
"celery==5.6.3",
|
"celery==5.6.3",
|
||||||
"Django==6.0.5",
|
"Django==6.0.5",
|
||||||
"django-compressor==4.6.0",
|
"django-compressor==4.6.0",
|
||||||
|
"django-environ>=0.13.0",
|
||||||
|
"django-ninja>=1.6.2",
|
||||||
"django-redis==6.0.0",
|
"django-redis==6.0.0",
|
||||||
# S3 backend requerido por tienda/storage_backends.py cuando S3_ENABLE=True.
|
# S3 backend requerido por tienda/storage_backends.py cuando S3_ENABLE=True.
|
||||||
"django-storages[s3]==1.14.6",
|
"django-storages[s3]==1.14.6",
|
||||||
@@ -14,8 +17,8 @@ dependencies = [
|
|||||||
"paypalrestsdk==1.13.3",
|
"paypalrestsdk==1.13.3",
|
||||||
"pillow==12.2.0",
|
"pillow==12.2.0",
|
||||||
"psycopg2-binary==2.9.12",
|
"psycopg2-binary==2.9.12",
|
||||||
"requests==2.33.1",
|
"requests==2.34.2",
|
||||||
"stripe==15.1.0",
|
"stripe==15.2.0",
|
||||||
"whitenoise==6.12.0",
|
"whitenoise==6.12.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
-101
@@ -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 django.contrib import admin
|
||||||
from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, StockReservation, StockReservationItem, User, VerificationCode, SavedPaymentMethod
|
from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, StockReservation, StockReservationItem, User, VerificationCode, SavedPaymentMethod, Review
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
@@ -20,7 +20,6 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
def banear_usuario_action(self, request, queryset):
|
def banear_usuario_action(self, request, queryset):
|
||||||
usuarios_baneados = 0
|
usuarios_baneados = 0
|
||||||
for user in queryset:
|
for user in queryset:
|
||||||
user: User = user
|
|
||||||
# Desactiva usuario
|
# Desactiva usuario
|
||||||
if user.registration_status == User.RegisterStatus.BANNED:
|
if user.registration_status == User.RegisterStatus.BANNED:
|
||||||
continue
|
continue
|
||||||
@@ -43,7 +42,6 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
def desbanear_usuario_action(self, request, queryset):
|
def desbanear_usuario_action(self, request, queryset):
|
||||||
user_desbaneados = 0
|
user_desbaneados = 0
|
||||||
for user in queryset:
|
for user in queryset:
|
||||||
user: User = user
|
|
||||||
if user.registration_status != User.RegisterStatus.BANNED:
|
if user.registration_status != User.RegisterStatus.BANNED:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -151,3 +149,10 @@ class SavedPaymentMethodAdmin(admin.ModelAdmin):
|
|||||||
list_display = ('id', 'user', 'method_type', 'label', 'is_default', 'created_at')
|
list_display = ('id', 'user', 'method_type', 'label', 'is_default', 'created_at')
|
||||||
list_filter = ('method_type', 'is_default', 'created_at')
|
list_filter = ('method_type', 'is_default', 'created_at')
|
||||||
search_fields = ('user__username', 'user__email', 'label', 'paypal_email')
|
search_fields = ('user__username', 'user__email', 'label', 'paypal_email')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Review)
|
||||||
|
class ReviewAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'product', 'user', 'rating', 'title', 'created_at')
|
||||||
|
list_filter = ('rating', 'created_at')
|
||||||
|
search_fields = ('user__username', 'product__name', 'title', 'content')
|
||||||
@@ -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 import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import FileExtensionValidator, MinLengthValidator, MaxLengthValidator
|
||||||
from .models import Category
|
from .models import Category
|
||||||
|
from .constants import IMAGE_TYPE, EMAIL_FORMNAME, INCORRECT_PASSWORDS
|
||||||
|
ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']
|
||||||
|
ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def validate_image_file(value):
|
||||||
|
ext = value.name.split('.')[-1].lower()
|
||||||
|
if ext not in ALLOWED_IMAGE_EXTENSIONS:
|
||||||
|
raise ValidationError(f'Tipo de archivo no permitido. Allowed: {", ".join(ALLOWED_IMAGE_EXTENSIONS)}')
|
||||||
|
if hasattr(value, 'content_type') and value.content_type not in ALLOWED_MIME_TYPES:
|
||||||
|
raise ValidationError(f'Tipo MIME no permitido. Allowed: {", ".join(ALLOWED_MIME_TYPES)}')
|
||||||
|
|
||||||
|
|
||||||
class ProductForm(forms.Form):
|
class ProductForm(forms.Form):
|
||||||
@@ -61,10 +75,11 @@ class ProductForm(forms.Form):
|
|||||||
primary_image = forms.ImageField(
|
primary_image = forms.ImageField(
|
||||||
label="Imagen Principal",
|
label="Imagen Principal",
|
||||||
required = False,
|
required = False,
|
||||||
|
validators=[validate_image_file],
|
||||||
widget = forms.ClearableFileInput(
|
widget = forms.ClearableFileInput(
|
||||||
attrs = {
|
attrs = {
|
||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'accept': 'image/*'
|
'accept': IMAGE_TYPE
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -108,6 +123,7 @@ class ProductEditForm(forms.Form):
|
|||||||
primary_image = forms.ImageField(
|
primary_image = forms.ImageField(
|
||||||
label="Imagen Principal (opcional)",
|
label="Imagen Principal (opcional)",
|
||||||
required=False,
|
required=False,
|
||||||
|
validators=[validate_image_file],
|
||||||
widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'})
|
widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,10 +132,11 @@ class SecondaryImageForm(forms.Form):
|
|||||||
image = forms.ImageField(
|
image = forms.ImageField(
|
||||||
label="Seleccionar Imagen",
|
label="Seleccionar Imagen",
|
||||||
required = True,
|
required = True,
|
||||||
|
validators=[validate_image_file],
|
||||||
widget = forms.ClearableFileInput(
|
widget = forms.ClearableFileInput(
|
||||||
attrs = {
|
attrs = {
|
||||||
'class': 'form-control',
|
'class': 'form-control',
|
||||||
'accept': 'image/*'
|
'accept': IMAGE_TYPE
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -178,7 +195,7 @@ class UserRegisterForm(forms.Form):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
email = forms.EmailField(
|
email = forms.EmailField(
|
||||||
label = "Correo Electrónico",
|
label = EMAIL_FORMNAME,
|
||||||
max_length = 255,
|
max_length = 255,
|
||||||
required = True,
|
required = True,
|
||||||
widget = forms.TextInput(
|
widget = forms.TextInput(
|
||||||
@@ -190,7 +207,9 @@ class UserRegisterForm(forms.Form):
|
|||||||
password = forms.CharField(
|
password = forms.CharField(
|
||||||
label = "Contraseña",
|
label = "Contraseña",
|
||||||
max_length = 255,
|
max_length = 255,
|
||||||
|
min_length = 8,
|
||||||
required = True,
|
required = True,
|
||||||
|
validators=[MinLengthValidator(8)],
|
||||||
widget = forms.PasswordInput(
|
widget = forms.PasswordInput(
|
||||||
attrs = {
|
attrs = {
|
||||||
'class': 'form-control'
|
'class': 'form-control'
|
||||||
@@ -200,7 +219,9 @@ class UserRegisterForm(forms.Form):
|
|||||||
password_confirm = forms.CharField(
|
password_confirm = forms.CharField(
|
||||||
label = "Verificar Contraseña",
|
label = "Verificar Contraseña",
|
||||||
max_length = 255,
|
max_length = 255,
|
||||||
|
min_length = 8,
|
||||||
required = True,
|
required = True,
|
||||||
|
validators=[MinLengthValidator(8)],
|
||||||
widget = forms.PasswordInput(
|
widget = forms.PasswordInput(
|
||||||
attrs = {
|
attrs = {
|
||||||
'class': 'form-control'
|
'class': 'form-control'
|
||||||
@@ -218,7 +239,7 @@ class UserRegisterForm(forms.Form):
|
|||||||
password = cleaned_data.get("password")
|
password = cleaned_data.get("password")
|
||||||
password_confirm = cleaned_data.get("password_confirm")
|
password_confirm = cleaned_data.get("password_confirm")
|
||||||
if password and password_confirm and password != password_confirm:
|
if password and password_confirm and password != password_confirm:
|
||||||
raise ValidationError("Las contraseñas no coinciden.")
|
raise ValidationError(INCORRECT_PASSWORDS)
|
||||||
|
|
||||||
|
|
||||||
class EditProfileForm(forms.Form):
|
class EditProfileForm(forms.Form):
|
||||||
@@ -235,7 +256,7 @@ class EditProfileForm(forms.Form):
|
|||||||
widget=forms.TextInput(attrs={'class': 'form-control'})
|
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||||
)
|
)
|
||||||
email = forms.EmailField(
|
email = forms.EmailField(
|
||||||
label="Correo Electrónico",
|
label=EMAIL_FORMNAME,
|
||||||
max_length=254,
|
max_length=254,
|
||||||
required=True,
|
required=True,
|
||||||
widget=forms.EmailInput(attrs={'class': 'form-control'})
|
widget=forms.EmailInput(attrs={'class': 'form-control'})
|
||||||
@@ -267,7 +288,7 @@ class ChangePasswordForm(forms.Form):
|
|||||||
new_password = cleaned_data.get("new_password")
|
new_password = cleaned_data.get("new_password")
|
||||||
confirm_password = cleaned_data.get("confirm_password")
|
confirm_password = cleaned_data.get("confirm_password")
|
||||||
if new_password and confirm_password and new_password != confirm_password:
|
if new_password and confirm_password and new_password != confirm_password:
|
||||||
raise ValidationError("Las contraseñas no coinciden.")
|
raise ValidationError(INCORRECT_PASSWORDS)
|
||||||
if new_password and len(new_password) < 8:
|
if new_password and len(new_password) < 8:
|
||||||
raise ValidationError("La contraseña debe tener al menos 8 caracteres.")
|
raise ValidationError("La contraseña debe tener al menos 8 caracteres.")
|
||||||
|
|
||||||
@@ -325,7 +346,7 @@ class ShippingAddressForm(forms.Form):
|
|||||||
|
|
||||||
class ResetPasswordForm(forms.Form):
|
class ResetPasswordForm(forms.Form):
|
||||||
email = forms.EmailField(
|
email = forms.EmailField(
|
||||||
label="Correo Electrónico",
|
label=EMAIL_FORMNAME,
|
||||||
max_length=254,
|
max_length=254,
|
||||||
required=True,
|
required=True,
|
||||||
widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'tu@email.com'})
|
widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'tu@email.com'})
|
||||||
@@ -351,4 +372,33 @@ class ResetPasswordPhase2Form(forms.Form):
|
|||||||
password = cleaned_data.get("password")
|
password = cleaned_data.get("password")
|
||||||
verify_password = cleaned_data.get("verify_password")
|
verify_password = cleaned_data.get("verify_password")
|
||||||
if password and verify_password and password != verify_password:
|
if password and verify_password and password != verify_password:
|
||||||
raise ValidationError("Las contraseñas no coinciden.")
|
raise ValidationError(INCORRECT_PASSWORDS)
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewForm(forms.Form):
|
||||||
|
rating = forms.IntegerField(
|
||||||
|
label="Puntuación",
|
||||||
|
required=True,
|
||||||
|
min_value=1,
|
||||||
|
max_value=5,
|
||||||
|
widget=forms.HiddenInput()
|
||||||
|
)
|
||||||
|
title = forms.CharField(
|
||||||
|
label="Título",
|
||||||
|
max_length=200,
|
||||||
|
required=True,
|
||||||
|
widget=forms.TextInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'placeholder': 'Título de tu valoración'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
content = forms.CharField(
|
||||||
|
label="Descripción",
|
||||||
|
max_length=2000,
|
||||||
|
required=True,
|
||||||
|
widget=forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 5,
|
||||||
|
'placeholder': 'Comparte tu experiencia con este producto...'
|
||||||
|
})
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-08 11:32
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('tienda', '0008_alter_product_briefdesc_alter_product_description'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='cartitem',
|
||||||
|
name='quantity',
|
||||||
|
field=models.PositiveIntegerField(default=1, validators=[django.core.validators.MaxValueValidator(9999)]),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='orderitem',
|
||||||
|
name='quantity',
|
||||||
|
field=models.PositiveIntegerField(default=1, validators=[django.core.validators.MaxValueValidator(9999)]),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='stockreservationitem',
|
||||||
|
name='quantity',
|
||||||
|
field=models.PositiveIntegerField(default=1, validators=[django.core.validators.MaxValueValidator(9999)]),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Review',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('rating', models.PositiveIntegerField(validators=[django.core.validators.MaxValueValidator(5)])),
|
||||||
|
('title', models.CharField(default='', max_length=200)),
|
||||||
|
('content', models.TextField(default='', max_length=2000)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('images', models.ManyToManyField(blank=True, related_name='product_reviews', to='tienda.image')),
|
||||||
|
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='tienda.product')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_reviews', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'unique_together': {('product', 'user')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-26 08:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('tienda', '0009_alter_cartitem_quantity_alter_orderitem_quantity_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='cart',
|
||||||
|
name='session_key',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=40),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='order',
|
||||||
|
name='session_key',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=40),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='stockreservation',
|
||||||
|
name='session_key',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=40),
|
||||||
|
),
|
||||||
|
]
|
||||||
+89
-8
@@ -3,9 +3,38 @@ from __future__ import annotations
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.auth.models import User, AbstractUser
|
from django.contrib.auth.models import User, AbstractUser
|
||||||
|
from django.core.validators import MaxValueValidator
|
||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX, TRANSACTION_CODE_LENGTH, TRANSACTION_CODE_ALPHABET
|
from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX, TRANSACTION_CODE_LENGTH, TRANSACTION_CODE_ALPHABET
|
||||||
import random, string
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
|
MAX_QUANTITY = 9999
|
||||||
|
|
||||||
|
|
||||||
|
class BlankToNoneCharField(models.CharField):
|
||||||
|
"""Treat empty strings as None in Python, but store as empty strings in DB."""
|
||||||
|
|
||||||
|
def to_python(self, value):
|
||||||
|
value = super().to_python(value)
|
||||||
|
if value == "":
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
def from_db_value(self, value, expression, connection):
|
||||||
|
if value == "":
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
def get_prep_value(self, value):
|
||||||
|
if value is None or value == "":
|
||||||
|
return ""
|
||||||
|
return super().get_prep_value(value)
|
||||||
|
|
||||||
|
def deconstruct(self):
|
||||||
|
name, path, args, kwargs = super().deconstruct()
|
||||||
|
path = "django.db.models.CharField"
|
||||||
|
return name, path, args, kwargs
|
||||||
|
|
||||||
|
|
||||||
def generate_transaction_code() -> str:
|
def generate_transaction_code() -> str:
|
||||||
@@ -45,9 +74,10 @@ class VerificationCode(models.Model):
|
|||||||
default = VerificationModes.VERIFY_ACCOUNT
|
default = VerificationModes.VERIFY_ACCOUNT
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def generate(user: User, code_mode: str) -> VerificationCode:
|
def generate(user: User, code_mode: str) -> VerificationCode:
|
||||||
while True:
|
while True:
|
||||||
code = "".join(random.choices(string.ascii_letters+string.digits, k=64))
|
code = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
|
||||||
if not VerificationCode.objects.filter(code=code).exists():
|
if not VerificationCode.objects.filter(code=code).exists():
|
||||||
return VerificationCode.objects.create(
|
return VerificationCode.objects.create(
|
||||||
code = code,
|
code = code,
|
||||||
@@ -119,6 +149,26 @@ class Product(models.Model):
|
|||||||
"creator": self.creator.to_dict() if self.creator else None
|
"creator": self.creator.to_dict() if self.creator else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def has_user_purchased(self, user):
|
||||||
|
"""Verifica si el usuario ha comprado este producto al menos una vez"""
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
return False
|
||||||
|
return OrderItem.objects.filter(
|
||||||
|
order__buyer=user,
|
||||||
|
product=self
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
def get_average_rating(self):
|
||||||
|
"""Retorna la nota media de las valoraciones"""
|
||||||
|
reviews = self.reviews.all()
|
||||||
|
if not reviews.exists():
|
||||||
|
return 0
|
||||||
|
return round(reviews.aggregate(models.Avg('rating'))['rating__avg'], 1)
|
||||||
|
|
||||||
|
def get_reviews_count(self):
|
||||||
|
"""Retorna el número total de valoraciones"""
|
||||||
|
return self.reviews.count()
|
||||||
|
|
||||||
|
|
||||||
class StockReservation(models.Model):
|
class StockReservation(models.Model):
|
||||||
STATUS_ACTIVE = "active"
|
STATUS_ACTIVE = "active"
|
||||||
@@ -140,7 +190,7 @@ class StockReservation(models.Model):
|
|||||||
]
|
]
|
||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, related_name="stock_reservations")
|
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, related_name="stock_reservations")
|
||||||
session_key = models.CharField(max_length=40, null=True, blank=True)
|
session_key = models.CharField(max_length=40, default="", blank=True)
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_ACTIVE)
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_ACTIVE)
|
||||||
payment_method = models.CharField(max_length=20, choices=PAYMENT_CHOICES)
|
payment_method = models.CharField(max_length=20, choices=PAYMENT_CHOICES)
|
||||||
expires_at = models.DateTimeField(db_index=True)
|
expires_at = models.DateTimeField(db_index=True)
|
||||||
@@ -154,21 +204,33 @@ class StockReservation(models.Model):
|
|||||||
class StockReservationItem(models.Model):
|
class StockReservationItem(models.Model):
|
||||||
reservation = models.ForeignKey(StockReservation, on_delete=models.CASCADE, related_name="items")
|
reservation = models.ForeignKey(StockReservation, on_delete=models.CASCADE, related_name="items")
|
||||||
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="stock_reservation_items")
|
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="stock_reservation_items")
|
||||||
quantity = models.PositiveIntegerField(default=1)
|
quantity = models.PositiveIntegerField(default=1, validators=[MaxValueValidator(MAX_QUANTITY)])
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ("reservation", "product")
|
unique_together = ("reservation", "product")
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
if self.quantity is not None and self.quantity > MAX_QUANTITY:
|
||||||
|
raise ValidationError(f'La cantidad no puede exceder {MAX_QUANTITY} unidades.')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.quantity}x {self.product.name} (reserva {self.reservation_id})"
|
return f"{self.quantity}x {self.product.name} (reserva {self.reservation_id})"
|
||||||
|
|
||||||
|
|
||||||
class Cart(models.Model):
|
class Cart(models.Model):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
|
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
|
||||||
session_key = models.CharField(max_length=40, null=True, blank=True)
|
session_key = BlankToNoneCharField(max_length=40, default="", blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.session_key is None:
|
||||||
|
self.session_key = ""
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
if self.session_key == "":
|
||||||
|
self.session_key = None
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Cart {self.id} - {self.user or self.session_key}"
|
return f"Cart {self.id} - {self.user or self.session_key}"
|
||||||
|
|
||||||
@@ -190,7 +252,7 @@ class Cart(models.Model):
|
|||||||
class CartItem(models.Model):
|
class CartItem(models.Model):
|
||||||
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name='items')
|
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name='items')
|
||||||
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
||||||
quantity = models.PositiveIntegerField(default=1)
|
quantity = models.PositiveIntegerField(default=1, validators=[MaxValueValidator(MAX_QUANTITY)])
|
||||||
added_at = models.DateTimeField(auto_now_add=True)
|
added_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -230,7 +292,7 @@ class Order(models.Model):
|
|||||||
|
|
||||||
buyer = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='orders')
|
buyer = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='orders')
|
||||||
shipping_address = models.ForeignKey('ShippingAddress', on_delete=models.SET_NULL, null=True, blank=True, related_name='orders')
|
shipping_address = models.ForeignKey('ShippingAddress', on_delete=models.SET_NULL, null=True, blank=True, related_name='orders')
|
||||||
session_key = models.CharField(max_length=40, null=True, blank=True)
|
session_key = models.CharField(max_length=40, default="", blank=True)
|
||||||
total = models.FloatField(default=0)
|
total = models.FloatField(default=0)
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PAID)
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PAID)
|
||||||
payment_method = models.CharField(max_length=20, choices=PAYMENT_CHOICES, default=PAYMENT_MANUAL)
|
payment_method = models.CharField(max_length=20, choices=PAYMENT_CHOICES, default=PAYMENT_MANUAL)
|
||||||
@@ -265,7 +327,7 @@ class OrderItem(models.Model):
|
|||||||
product = models.ForeignKey(Product, on_delete=models.SET_NULL, null=True, blank=True)
|
product = models.ForeignKey(Product, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
product_name = models.CharField(max_length=200, default="")
|
product_name = models.CharField(max_length=200, default="")
|
||||||
seller = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='order_items_to_fulfill')
|
seller = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='order_items_to_fulfill')
|
||||||
quantity = models.PositiveIntegerField(default=1)
|
quantity = models.PositiveIntegerField(default=1, validators=[MaxValueValidator(MAX_QUANTITY)])
|
||||||
unit_price = models.FloatField(default=0)
|
unit_price = models.FloatField(default=0)
|
||||||
total_price = models.FloatField(default=0)
|
total_price = models.FloatField(default=0)
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING)
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING)
|
||||||
@@ -323,6 +385,25 @@ class SavedPaymentMethod(models.Model):
|
|||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class Review(models.Model):
|
||||||
|
"""Valoraciones de productos por usuarios que han realizado una compra"""
|
||||||
|
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='reviews')
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='product_reviews')
|
||||||
|
rating = models.PositiveIntegerField(validators=[MaxValueValidator(5)])
|
||||||
|
title = models.CharField(max_length=200, default="")
|
||||||
|
content = models.TextField(max_length=2000, default="")
|
||||||
|
images = models.ManyToManyField(Image, related_name='product_reviews', blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('product', 'user')
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Valoración de {self.user.username} en {self.product.name} ({self.rating}★)"
|
||||||
|
|
||||||
|
|
||||||
class ShippingAddress(models.Model):
|
class ShippingAddress(models.Model):
|
||||||
"""Direcciones de entrega de los usuarios"""
|
"""Direcciones de entrega de los usuarios"""
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='shipping_addresses')
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='shipping_addresses')
|
||||||
|
|||||||
+5
-2
@@ -17,15 +17,18 @@ class Recibo(FPDF):
|
|||||||
|
|
||||||
def generar_recibo(cliente: str, total: float, objetos: list, metodo_pago: str, transaction_code: str):
|
def generar_recibo(cliente: str, total: float, objetos: list, metodo_pago: str, transaction_code: str):
|
||||||
pdf = Recibo()
|
pdf = Recibo()
|
||||||
font_path = "/fonts/Roboto-Regular.ttf"
|
|
||||||
pdf.add_font('Roboto', '', '/fonts/Roboto-Regular.ttf')
|
pdf.add_font('Roboto', '', '/fonts/Roboto-Regular.ttf')
|
||||||
pdf.add_font('Roboto', 'B', '/fonts/Roboto-Bold.ttf')
|
pdf.add_font('Roboto', 'B', '/fonts/Roboto-Bold.ttf')
|
||||||
pdf.add_page()
|
pdf.add_page()
|
||||||
pdf.set_font('Roboto', size=12)
|
pdf.set_font('Roboto', size=12)
|
||||||
|
|
||||||
|
METODOS_MAP = {"stripe": "Stripe", "paypal": "PayPal", "manual": "Manual"}
|
||||||
|
metodo_mostrar = METODOS_MAP.get(metodo_pago, metodo_pago)
|
||||||
|
|
||||||
pdf.cell(0, 10, f"Cliente: {cliente}", ln=True)
|
pdf.cell(0, 10, f"Cliente: {cliente}", ln=True)
|
||||||
pdf.cell(0, 10, f"ID de transaccion: {transaction_code}", ln=True)
|
pdf.cell(0, 10, f"ID de transaccion: {transaction_code}", ln=True)
|
||||||
pdf.cell(0, 10, f"")
|
pdf.cell(0, 10, f"Metodo de pago: {metodo_mostrar}", ln=True)
|
||||||
|
pdf.cell(0, 10, "")
|
||||||
|
|
||||||
DATA = []
|
DATA = []
|
||||||
DATA.append(
|
DATA.append(
|
||||||
|
|||||||
@@ -318,3 +318,23 @@ p.price {
|
|||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--chat--color--primary: #513CB0;
|
||||||
|
--chat--color--primary-shade-50: #3f2a8f;
|
||||||
|
--chat--color--primary--shade-100: #361DA7;
|
||||||
|
--chat--color--secondary: #513CB0;
|
||||||
|
--chat--color-secondary-shade-50: #3f2a8f;
|
||||||
|
--chat--color--typing: #513CB0;
|
||||||
|
--chat--color-dark: #101330;
|
||||||
|
--chat--window--border-radius: 12px;
|
||||||
|
--chat--toggle--background: #513CB0;
|
||||||
|
--chat--toggle--hover--background: #3f2a8f;
|
||||||
|
--chat--toggle--active--background: #361DA7;
|
||||||
|
--chat--message--bot--background: #f0f0f0;
|
||||||
|
--chat--message--user--background: #513CB0;
|
||||||
|
--chat--header--background: #513CB0;
|
||||||
|
--chat--input--send--button--color: #513CB0;
|
||||||
|
--chat--input--send--button--color-hover: #3f2a8f;
|
||||||
|
--chat--close--button--color-hover: #FC3F44;
|
||||||
|
}
|
||||||
|
|||||||
+4
-3
@@ -4,7 +4,8 @@ from django.template.loader import render_to_string
|
|||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
from .utilities import send_email, send_hemail
|
from .utilities import send_email, send_hemail
|
||||||
from .vars import login_message, verify_message
|
from .vars import login_message, verify_message
|
||||||
import random, string
|
import secrets
|
||||||
|
import string
|
||||||
from . import pdf
|
from . import pdf
|
||||||
|
|
||||||
from .models import User, VerificationCode
|
from .models import User, VerificationCode
|
||||||
@@ -43,7 +44,7 @@ def enviar_correo_confirmacion(id: int):
|
|||||||
code = VerificationCode.objects.create(
|
code = VerificationCode.objects.create(
|
||||||
user = usuario,
|
user = usuario,
|
||||||
code_mode = VerificationCode.VerificationModes.VERIFY_ACCOUNT,
|
code_mode = VerificationCode.VerificationModes.VERIFY_ACCOUNT,
|
||||||
code = ''.join(random.choices(string.digits, k=12))
|
code = ''.join(secrets.choice(string.digits) for _ in range(12))
|
||||||
)
|
)
|
||||||
|
|
||||||
message = verify_message.format(name = usuario.get_full_name(), protocol = settings.PROTOCOL, domain = settings.DOMAIN, code = code.code)
|
message = verify_message.format(name = usuario.get_full_name(), protocol = settings.PROTOCOL, domain = settings.DOMAIN, code = code.code)
|
||||||
@@ -60,7 +61,7 @@ def enviar_correo_recuperacion(email: str):
|
|||||||
ver_code = VerificationCode.objects.create(
|
ver_code = VerificationCode.objects.create(
|
||||||
code_mode = VerificationCode.VerificationModes.RESET_PASSWORD,
|
code_mode = VerificationCode.VerificationModes.RESET_PASSWORD,
|
||||||
user = usuario,
|
user = usuario,
|
||||||
code = ''.join(random.choices(string.digits, k=12))
|
code = ''.join(secrets.choice(string.digits) for _ in range(12))
|
||||||
)
|
)
|
||||||
ver_code.save()
|
ver_code.save()
|
||||||
html_content = render_to_string(
|
html_content = render_to_string(
|
||||||
|
|||||||
@@ -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 %}
|
{% load static %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<script src="https://js.stripe.com/v3/"></script>
|
<script src="https://js.stripe.com/v3/" integrity="sha384-353f1ae25ae0929bea5f9379a594131b27e45a89d8f918dcc040c4ccbe6fd35fe6fd1d61ccc6e0c911c9b54325235904"></script>
|
||||||
<style>
|
<style>
|
||||||
#card-element {
|
#card-element {
|
||||||
border: 1px solid #ced4da;
|
border: 1px solid #ced4da;
|
||||||
@@ -44,8 +44,8 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Datos de la tarjeta</label>
|
<label id="label-card-data" class="form-label">Datos de la tarjeta <input type="hidden"></label>
|
||||||
<div id="card-element"></div>
|
<div id="card-element" aria-labelledby="label-card-data"></div>
|
||||||
<div id="card-errors" role="alert"></div>
|
<div id="card-errors" role="alert"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="description" content="Sitio web de comercio local Almeriense">
|
|
||||||
<title>Comercialmeria</title>
|
<title>Comercialmeria</title>
|
||||||
<link rel="preload" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
<meta name="description" content="Sitio web de comercio local Almeriense">
|
||||||
|
|
||||||
<noscript>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
|
||||||
</noscript>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
||||||
<link rel="preload" href="{% static 'css/custom.css' %}" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
<link rel="preload" href="{% static 'css/custom.css' %}" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
||||||
<noscript><link rel="stylesheet" href="{% static 'css/custom.css' %}"></noscript>
|
<noscript><link rel="stylesheet" href="{% static 'css/custom.css' %}"></noscript>
|
||||||
@@ -111,8 +106,8 @@
|
|||||||
<!-- Barra de búsqueda con sugerencias -->
|
<!-- Barra de búsqueda con sugerencias -->
|
||||||
<form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm">
|
<form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input class="form-control" type="search" name="q" id="searchInput" placeholder="Buscar productos..." aria-label="Buscar" autocomplete="off" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="searchSuggestions" aria-activedescendant="" aria-haspopup="listbox">
|
<input class="form-control" type="search" name="q" id="searchInput" placeholder="Buscar productos..." aria-label="Buscar" autocomplete="off" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="searchSuggestions" aria-activedescendant="searchbutton" aria-haspopup="listbox">
|
||||||
<button class="btn btn-outline-primary" type="submit" aria-label="Buscar productos">🔍 Buscar</button>
|
<button class="btn btn-outline-primary" type="submit" id="searchbutton" aria-label="Buscar productos">🔍 Buscar</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-suggestions" id="searchSuggestions" role="listbox" aria-label="Sugerencias de búsqueda"></div>
|
<div class="search-suggestions" id="searchSuggestions" role="listbox" aria-label="Sugerencias de búsqueda"></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -344,5 +339,27 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endcache %}
|
{% endcache %}
|
||||||
|
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/@n8n/chat/dist/style.css" rel="stylesheet" integrity="sha384-b7166c239e461f42296ad7248c04ef6768e9340a51aef45fad197acf8f4c16f119f36376b19516548885a9ecabdccc10" />
|
||||||
|
<script type="module">
|
||||||
|
import { createChat } from 'https://cdn.jsdelivr.net/npm/@n8n/chat/dist/chat.bundle.es.js';
|
||||||
|
|
||||||
|
createChat({
|
||||||
|
webhookUrl: 'https://n8n.elordenador.org/webhook/0e2cbe42-39d2-4e86-be62-c12542e246d4/chat',
|
||||||
|
initialMessages: [
|
||||||
|
'¡Hola! 👋',
|
||||||
|
'Soy el asistente virtual de Comercialmeria. ¿En qué puedo ayudarte?'
|
||||||
|
],
|
||||||
|
i18n: {
|
||||||
|
en: {
|
||||||
|
title: 'Chat de Soporte',
|
||||||
|
subtitle: 'Estamos aquí para ayudarte 24/7.',
|
||||||
|
footer: '',
|
||||||
|
getStarted: 'Nueva conversación',
|
||||||
|
inputPlaceholder: 'Escribe tu mensaje...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% load vat_filters %}
|
{% load vat_filters %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<script src="https://js.stripe.com/v3/"></script>
|
<script src="https://js.stripe.com/v3/" integrity="sha384-353f1ae25ae0929bea5f9379a594131b27e45a89d8f918dcc040c4ccbe6fd35fe6fd1d61ccc6e0c911c9b54325235904"></script>
|
||||||
<script src="https://www.paypal.com/sdk/js?client-id={{ paypal_client_id }}¤cy=EUR" defer></script>
|
<script src="https://www.paypal.com/sdk/js?client-id={{ paypal_client_id }}¤cy=EUR" defer></script>
|
||||||
<style>
|
<style>
|
||||||
#card-element {
|
#card-element {
|
||||||
@@ -84,11 +84,11 @@
|
|||||||
<table class="table table-striped align-middle">
|
<table class="table table-striped align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Producto</th>
|
<th scope="col">Producto</th>
|
||||||
<th class="text-end">Precio (sin IVA)</th>
|
<th scope="col" class="text-end">Precio (sin IVA)</th>
|
||||||
<th class="text-end">Cantidad</th>
|
<th scope="col" class="text-end">Cantidad</th>
|
||||||
<th class="text-end">Stock actual</th>
|
<th scope="col" class="text-end">Stock actual</th>
|
||||||
<th class="text-end">Subtotal (con IVA)</th>
|
<th scope="col" class="text-end">Subtotal (con IVA)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -104,16 +104,16 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="4" class="text-end">Subtotal:</th>
|
<th colspan="4" scope="row" class="text-end">Subtotal:</th>
|
||||||
<th class="text-end">{{ cart.get_total|format_price }}€</th>
|
<td class="text-end">{{ cart.get_total|format_price }}€</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="4" class="text-end">IVA (21%):</th>
|
<th scope="row" colspan="4" class="text-end">IVA (21%):</th>
|
||||||
<th class="text-end text-success">+{{ cart.get_vat_amount|format_price }}€</th>
|
<td class="text-end text-success">+{{ cart.get_vat_amount|format_price }}€</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style="background-color: #f8f9fa;">
|
<tr style="background-color: #f8f9fa;">
|
||||||
<th colspan="4" class="text-end" style="font-size: 1.1rem;">Total:</th>
|
<th scope="row" colspan="4" class="text-end" style="font-size: 1.1rem;">Total:</th>
|
||||||
<th class="text-end" style="font-size: 1.1rem;">{{ cart.get_total_with_vat|format_price }}€</th>
|
<td class="text-end" style="font-size: 1.1rem;">{{ cart.get_total_with_vat|format_price }}€</th>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
<h5 class="card-title mb-3">2) Selecciona tu método de pago</h5>
|
<h5 class="card-title mb-3">2) Selecciona tu método de pago</h5>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<ul class="nav nav-tabs mb-3" id="paymentTabs" role="tablist">
|
<ul class="nav nav-tabs mb-3" id="paymentTabs">
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link active" id="tab-card" data-tab="pane-card" type="button"
|
<button class="nav-link active" id="tab-card" data-tab="pane-card" type="button"
|
||||||
role="tab" aria-selected="true" aria-controls="pane-card" tabindex="0">
|
role="tab" aria-selected="true" aria-controls="pane-card" tabindex="0">
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
|
|
||||||
<!-- Tarjeta tab -->
|
<!-- Tarjeta tab -->
|
||||||
<div id="pane-card" class="payment-tab-content active"
|
<div id="pane-card" class="payment-tab-content active"
|
||||||
role="tabpanel" aria-labelledby="tab-card" tabindex="0">
|
role="tabpanel" aria-labelledby="tab-card">
|
||||||
{% if saved_cards %}
|
{% if saved_cards %}
|
||||||
<fieldset class="mb-3">
|
<fieldset class="mb-3">
|
||||||
<legend class="fw-semibold fs-6 mb-2">Selección de tarjeta</legend>
|
<legend class="fw-semibold fs-6 mb-2">Selección de tarjeta</legend>
|
||||||
@@ -164,8 +164,8 @@
|
|||||||
|
|
||||||
<div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}>
|
<div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Número de tarjeta</label>
|
<label id="label-card-number" class="form-label">Número de tarjeta <input type="hidden"></label>
|
||||||
<div id="card-element"></div>
|
<div id="card-element" aria-labelledby="label-card-number"></div>
|
||||||
<div id="card-errors" role="alert"></div>
|
<div id="card-errors" role="alert"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check mb-3">
|
<div class="form-check mb-3">
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
|
|
||||||
<!-- PayPal tab -->
|
<!-- PayPal tab -->
|
||||||
<div id="pane-paypal" class="payment-tab-content"
|
<div id="pane-paypal" class="payment-tab-content"
|
||||||
role="tabpanel" aria-labelledby="tab-paypal" tabindex="0">
|
role="tabpanel" aria-labelledby="tab-paypal">
|
||||||
{% if saved_paypal %}
|
{% if saved_paypal %}
|
||||||
<div class="alert alert-light border mb-3">
|
<div class="alert alert-light border mb-3">
|
||||||
<small class="text-muted">Cuenta PayPal guardada:</small>
|
<small class="text-muted">Cuenta PayPal guardada:</small>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 20px;">
|
<th align="center" style="padding: 20px;">
|
||||||
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
|
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="background-color: #007bff; padding: 40px;">
|
<th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
|
||||||
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido bloqueada</h1>
|
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido bloqueada</h1>
|
||||||
</td>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 40px">
|
<td align="center" style="padding: 40px">
|
||||||
@@ -22,6 +22,6 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 20px;">
|
<th scope="col" align="center" style="padding: 20px;">
|
||||||
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
|
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="background-color: #007bff; padding: 40px;">
|
<th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
|
||||||
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1>
|
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1>
|
||||||
</td>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 30px; font-family: sans-serif; line-height: 1.5; color: #444444;">
|
<td style="padding: 30px; font-family: sans-serif; line-height: 1.5; color: #444444;">
|
||||||
@@ -16,6 +16,6 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 20px;">
|
<th scope="col" align="center" style="padding: 20px;">
|
||||||
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
|
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="background-color: #007bff; padding: 40px;">
|
<th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
|
||||||
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1>
|
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1>
|
||||||
</td>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 40px">
|
<td align="center" style="padding: 40px">
|
||||||
@@ -22,6 +22,6 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
<table border="0" cellpadding="0" style="width: 100%;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 20px;">
|
<th scope="col" align="center" style="padding: 20px;">
|
||||||
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
|
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="background-color: #007bff; padding: 40px;">
|
<th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
|
||||||
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido desbloqueada</h1>
|
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido desbloqueada</h1>
|
||||||
</td>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 40px">
|
<td align="center" style="padding: 40px">
|
||||||
@@ -22,6 +22,6 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 20px;">
|
<th scope="col" align="center" style="padding: 20px;">
|
||||||
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
|
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="background-color: #007bff; padding: 40px;">
|
<th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
|
||||||
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1>
|
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1>
|
||||||
</td>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 40px">
|
<td align="center" style="padding: 40px">
|
||||||
@@ -21,6 +21,6 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if producto.primary_image %}
|
{% if producto.primary_image %}
|
||||||
<img src="{{ producto.primary_image.image.url }}" alt="{{ producto.primary_image.alt|default:producto.name }}" class="rounded" style="width: 200px; height: 200px; object-fit: cover;">
|
<img src="{{ producto.primary_image.image.url }}" alt="{{ producto.primary_image.alt|default:producto.name }} - imagen principal" class="rounded" style="width: 200px; height: 200px; object-fit: cover;">
|
||||||
<p class="mt-2 text-muted mb-0">Esta imagen no se puede cambiar desde aquí.</p>
|
<p class="mt-2 text-muted mb-0">Esta imagen no se puede cambiar desde aquí.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted">No hay imagen principal asignada.</p>
|
<p class="text-muted">No hay imagen principal asignada.</p>
|
||||||
|
|||||||
@@ -62,4 +62,101 @@
|
|||||||
{{ product.description }}
|
{{ product.description }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<h4 class="mb-3">Valoraciones</h4>
|
||||||
|
<div id="reviews-summary" class="mb-4">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="fs-4" id="average-rating">0.0</div>
|
||||||
|
<div>
|
||||||
|
<div id="stars-display"></div>
|
||||||
|
<small class="text-muted" id="reviews-count">0 valoraciones</small>
|
||||||
|
</div>
|
||||||
|
{% if can_review %}
|
||||||
|
<a href="{% url 'add_review' product.id %}" class="btn btn-sm btn-outline-primary ms-auto">Valorar este producto</a>
|
||||||
|
{% elif user_has_review %}
|
||||||
|
<div class="ms-auto">
|
||||||
|
<a href="{% url 'add_review' product.id %}" class="btn btn-sm btn-outline-primary">Editar mi valoración</a>
|
||||||
|
<form method="post" action="{% url 'delete_review' user_review_id %}" style="display:inline;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('¿Eliminar esta valoración?');">Eliminar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% elif user.is_authenticated %}
|
||||||
|
<span class="text-muted ms-auto">Solo puedes valorar productos que hayas comprado</span>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'login' %}?next={% url 'producto' product.id %}" class="btn btn-sm btn-outline-primary ms-auto">Inicia sesión para valorar</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="reviews-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
async function loadReviews() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("{% url 'product_reviews' product.id %}");
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('average-rating').textContent = data.average_rating;
|
||||||
|
document.getElementById('reviews-count').textContent = data.reviews_count + ' valoraciones';
|
||||||
|
|
||||||
|
let starsHtml = '';
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
starsHtml += `<span class="${i <= Math.round(data.average_rating) ? 'text-warning' : 'text-secondary'}">★</span>`;
|
||||||
|
}
|
||||||
|
document.getElementById('stars-display').innerHTML = starsHtml;
|
||||||
|
|
||||||
|
const reviewsList = document.getElementById('reviews-list');
|
||||||
|
if (data.reviews.length === 0) {
|
||||||
|
reviewsList.innerHTML = '<p class="text-muted">Aún no hay valoraciones para este producto.</p>';
|
||||||
|
} else {
|
||||||
|
let reviewsHtml = '';
|
||||||
|
data.reviews.forEach(review => {
|
||||||
|
let imagesHtml = '';
|
||||||
|
if (review.images && review.images.length > 0) {
|
||||||
|
imagesHtml = '<div class="mt-2">';
|
||||||
|
review.images.forEach(img => {
|
||||||
|
imagesHtml += `<img src="${img.image}" class="img-thumbnail me-1" style="max-width: 80px; max-height: 80px;" alt="">`;
|
||||||
|
});
|
||||||
|
imagesHtml += '</div>';
|
||||||
|
}
|
||||||
|
const actionsHtml = review.is_owner
|
||||||
|
? `<div class="mt-2">
|
||||||
|
<a href="/tienda/producto/${review.id}/valorar/" class="btn btn-sm btn-outline-primary me-1">Editar</a>
|
||||||
|
<form method="post" action="/tienda/producto/${review.id}/valorar/eliminar/" style="display:inline;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('¿Eliminar esta valoración?');">Eliminar</button>
|
||||||
|
</form>
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
reviewsHtml += `
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<strong>${review.user}</strong>
|
||||||
|
<span class="text-warning ms-1">${'★'.repeat(review.rating)}</span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">${new Date(review.created_at).toLocaleDateString('es-ES')}</small>
|
||||||
|
</div>
|
||||||
|
<h6 class="mt-2">${review.title}</h6>
|
||||||
|
<p class="mb-1">${review.content}</p>
|
||||||
|
${imagesHtml}
|
||||||
|
${actionsHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
reviewsList.innerHTML = reviewsHtml;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading reviews:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadReviews();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+5
-4
@@ -16,6 +16,7 @@ from .models import (
|
|||||||
)
|
)
|
||||||
from .forms import UserRegisterForm, UserLoginForm, EditProfileForm, ChangePasswordForm, ShippingAddressForm, ResetPasswordForm, ResetPasswordPhase2Form
|
from .forms import UserRegisterForm, UserLoginForm, EditProfileForm, ChangePasswordForm, ShippingAddressForm, ResetPasswordForm, ResetPasswordPhase2Form
|
||||||
from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX
|
from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX
|
||||||
|
import secrets
|
||||||
import string
|
import string
|
||||||
import random
|
import random
|
||||||
|
|
||||||
@@ -335,7 +336,7 @@ class VerificationCodeModelTests(TestCase):
|
|||||||
"""50 códigos pueden crearse sin conflictos."""
|
"""50 códigos pueden crearse sin conflictos."""
|
||||||
codes = []
|
codes = []
|
||||||
for i in range(50):
|
for i in range(50):
|
||||||
mode = random.choice([
|
mode = secrets.choice([
|
||||||
VerificationCode.VerificationModes.VERIFY_ACCOUNT,
|
VerificationCode.VerificationModes.VERIFY_ACCOUNT,
|
||||||
VerificationCode.VerificationModes.RESET_PASSWORD
|
VerificationCode.VerificationModes.RESET_PASSWORD
|
||||||
])
|
])
|
||||||
@@ -377,7 +378,7 @@ class CategoryModelTests(TestCase):
|
|||||||
"""100 categorías pueden crearse sin problemas."""
|
"""100 categorías pueden crearse sin problemas."""
|
||||||
categories = []
|
categories = []
|
||||||
for i in range(100):
|
for i in range(100):
|
||||||
cat = Category.objects.create(name=f"Category_{i}_{random.randint(1000, 9999)}")
|
cat = Category.objects.create(name=f"Category_{i}_{1000 + secrets.randbelow(9000)}")
|
||||||
categories.append(cat)
|
categories.append(cat)
|
||||||
|
|
||||||
self.assertEqual(len(categories), 100)
|
self.assertEqual(len(categories), 100)
|
||||||
@@ -1786,7 +1787,7 @@ class EndpointViewTests(TestCase):
|
|||||||
self.assertTrue(OrderMessage.objects.filter(order_item=item, sender=self.seller).exists())
|
self.assertTrue(OrderMessage.objects.filter(order_item=item, sender=self.seller).exists())
|
||||||
|
|
||||||
delete_get = self.client.get(reverse("borrar_producto", args=[created.id]))
|
delete_get = self.client.get(reverse("borrar_producto", args=[created.id]))
|
||||||
self.assertEqual(delete_get.status_code, 302)
|
self.assertEqual(delete_get.status_code, 405)
|
||||||
delete_post = self.client.post(reverse("borrar_producto", args=[created.id]))
|
delete_post = self.client.post(reverse("borrar_producto", args=[created.id]))
|
||||||
self.assertEqual(delete_post.status_code, 302)
|
self.assertEqual(delete_post.status_code, 302)
|
||||||
self.assertFalse(Product.objects.filter(id=created.id).exists())
|
self.assertFalse(Product.objects.filter(id=created.id).exists())
|
||||||
@@ -2068,7 +2069,7 @@ class EndpointViewTests(TestCase):
|
|||||||
self.assertEqual(new_address.full_name, "Comprador Dos Editado")
|
self.assertEqual(new_address.full_name, "Comprador Dos Editado")
|
||||||
|
|
||||||
delete_get = self.client.get(reverse("eliminar_direccion", args=[new_address.id]))
|
delete_get = self.client.get(reverse("eliminar_direccion", args=[new_address.id]))
|
||||||
self.assertEqual(delete_get.status_code, 302)
|
self.assertEqual(delete_get.status_code, 405)
|
||||||
delete_post = self.client.post(reverse("eliminar_direccion", args=[new_address.id]))
|
delete_post = self.client.post(reverse("eliminar_direccion", args=[new_address.id]))
|
||||||
self.assertEqual(delete_post.status_code, 302)
|
self.assertEqual(delete_post.status_code, 302)
|
||||||
self.assertFalse(ShippingAddress.objects.filter(id=new_address.id).exists())
|
self.assertFalse(ShippingAddress.objects.filter(id=new_address.id).exists())
|
||||||
|
|||||||
+4
-1
@@ -68,5 +68,8 @@ urlpatterns = [
|
|||||||
path("sobre-nosotros", views.sobre_nosotros, name="sobre_nosotros"),
|
path("sobre-nosotros", views.sobre_nosotros, name="sobre_nosotros"),
|
||||||
path("ayuda", views.ayuda, name="ayuda"),
|
path("ayuda", views.ayuda, name="ayuda"),
|
||||||
path("reset-password", views.reset_password, name="reset_password"),
|
path("reset-password", views.reset_password, name="reset_password"),
|
||||||
path("reset-password-phase2/<str:code>", views.reset_password_phase2, name="reset_password_phase2")
|
path("reset-password-phase2/<str:code>", views.reset_password_phase2, name="reset_password_phase2"),
|
||||||
|
path("producto/<int:product_id>/valorar/", views.add_review, name="add_review"),
|
||||||
|
path("valoracion/<int:review_id>/eliminar/", views.delete_review, name="delete_review"),
|
||||||
|
path("api/producto/<int:product_id>/valoraciones/", views.product_reviews, name="product_reviews"),
|
||||||
]
|
]
|
||||||
|
|||||||
+2
-26
@@ -4,30 +4,6 @@ from django.conf import settings
|
|||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("email.system")
|
logger = logging.getLogger("email.system")
|
||||||
#
|
|
||||||
#def send_email(dest: str, title: str, body: str):
|
|
||||||
# context = ssl.create_default_context()
|
|
||||||
# try:
|
|
||||||
# with smtplib.SMTP(settings.SMTP_ENDPOINT, settings.SMTP_PORT) as server:
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# server.ehlo()
|
|
||||||
# server.starttls(context=context)
|
|
||||||
# server.ehlo()
|
|
||||||
# server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)
|
|
||||||
#
|
|
||||||
# message = """\
|
|
||||||
#Subject: {}
|
|
||||||
#{}
|
|
||||||
# """.format(title, body)
|
|
||||||
# server.sendmail(settings.SMTP_EMAIL, dest, message)
|
|
||||||
# logger.info("EMAIL_SENT to=%s subject=%s", dest, title)
|
|
||||||
#
|
|
||||||
# except Exception as e:
|
|
||||||
# logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e))
|
|
||||||
# return (False, e)
|
|
||||||
#
|
|
||||||
# return (True,)
|
|
||||||
|
|
||||||
def send_email(dest: str, title: str, body: str):
|
def send_email(dest: str, title: str, body: str):
|
||||||
try:
|
try:
|
||||||
@@ -40,7 +16,7 @@ def send_email(dest: str, title: str, body: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info("EMAIL_SENT to=%s subject=%s", dest, title)
|
logger.info("EMAIL_SENT to=%s subject=%s", dest, title)
|
||||||
return (True,)
|
return (True, None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e))
|
logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e))
|
||||||
return (False, e)
|
return (False, e)
|
||||||
@@ -57,7 +33,7 @@ def send_hemail(dest: str, title: str, body: str, nbody: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info("EMAIL_SENT to=%s subject=%s", dest, title)
|
logger.info("EMAIL_SENT to=%s subject=%s", dest, title)
|
||||||
return (True,)
|
return (True, None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e))
|
logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e))
|
||||||
return (False, e)
|
return (False, e)
|
||||||
+385
-246
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" },
|
{ url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asgiref"
|
name = "asgiref"
|
||||||
version = "3.11.1"
|
version = "3.11.1"
|
||||||
@@ -324,6 +333,28 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d5/9d/9a0ba39f33574994e5b33aea55a68e8fad72b8dd923a82300e4e91774f59/django_compressor-4.6.0-py3-none-any.whl", hash = "sha256:6e7b21020a0d86272c5e37000c33accc4ebeb77394a3dd86d775a09aae7aade4", size = 96828, upload-time = "2025-11-10T13:12:10.001Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/9d/9a0ba39f33574994e5b33aea55a68e8fad72b8dd923a82300e4e91774f59/django_compressor-4.6.0-py3-none-any.whl", hash = "sha256:6e7b21020a0d86272c5e37000c33accc4ebeb77394a3dd86d775a09aae7aade4", size = 96828, upload-time = "2025-11-10T13:12:10.001Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-environ"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/18/3c/60983e6ec9b24a8d8588eecebfd21123cba980bce0a905807a27692f0860/django_environ-0.13.0.tar.gz", hash = "sha256:6c401e4c219442c2c4588c2116d5292b5484a6f69163ed09cd41f3943bfb645f", size = 63529, upload-time = "2026-02-18T01:08:08.791Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/00/3767393ece946084e1c6830a33ffb8e39d68642e27ad5ac7d4c8bd5de866/django_environ-0.13.0-py3-none-any.whl", hash = "sha256:37799d14cd78222c6fd8298e48bfe17965ff8e586091ad66a463e52e0e7b799e", size = 20682, upload-time = "2026-02-18T01:08:07.359Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-ninja"
|
||||||
|
version = "1.6.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d5/7c/3307e17b872f545c88314b2737a22f965785dfb5a120d739b0131d0492c3/django_ninja-1.6.2.tar.gz", hash = "sha256:d56ae5aa4791068ef4ac9a66cfdf2fc11f507413ded35abb79c51d0d52ad6412", size = 3685599, upload-time = "2026-03-18T20:06:47.284Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/0c/25f72060a39632fbd2d90e9c8b6052a09cd45b0598fc06c0758d313f0052/django_ninja-1.6.2-py3-none-any.whl", hash = "sha256:20095f5900bada22ea00cf1a58af50bdb285b2354c61a9d9b47d0dc89ac462d6", size = 2374994, upload-time = "2026-03-18T20:06:45.676Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-redis"
|
name = "django-redis"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -407,11 +438,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.13"
|
version = "3.15"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -514,6 +545,8 @@ dependencies = [
|
|||||||
{ name = "celery" },
|
{ name = "celery" },
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
{ name = "django-compressor" },
|
{ name = "django-compressor" },
|
||||||
|
{ name = "django-environ" },
|
||||||
|
{ name = "django-ninja" },
|
||||||
{ name = "django-redis" },
|
{ name = "django-redis" },
|
||||||
{ name = "django-storages", extra = ["s3"] },
|
{ name = "django-storages", extra = ["s3"] },
|
||||||
{ name = "fpdf2" },
|
{ name = "fpdf2" },
|
||||||
@@ -531,6 +564,8 @@ requires-dist = [
|
|||||||
{ name = "celery", specifier = "==5.6.3" },
|
{ name = "celery", specifier = "==5.6.3" },
|
||||||
{ name = "django", specifier = "==6.0.5" },
|
{ name = "django", specifier = "==6.0.5" },
|
||||||
{ name = "django-compressor", specifier = "==4.6.0" },
|
{ name = "django-compressor", specifier = "==4.6.0" },
|
||||||
|
{ name = "django-environ", specifier = ">=0.13.0" },
|
||||||
|
{ name = "django-ninja", specifier = ">=1.6.2" },
|
||||||
{ name = "django-redis", specifier = "==6.0.0" },
|
{ name = "django-redis", specifier = "==6.0.0" },
|
||||||
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
|
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
|
||||||
{ name = "fpdf2", specifier = "==2.8.7" },
|
{ name = "fpdf2", specifier = "==2.8.7" },
|
||||||
@@ -538,7 +573,7 @@ requires-dist = [
|
|||||||
{ name = "paypalrestsdk", specifier = "==1.13.3" },
|
{ name = "paypalrestsdk", specifier = "==1.13.3" },
|
||||||
{ name = "pillow", specifier = "==12.2.0" },
|
{ name = "pillow", specifier = "==12.2.0" },
|
||||||
{ name = "psycopg2-binary", specifier = "==2.9.12" },
|
{ name = "psycopg2-binary", specifier = "==2.9.12" },
|
||||||
{ name = "requests", specifier = "==2.33.1" },
|
{ name = "requests", specifier = "==2.34.2" },
|
||||||
{ name = "stripe", specifier = "==15.1.0" },
|
{ name = "stripe", specifier = "==15.1.0" },
|
||||||
{ name = "whitenoise", specifier = "==6.12.0" },
|
{ name = "whitenoise", specifier = "==6.12.0" },
|
||||||
]
|
]
|
||||||
@@ -571,6 +606,62 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.13.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.46.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyopenssl"
|
name = "pyopenssl"
|
||||||
version = "26.2.0"
|
version = "26.2.0"
|
||||||
@@ -626,7 +717,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.33.1"
|
version = "2.34.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
@@ -634,9 +725,9 @@ dependencies = [
|
|||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -711,6 +802,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tzdata"
|
name = "tzdata"
|
||||||
version = "2026.2"
|
version = "2026.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user